rimlike/scenes/storyteller/event_catalog.gd
megaproxy f30c7a858b reskin wolf as hyena — visual + lean-in flavor
Replaces the procedural brown-rectangle "wolf" with the CraftPix Free
Desert Enemy Sprite Sheets hyena. 48×48, side-view, 2-direction via
horizontal flip. AnimatedSprite2D mounted in Wolf.setup() and
Wolf.from_dict() (same pattern as pawn reskin Slice 1). Anims: idle
(4f @ 5fps) + walk (6f @ 10fps). Attack/hurt/death anims skipped for
MVP scope.

Player-facing copy renamed wolf→hyena in strings.gd (6 entries) and
event_catalog.gd (3 EventDef title/body fields). Internal identifiers
(class Wolf, World.wolves, EventBus.wolf_spawned, save class_id
&"wolf", event IDs like &"lone_wolf") stay the same for save compat
— see header comment in wolf.gd.

MCP runtime verified: hyena AnimatedSprite2D mounted on spawn, idle
anim plays, storyteller modal renders "Lone Hyena — A starving hyena
circles your livestock."

Sprites: CraftPix Free Desert Enemy Sprite Sheets.
License: CraftPix Free (commercial OK, attribution appreciated).
https://free-game-assets.itch.io/free-enemy-sprite-sheets-pixel-art

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:03:46 +01:00

637 lines
25 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name EventCatalog
## Phase 15 — static registry that constructs all 25 EventDefs from the
## 25-prompt corpus (docs/design.md §"Prompt corpus") and registers them
## with the Storyteller autoload at world boot.
##
## Usage (in world.gd _ready, after systems are up):
## EventCatalog.register_all(Storyteller)
##
## Design decisions (see memory.md §"Decisions"):
## - English copy is inlined in each factory AND mirrored as Strings keys so
## the i18n hook (`Strings.t(key)`) exists today without requiring it.
## EventDef carries the rendered English string; locale switching can swap
## it via the string table later without touching EventDef or this catalog.
## - Effect helpers (Phase 17 — all wired):
## _spawn_wanderer_pawn — emits EventBus.request_pawn_spawn; World scene handles.
## _apply_buff_next_n_jobs — registers a timed yield buff on Storyteller.
## _spawn_wolves — emits EventBus.request_wolf_spawn; WolfSpawner handles.
## _apply_pawn_status — applies a status to a random pawn.
## _apply_colony_mood_thought — applies a thought to all living pawns.
##
## The 25-event count is the Phase 15 gate. Do not commit with != 25.
## Construct all 25 EventDefs and register them with `storyteller_node`.
## Called once from world._ready(), after World systems are mounted.
static func register_all(storyteller_node: Node) -> void:
var defs: Array = [
# ── Nudges (1-4) ──────────────────────────────────────────────────
_event_01_first_beds(),
_event_02_empty_larder(),
_event_03_no_fire(),
_event_04_walls(),
# ── Seasonal (5-8) ────────────────────────────────────────────────
_event_05_spring_awakens(),
_event_06_summers_heat(),
_event_07_autumns_harvest(),
_event_08_winters_edge(),
# ── Wanderers (9-12) ──────────────────────────────────────────────
_event_09_a_traveler(),
_event_10_the_refugee_family(),
_event_11_the_old_soldier(),
_event_12_the_wandering_healer(),
# ── Threats (13-16) ───────────────────────────────────────────────
_event_13_wolves_at_the_edge(),
_event_14_lone_wolf(),
_event_15_pack_hunt(),
_event_16_bandit_scouts(),
# ── Disease (17-19) ───────────────────────────────────────────────
_event_17_fever(),
_event_18_a_bad_cut(),
_event_19_the_sleeplessness(),
# ── Resource (20-22) ──────────────────────────────────────────────
_event_20_bountiful_harvest(),
_event_21_lumberjacks_luck(),
_event_22_veins_of_iron(),
# ── Lore (23-24) ──────────────────────────────────────────────────
_event_23_strange_stones(),
_event_24_an_old_map(),
# ── Milestone (25) ────────────────────────────────────────────────
_event_25_one_year_survived(),
]
for def in defs:
storyteller_node.register_event(def)
Audit.log("storyteller", "%d events registered" % defs.size())
# ── Nudge factories ─────────────────────────────────────────────────────────
static func _event_01_first_beds() -> EventDef:
var d := EventDef.new()
d.id = &"first_beds"
d.title = "First Beds"
d.body = "Your settlers slept on the cold ground again. They are starting to ache."
d.category = EventDef.Category.NUDGE
d.display = EventDef.Display.BANNER
d.cooldown_days = 3
d.base_weight = 3.0 # high weight — active whenever trigger is true
d.auto_pause = false
# Trigger: day 2+, fewer beds than pawns.
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 2 and World.beds.size() < World.pawns.size()
d.on_resolve = func(_c: int) -> void:
pass # nudge — dismiss only, no mechanical effect
return d
static func _event_02_empty_larder() -> EventDef:
var d := EventDef.new()
d.id = &"empty_larder"
d.title = "Empty Larder"
d.body = "The larder is bare. Spring won't last forever."
d.category = EventDef.Category.NUDGE
d.display = EventDef.Display.BANNER
d.cooldown_days = 3
d.base_weight = 2.0
d.auto_pause = false
# Trigger: day 3+, no crops in the world (proxy for no farm zone).
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 3 and World.crops.is_empty()
d.on_resolve = func(_c: int) -> void:
pass
return d
static func _event_03_no_fire() -> EventDef:
var d := EventDef.new()
d.id = &"no_fire"
d.title = "No Fire"
d.body = "Without a hearth, the cold will bite by night."
d.category = EventDef.Category.NUDGE
d.display = EventDef.Display.BANNER
d.cooldown_days = 3
d.base_weight = 2.0
d.auto_pause = false
# Trigger: day 4+, no light sources (proxy for no hearth/torch).
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 4 and World.light_sources.is_empty()
d.on_resolve = func(_c: int) -> void:
pass
return d
static func _event_04_walls() -> EventDef:
var d := EventDef.new()
d.id = &"walls"
d.title = "Walls?"
d.body = "Sleeping under stars is romantic until the hyenas arrive."
d.category = EventDef.Category.NUDGE
d.display = EventDef.Display.BANNER
d.cooldown_days = 4
d.base_weight = 2.0
d.auto_pause = false
# Trigger: day 5+, build queue has no wall entities and no completed walls.
# Proxy: no doors means no enclosed building.
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 5 and World.doors.is_empty()
d.on_resolve = func(_c: int) -> void:
pass
return d
# ── Seasonal factories ──────────────────────────────────────────────────────
static func _event_05_spring_awakens() -> EventDef:
var d := EventDef.new()
d.id = &"spring_awakens"
d.title = "Spring Awakens"
d.body = "The thaw runs in every stream. Crops will grow fast now."
d.category = EventDef.Category.SEASONAL
d.display = EventDef.Display.BANNER
d.cooldown_days = 12
d.base_weight = 1.0
d.auto_pause = false
# Trigger: first tick of spring. Storyteller fires this once per season boundary.
d.trigger_predicate = func() -> bool:
return Clock.current_season() == Clock.SEASON_SPRING
# Effect: +20% crop growth speed for 12 days (rest of spring season).
d.on_resolve = func(_c: int) -> void:
Storyteller.add_buff(&"crop_growth", 1.20, 12)
Audit.log("storyteller", "spring_awakens: +20%% crop growth for 12 days")
return d
static func _event_06_summers_heat() -> EventDef:
var d := EventDef.new()
d.id = &"summers_heat"
d.title = "Summer's Heat"
d.body = "The sun beats down. Unsheltered work will tire faster."
d.category = EventDef.Category.SEASONAL
d.display = EventDef.Display.BANNER
d.cooldown_days = 12
d.base_weight = 1.0
d.auto_pause = false
d.trigger_predicate = func() -> bool:
return Clock.current_season() == Clock.SEASON_SUMMER
# Effect: sleep-need decays 15% faster for 12 days (summer heat fatigue).
# Consumer: SleepProvider / Pawn._tick_energy checks Storyteller.get_buff_multiplier(&"sleep_decay").
d.on_resolve = func(_c: int) -> void:
Storyteller.add_buff(&"sleep_decay", 1.15, 12)
Audit.log("storyteller", "summers_heat: +15%% sleep decay for 12 days")
return d
static func _event_07_autumns_harvest() -> EventDef:
var d := EventDef.new()
d.id = &"autumns_harvest"
d.title = "Autumn's Harvest"
d.body = "The fields are heavy with the last of the year's bounty."
d.category = EventDef.Category.SEASONAL
d.display = EventDef.Display.BANNER
d.cooldown_days = 12
d.base_weight = 1.0
d.auto_pause = false
d.trigger_predicate = func() -> bool:
return Clock.current_season() == Clock.SEASON_AUTUMN
# Effect: +25% harvest yield for 12 days (autumn bonus).
# Consumer: Plant/HarvestToil checks Storyteller.get_buff_multiplier(&"harvest_yield").
d.on_resolve = func(_c: int) -> void:
Storyteller.add_buff(&"harvest_yield", 1.25, 12)
Audit.log("storyteller", "autumns_harvest: +25%% harvest yield for 12 days")
return d
static func _event_08_winters_edge() -> EventDef:
var d := EventDef.new()
d.id = &"winters_edge"
d.title = "Winter's Edge"
d.body = "Frost has come. The road is closed; you are alone."
d.category = EventDef.Category.SEASONAL
d.display = EventDef.Display.BANNER
d.cooldown_days = 12
d.base_weight = 1.0
d.auto_pause = false
d.trigger_predicate = func() -> bool:
return Clock.current_season() == Clock.SEASON_WINTER
# Effect: raise threat weight ×1.4 for 12 days (winter pressure); suppress
# wanderer weight ×0.2 for 5 days (roads closed).
d.on_resolve = func(_c: int) -> void:
Storyteller.add_buff(&"threat_weight", 1.40, 12)
Storyteller.add_buff(&"wanderer_weight", 0.20, 5)
Audit.log("storyteller", "winters_edge: threat_weight ×1.4 for 12d, wanderer_weight ×0.2 for 5d")
return d
# ── Wanderer factories ──────────────────────────────────────────────────────
static func _event_09_a_traveler() -> EventDef:
var d := EventDef.new()
d.id = &"a_traveler"
d.title = "A Traveler"
d.body = "A weary traveler stumbles toward your gate. They look hungry. Will you welcome them?"
d.category = EventDef.Category.WANDERER
d.display = EventDef.Display.MODAL
d.cooldown_days = 5
d.base_weight = 1.0
d.choices = ["Welcome", "Send away"]
d.auto_pause = true
# Trigger: ghost state (all pawns gone) OR ~8 days have passed.
d.trigger_predicate = func() -> bool:
return Storyteller.ghost_state or Clock.current_day() >= 8
# Effect: Welcome → +1 pawn (stub); Send away → no effect.
d.on_resolve = func(c: int) -> void:
if c == 0:
_spawn_wanderer_pawn({})
return d
static func _event_10_the_refugee_family() -> EventDef:
var d := EventDef.new()
d.id = &"the_refugee_family"
d.title = "The Refugee Family"
d.body = "A family fleeing bandits arrives. They have nothing, but they would work hard."
d.category = EventDef.Category.WANDERER
d.display = EventDef.Display.MODAL
d.cooldown_days = 8
d.base_weight = 1.0
d.choices = ["Welcome", "Refuse"]
d.auto_pause = true
# Trigger: post-day-15.
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 15
# Effect: Welcome → +2 pawns low skills (stub); Refuse → 2 mood colony 1 day (stub).
d.on_resolve = func(c: int) -> void:
if c == 0:
_spawn_wanderer_pawn({})
_spawn_wanderer_pawn({})
else:
_apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.refused_refugees())
return d
static func _event_11_the_old_soldier() -> EventDef:
var d := EventDef.new()
d.id = &"the_old_soldier"
d.title = "The Old Soldier"
d.body = "A retired soldier offers his blade for a place by your fire. Combat 8, but old and tired."
d.category = EventDef.Category.WANDERER
d.display = EventDef.Display.MODAL
d.cooldown_days = 8
d.base_weight = 1.0
d.choices = ["Welcome", "Refuse"]
d.auto_pause = true
# Trigger: post-day-20.
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 20
d.on_resolve = func(c: int) -> void:
if c == 0:
_spawn_wanderer_pawn({"combat": 8})
return d
static func _event_12_the_wandering_healer() -> EventDef:
var d := EventDef.new()
d.id = &"the_wandering_healer"
d.title = "The Wandering Healer"
d.body = "A traveling healer asks for shelter. She brings knowledge of medicine."
d.category = EventDef.Category.WANDERER
d.display = EventDef.Display.MODAL
d.cooldown_days = 8
d.base_weight = 1.0
d.choices = ["Welcome", "Refuse"]
d.auto_pause = true
# Trigger: any pawn has a status (proxy for sick/injured — exact sick() status ships Phase 17).
d.trigger_predicate = func() -> bool:
for p in World.pawns:
if not p.statuses.is_empty():
return true
return false
d.on_resolve = func(c: int) -> void:
if c == 0:
_spawn_wanderer_pawn({"medicine": 6})
return d
# ── Threat factories ─────────────────────────────────────────────────────────
static func _event_13_wolves_at_the_edge() -> EventDef:
var d := EventDef.new()
d.id = &"wolves_at_the_edge"
d.title = "Hyenas at the Edge"
d.body = "Hyenas howl in the distance. They will be here by nightfall."
d.category = EventDef.Category.THREAT
d.display = EventDef.Display.MODAL
d.cooldown_days = 3
d.base_weight = 1.0
d.choices = ["Prepare"]
d.auto_pause = true
# Trigger: anytime after day 3 (season-weighting handled by Storyteller tension).
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 3
# Effect: spawn 1-3 wolves via EventBus.request_wolf_spawn.
d.on_resolve = func(_c: int) -> void:
_spawn_wolves(randi_range(1, 3))
return d
static func _event_14_lone_wolf() -> EventDef:
var d := EventDef.new()
d.id = &"lone_wolf"
d.title = "Lone Hyena"
d.body = "A starving hyena circles your livestock."
d.category = EventDef.Category.THREAT
d.display = EventDef.Display.MODAL
d.cooldown_days = 3
d.base_weight = 1.5 # slightly higher — low-threat random, more common
d.choices = ["Prepare"]
d.auto_pause = true
# Trigger: anytime (low-threat random).
d.trigger_predicate = func() -> bool:
return true
d.on_resolve = func(_c: int) -> void:
_spawn_wolves(1)
return d
static func _event_15_pack_hunt() -> EventDef:
var d := EventDef.new()
d.id = &"pack_hunt"
d.title = "Pack Hunt"
d.body = "A hunting pack moves through the forest. They smell your colony."
d.category = EventDef.Category.THREAT
d.display = EventDef.Display.MODAL
d.cooldown_days = 5
d.base_weight = 1.0
d.choices = ["Prepare"]
d.auto_pause = true
# Trigger: post-day-30.
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 30
d.on_resolve = func(_c: int) -> void:
_spawn_wolves(randi_range(4, 6))
return d
static func _event_16_bandit_scouts() -> EventDef:
var d := EventDef.new()
d.id = &"bandit_scouts"
d.title = "Bandit Scouts"
d.body = "Strange figures watched from the treeline at dusk. Bandits, perhaps."
d.category = EventDef.Category.THREAT
d.display = EventDef.Display.MODAL
d.cooldown_days = 5
d.base_weight = 1.0
d.choices = ["Stay alert"]
d.auto_pause = true
# Trigger: post-day-25.
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 25
# Effect: additional threat weight ×1.3 for 3 days ("they'll be back").
# The base THREAT fire already bumps tension +15; this adds a short-window
# bias toward more threat events as a narrative follow-up.
d.on_resolve = func(_c: int) -> void:
Storyteller.add_buff(&"threat_weight", 1.30, 3)
Audit.log("storyteller", "bandit_scouts: threat_weight ×1.3 for 3 days")
return d
# ── Disease factories ────────────────────────────────────────────────────────
static func _event_17_fever() -> EventDef:
var d := EventDef.new()
d.id = &"fever"
d.title = "Fever"
d.body = "%pawn% woke with a fever. The sickness may spread."
d.category = EventDef.Category.DISEASE
d.display = EventDef.Display.MODAL
d.cooldown_days = 4
d.base_weight = 1.0
d.choices = ["Treat them"]
d.auto_pause = true
# Trigger: random after ~30 days.
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 30 and not World.pawns.is_empty()
# Effect: Sick status (severity 2) on one random pawn.
d.on_resolve = func(_c: int) -> void:
_apply_pawn_status(func() -> Status: return StatusCatalog.sick(2))
return d
static func _event_18_a_bad_cut() -> EventDef:
var d := EventDef.new()
d.id = &"a_bad_cut"
d.title = "A Bad Cut"
d.body = "%pawn% gashed their hand chopping wood. The wound looks deep."
d.category = EventDef.Category.DISEASE
d.display = EventDef.Display.MODAL
d.cooldown_days = 4
d.base_weight = 1.0
d.choices = ["Treat them"]
d.auto_pause = true
# Trigger: any tree chopped and low chance (proxy: trees exist and day >= 5).
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 5 and not World.trees.is_empty()
# Effect: Bleeding status on a random pawn — real-wired (StatusCatalog.bleeding exists).
d.on_resolve = func(_c: int) -> void:
_apply_pawn_status(func() -> Status: return StatusCatalog.bleeding(1))
return d
static func _event_19_the_sleeplessness() -> EventDef:
var d := EventDef.new()
d.id = &"the_sleeplessness"
d.title = "The Sleeplessness"
d.body = "%pawn% has barely slept. Something weighs on them."
d.category = EventDef.Category.DISEASE
d.display = EventDef.Display.BANNER
d.cooldown_days = 4
d.base_weight = 1.0
d.auto_pause = false
# Trigger: any pawn is tired (has the tired thought/status).
d.trigger_predicate = func() -> bool:
for p in World.pawns:
if p.is_tired():
return true
return false
# Effect: apply tired thought colony-wide (reinforces existing penalty; feels like communal dread).
d.on_resolve = func(_c: int) -> void:
_apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.tired())
return d
# ── Resource factories ───────────────────────────────────────────────────────
static func _event_20_bountiful_harvest() -> EventDef:
var d := EventDef.new()
d.id = &"bountiful_harvest"
d.title = "Bountiful Harvest"
d.body = "Your fields exceeded the season. The granary swells."
d.category = EventDef.Category.RESOURCE
d.display = EventDef.Display.BANNER
d.cooldown_days = 3
d.base_weight = 1.0
d.auto_pause = false
# Trigger: autumn (harvest season) and crops are present.
d.trigger_predicate = func() -> bool:
return Clock.current_season() == Clock.SEASON_AUTUMN and not World.crops.is_empty()
# Effect: +25% harvest yield for ~5 days via Storyteller buff registry.
d.on_resolve = func(_c: int) -> void:
_apply_buff_next_n_jobs(&"harvest", 10, 1.25)
return d
static func _event_21_lumberjacks_luck() -> EventDef:
var d := EventDef.new()
d.id = &"lumberjacks_luck"
d.title = "Lumberjack's Luck"
d.body = "%pawn% found a copse of unusually thick trees."
d.category = EventDef.Category.RESOURCE
d.display = EventDef.Display.BANNER
d.cooldown_days = 3
d.base_weight = 1.0
d.auto_pause = false
# Trigger: trees present (chop job context).
d.trigger_predicate = func() -> bool:
return not World.trees.is_empty()
# Effect: next ~1.5 days of chop jobs +50% wood via Storyteller buff registry.
d.on_resolve = func(_c: int) -> void:
_apply_buff_next_n_jobs(&"chop", 3, 1.5)
return d
static func _event_22_veins_of_iron() -> EventDef:
var d := EventDef.new()
d.id = &"veins_of_iron"
d.title = "Veins of Iron"
d.body = "A miner reports a rich vein, deeper than expected."
d.category = EventDef.Category.RESOURCE
d.display = EventDef.Display.BANNER
d.cooldown_days = 3
d.base_weight = 1.0
d.auto_pause = false
# Trigger: rocks present (mine job context).
d.trigger_predicate = func() -> bool:
return not World.rocks.is_empty()
# Effect: mining yield ×2 for ~1 day via Storyteller buff registry.
d.on_resolve = func(_c: int) -> void:
_apply_buff_next_n_jobs(&"mine", 1, 2.0)
return d
# ── Lore factories ───────────────────────────────────────────────────────────
static func _event_23_strange_stones() -> EventDef:
var d := EventDef.new()
d.id = &"strange_stones"
d.title = "Strange Stones"
d.body = "Settlers report finding carved stones in the wood — older than any memory."
d.category = EventDef.Category.LORE
d.display = EventDef.Display.BANNER
d.cooldown_days = 6
d.base_weight = 1.0
d.auto_pause = false
# Trigger: post-day-15, random.
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 15
d.on_resolve = func(_c: int) -> void:
pass # flavor only
return d
static func _event_24_an_old_map() -> EventDef:
var d := EventDef.new()
d.id = &"an_old_map"
d.title = "An Old Map"
d.body = "%pawn% found a tattered map. Roads to the north, half-faded."
d.category = EventDef.Category.LORE
d.display = EventDef.Display.BANNER
d.cooldown_days = 6
d.base_weight = 1.0
d.auto_pause = false
# Trigger: post-recruit (more than 1 pawn present).
d.trigger_predicate = func() -> bool:
return World.pawns.size() > 1
d.on_resolve = func(_c: int) -> void:
pass # flavor; seeds v2 trade/exploration
return d
# ── Milestone factory ────────────────────────────────────────────────────────
static func _event_25_one_year_survived() -> EventDef:
var d := EventDef.new()
d.id = &"one_year_survived"
d.title = "One Year Survived"
d.body = "A full year. The first frost feels different now — yours is a real settlement."
d.category = EventDef.Category.MILESTONE
d.display = EventDef.Display.BANNER
d.cooldown_days = 30
d.base_weight = 1.0
d.auto_pause = false
# Trigger: end of first winter (day >= 4 seasons × 12 days = 48).
d.trigger_predicate = func() -> bool:
return Clock.current_day() >= 48 and Clock.current_season() == Clock.SEASON_WINTER
# Effect: +6 mood "We made it through a year" colony-wide for 2 in-game days.
d.on_resolve = func(_c: int) -> void:
_apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.we_made_it())
return d
# ── Effect helpers ───────────────────────────────────────────────────────────
## All helpers are static. Stub bodies log via Audit where the underlying
## system is not yet available; real bodies are annotated per-helper.
## Emit EventBus.request_pawn_spawn so the World scene instantiates a new pawn.
## Also applies the hopeful_newcomer thought colony-wide (including the new pawn
## if it is registered before the next thought tick — timing may vary).
## Phase 17 recruit UI wires additional name/trait input; this is the mechanical
## fallback that fires regardless of any future recruit dialog.
static func _spawn_wanderer_pawn(skills: Dictionary = {}) -> void:
EventBus.request_pawn_spawn.emit(skills)
_apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.hopeful_newcomer())
Audit.log("storyteller", "_spawn_wanderer_pawn: emitted request_pawn_spawn(skills=%s)" % str(skills))
## Register a timed yield multiplier buff via Storyteller.add_buff().
## `kind` maps to the buff key that job runners query (e.g. &"harvest_yield",
## &"chop_yield", &"mine_yield"). `count` is used to estimate duration in days:
## each job is assumed to take ~0.5 in-game days at average pawn count, so
## duration = max(1, count / 2) days. Consumers call
## Storyteller.get_buff_multiplier(kind) when computing yield.
static func _apply_buff_next_n_jobs(kind: StringName, count: int, multiplier: float) -> void:
var duration: int = maxi(1, count / 2)
Storyteller.add_buff(kind, multiplier, duration)
Audit.log("storyteller", "_apply_buff_next_n_jobs: kind=%s ×%.2f for %d days" % [kind, multiplier, duration])
## Emit EventBus.request_wolf_spawn so WolfSpawner (scene child) picks it up.
## WolfSpawner._on_request_wolf_spawn bypasses darkness/cooldown gates and
## force-spawns immediately. Bus-based wiring — no get_node("/root/...") smell.
static func _spawn_wolves(count: int) -> void:
EventBus.request_wolf_spawn.emit(count)
Audit.log("storyteller", "_spawn_wolves: emitted request_wolf_spawn(count=%d)" % count)
## Apply a status to a random pawn (or a specific pawn if non-null).
## The factory callable returns a fresh Status — mirrors StatusCatalog pattern.
## All statuses in StatusCatalog are now wired (bleeding, sick).
static func _apply_pawn_status(status_factory: Callable, pawn = null) -> void:
var target = pawn
if target == null:
if World.pawns.is_empty():
Audit.log("storyteller", "_apply_pawn_status: no pawns present")
return
target = World.pawns[randi() % World.pawns.size()]
if target.has_method("add_status"):
target.add_status(status_factory.call())
else:
Audit.log("storyteller", "_apply_pawn_status: target missing add_status()")
## Apply a thought to every living pawn in the colony.
## `thought_factory` is a Callable that returns a fresh Thought each time.
## REAL — ThoughtCatalog thoughts and Pawn.add_thought() both exist.
static func _apply_colony_mood_thought(thought_factory: Callable) -> void:
for p in World.pawns:
if p.has_method("add_thought"):
p.add_thought(thought_factory.call())