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 wolves 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 = "Wolves at the Edge" d.body = "Wolves 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 Wolf" d.body = "A starving wolf 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())