rimlike/scenes/storyteller/event_catalog.gd
megaproxy 3da7353387 Phase 15: Storyteller (25 events, daily roll, banner+modal UI)
Three-agent fan-out reusing the contracts-first pattern: Opus pre-wrote
EventDef class + 5 EventBus signals + Storyteller autoload stub before
dispatch. Pattern proven across Phases 12/13/14/15.

EventDef + 25-event corpus (Agent A):
- scenes/storyteller/event_def.gd — data class with id/title/body/
  category/display/cooldown_days/base_weight/choices/auto_pause/
  focus_tile/trigger_predicate/on_resolve
- scenes/storyteller/event_catalog.gd — class_name EventCatalog with
  register_all() dispatcher + 25 _event_NN() static factories covering
  all 8 categories (nudge×4, seasonal×4, wanderer×4, threat×4, disease×3,
  resource×3, lore×2, milestone×1)
- Strings catalog: 50 keys added (event.<id>.title + event.<id>.body)
  + ui.go_there / ui.dismiss for UI buttons
- on_resolve effects: real-wired for a_bad_cut (StatusCatalog.bleeding),
  one_year_survived + refugee_family + sleeplessness (colony mood thoughts);
  stubbed-with-log for wanderer spawns (Phase 17 recruit UI), resource
  buffs (Phase 17 work-buff system), wolf spawn (EventBus signal pending),
  fever (StatusCatalog.sick pending), seasonal effects

Storyteller real implementation (Agent B):
- autoload/storyteller.gd — replaced stub with full logic:
  * Daily 6 AM roll via Clock.phase_changed(&dawn), one-per-day guard
  * Per-event cooldown via _event_last_fired Dict; per-category via
    _category_last_fired Dict + CATEGORY_COOLDOWN_DAYS (nudge=2,
    seasonal=12, wanderer=5, threat=3, disease=4, resource=3, lore=6,
    milestone=30) — both gates must pass
  * Tension model: 0..100, −3/roll decay, +15 on THREAT fire (net +12)
    Category multipliers: THREAT = lerp(2.0, 0.3, t/100),
    RESOURCE = lerp(0.5, 1.5, t/100), others = 1.0
  * State-trigger 3× weight boost when predicate currently true
  * Auto-pause Sim before showing UI for auto_pause events
  * Ghost state: _on_pawn_died flips on World.pawns empty,
    _ghost_wanderer_target_day = today + randi_range(3, 5),
    daily roll bypasses pool and force-fires WANDERER (prefers a_traveler)
  * Full save/load round-trip incl. cooldown dicts (StringName↔String)

Banner + Modal UI (Agent C):
- scenes/ui/storyteller_banner.gd — class_name StorytellerBanner extends
  CanvasLayer (layer 15), top-center under top-bar, 6-sec auto-dismiss
  Timer, tap-to-dismiss-early, internal queue for back-to-back events
- scenes/ui/storyteller_modal.gd — class_name StorytellerModal extends
  CanvasLayer (layer 20), center PanelContainer, full-screen 0.45 dim
  ColorRect, 0/1/2 choice button layouts
- camera_rig.gd: pan_to_tile(tile) public helper using existing
  _centre_on tween slot
- Both UI scenes runtime-instantiated in main.gd as CanvasLayer children
  (no .tscn edit needed)
- %pawn% substitution at display time (World.pawns[0].pawn_name fallback)

Modal auto-hide-on-resolve fix (Opus mid-flight):
- Original Agent C modal only hid on internal button click. Added
  EventBus.storyteller_event_resolved subscriber → _set_visible(false)
  so external resolve_current calls (test scripts, ghost-state auto-fire)
  also dismiss the dialog.

MCP runtime verified across two boots:
- Boot 1: day 0 roll → lone_wolf THREAT, modal 'A starving wolf circles
  your livestock.' with Prepare/Dismiss + auto-pause (tick 1 frozen).
  Resolve → tension 27→42, sim resumed.
- Boot 2: day 0 roll → an_old_map LORE, top-center banner, non-blocking.
  Banner path + modal path both visually confirmed.

Deferred to Phase 17 polish:
- EventBus.request_wolf_spawn signal — wolf-spawn effects log-stub today
- Wanderer recruit UI (modal currently dismisses, pawn add deferred)
- Resource buff system (next-N-jobs multipliers)
- 3+ choice modals (current UI renders first 2)
- .tres event resources (currently code-as-data factories)

Delegation: 3× gdscript-refactor (Sonnet) agents in parallel;
modal-hide fix on Opus; integration + MCP verify on Opus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:01:35 +01:00

622 lines
24 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 below are stubbed with Audit.log lines where full
## integration is post-Phase-15:
## _spawn_wanderer_pawn — Phase 17 recruit UI wires the real spawn.
## _apply_buff_next_n_jobs — Phase 17 work-buff system.
## _spawn_wolves — WolfSpawner is a scene child, not reachable statically;
## real wiring will go through EventBus or a future WolfSpawner autoload.
## _apply_pawn_status (sick) — StatusCatalog.sick() ships Phase 17.
## All real-wired effects are noted per-event below.
##
## 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: stubbed — Phase 17 will apply +10% crop growth for the season.
d.on_resolve = func(_c: int) -> void:
Audit.log("storyteller", "spring_awakens resolved — crop growth buff stubbed (Phase 17)")
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: stubbed — Phase 17 will apply outdoor work 5% speed modifier.
d.on_resolve = func(_c: int) -> void:
Audit.log("storyteller", "summers_heat resolved — outdoor fatigue buff stubbed (Phase 17)")
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: stubbed — Phase 17 will apply +15% harvest yield.
d.on_resolve = func(_c: int) -> void:
Audit.log("storyteller", "autumns_harvest resolved — yield buff stubbed (Phase 17)")
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: no wanderers 5 days + raise threat weight — both stubbed Phase 17.
d.on_resolve = func(_c: int) -> void:
Audit.log("storyteller", "winters_edge resolved — wanderer suppress + threat weight stubbed (Phase 17)")
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.slept_on_floor()) # placeholder thought, Phase 17 adds colony_refused_refugees
Audit.log("storyteller", "refugee_family refused — colony mood penalty stubbed (Phase 17)")
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 (stubbed until WolfSpawner is accessible statically).
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: flavor + threat-likelihood raise — stubbed (v2 raids).
d.on_resolve = func(_c: int) -> void:
Audit.log("storyteller", "bandit_scouts resolved — threat-likelihood raise stubbed (v2 raids)")
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 on random pawn — StatusCatalog.sick() ships Phase 17.
d.on_resolve = func(_c: int) -> void:
Audit.log("storyteller", "fever resolved — StatusCatalog.sick() stubbed (Phase 17)")
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: mood penalty 2 days — apply tired thought colony-wide (stubbed intensity).
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% yield — stubbed, Phase 17 crop-yield multiplier.
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 3 trees +50% wood — stubbed.
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: next mining yield ×2 — stubbed.
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: +5 mood "We made it" colony 2 days — real-wired via colony thought.
d.on_resolve = func(_c: int) -> void:
_apply_colony_mood_thought(func() -> Thought: return ThoughtCatalog.well_rested())
# well_rested() is the closest existing +5 mood thought; Phase 17 adds
# ThoughtCatalog.we_made_it() with correct label + longer duration.
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.
## Instantiate a new pawn at a random map-edge tile with optional skill seed.
## STUB — Phase 17 recruit UI will wire the real spawn via PawnFactory or similar.
static func _spawn_wanderer_pawn(skills: Dictionary = {}) -> void:
Audit.log("storyteller", "_spawn_wanderer_pawn called with skills=%s — stubbed (Phase 17)" % str(skills))
## Flag the next `count` jobs of `kind` to use a yield multiplier.
## STUB — Phase 17 work-buff system will implement the ring buffer.
static func _apply_buff_next_n_jobs(kind: StringName, count: int, multiplier: float) -> void:
Audit.log("storyteller", "_apply_buff_next_n_jobs: kind=%s count=%d ×%.2f — stubbed (Phase 17)" % [kind, count, multiplier])
## Emit EventBus.request_wolf_spawn so WolfSpawner (scene child) picks it up.
## WolfSpawner listens on that signal (Phase 10). This is the correct bus-based
## wiring — no get_node("/root/...") smell.
static func _spawn_wolves(count: int) -> void:
if EventBus.has_signal("request_wolf_spawn"):
EventBus.emit_signal("request_wolf_spawn", count)
else:
Audit.log("storyteller", "_spawn_wolves: EventBus.request_wolf_spawn not declared — stubbed (Phase 17)")
## Apply a status to a random pawn (or a specific pawn if non-null).
## The factory callable returns a fresh Status — mirrors StatusCatalog pattern.
## REAL for statuses that exist (bleeding); STUB for sick() until Phase 17.
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())