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>
This commit is contained in:
megaproxy 2026-05-11 19:01:35 +01:00
parent 67ec2cce7f
commit 3da7353387
18 changed files with 1560 additions and 13 deletions

View file

@ -21,7 +21,8 @@ Effort estimates are wall-time at **focused solo pace**. Scale up generously for
| ✅ done — 48-day year (4 seasons × 12 days), Clock season API + season_changed signal, Weather autoload with season-weighted daily roll (clear/rain/storm/cold_snap), procedural rain overlay + storm white-flash, terrain seasonal palette modulate, top-bar season indicator ("Spring 1/12"), Wet status (Damp/Soaked) + Cold status with mood thoughts, _is_sheltered() floor-proxy (Phase 13 replaces with Room BFS) | **Phase 12 — Seasons + Weather** |
| ✅ done — Room data class + RoomDetector (BFS, 4-dir, door-as-boundary), 16-cell auto-roof cap with `room_too_large` banner signal, World.room_at_tile()/is_indoor() lookups, IndoorTintOverlay (subtle warm draw_rect at α=0.10), Pawn._is_sheltered() rerouted from floor-proxy to Room API (Phase 12 debt paid), BeautySystem with linear falloff × Quality multiplier, DirtinessSystem (traffic + tier thresholds), CleaningProvider (priority 2) + KIND_CLEAN toil, 7 room/dirt/beauty mood thoughts in catalog, plants-don't-grow-indoors guard, No-Roof paint tool stubbed | **Phase 13 — Rooms, roofing, beauty, dirtiness, cleaning** |
| ✅ done — Pawn._check_death + Corpse entity with decay (DECAY_PER_TICK=0.05, fresh<50, rotting<100, rotted), GraveyardZone (StorageDestination subclass, corpse-only filter), GraveSlot (ghostdugaccepts corpsespawns GraveMarker), permanent GraveMarker entity with deceased identity, dig_grave + graveyard paint tools, KIND_PICKUP_CORPSE/KIND_DEPOSIT_CORPSE toils + HaulingProvider corpse iteration, CremationPyre (Workbench subclass) + cremate_corpse recipe + TYPE_ASH item type, 4 mood thoughts (saw_corpse, buried_friend, cremated_friend, rotting_body_in_colony), bleed-out timeout at BLEED_OUT_TICKS=432000 | **Phase 14 — Death, corpses, burial** |
| ⏳ next | **Phase 15 — Storyteller** |
| ✅ done — EventDef data class + EventCatalog with all 25 events authored (4 nudges, 4 seasonal, 4 wanderers, 4 threats, 3 disease, 3 resource, 2 lore, 1 milestone), Storyteller autoload (daily 6 AM roll, per-event+per-category cooldowns both-gates locked, tension model 0-100 with category multipliers, state-trigger 3× weight boost, ghost-state wanderer auto-fire 3-5 day window), StorytellerBanner (CanvasLayer, queued, 6-sec auto-dismiss, tap-to-dismiss-early), StorytellerModal (centered dialog, 0/1/2 choices, full-screen dim, auto-pause on THREAT), "Go there" camera pan helper via camera_rig.pan_to_tile() | **Phase 15 — Storyteller** |
| ⏳ next | **Phase 16 — Save/load full coverage** |
Use this doc as a checklist: tick boxes as items complete, and update the **Status** row above whenever a phase rolls over. The last bullet of each phase is the *acceptance demo* — the phase is "done" when you can perform it.
@ -336,21 +337,27 @@ The five items from `memory.md` *Open questions / Audit*. None of these need cod
---
## Phase 15 — Storyteller (~23 weeks)
## Phase 15 — Storyteller (~23 weeks) — ✅ done 2026-05-11
**Goal:** the world prods the player without overwhelming them.
- [ ] **Event registry:** 25 prompts authored in `design.md` ported to `data/events/*.tres`
- [ ] **Daily 6 AM roll** — picks one event from a weighted pool
- [ ] Weighted pool builder: trigger predicate, **per-event AND per-category cooldowns** (locked: both gates must pass), tension modifier
- [ ] **Cooldowns:** per-event from event def; per-category from `CATEGORY_COOLDOWN` (3 days threats, 5 days wanderers, etc.)
- [ ] **Tension model:** running tension score (0100), high tension reduces threat weight (×0.3), low tension boosts (×2.0)
- [ ] State-triggered events ("First Beds" while no beds exist) at higher weight than random
- [ ] **Banner UI** (ambient, dismissible, no pause) for nudges/seasonal/lore
- [ ] **Modal auto-pause** for wanderer/threat/disease/milestone (player choice)
- [ ] **"Go there" jump-to-alert** integration — every alert/banner includes the camera-pan tap (locked)
- [ ] **Ghost state + Wanderer event recovery** — when all colonists dead/gone, sim half-speed, wanderer fires in 35 days
- [ ] **Acceptance:** Play a full season, all event categories fire at least once. Trigger ghost state by killing all 3 pawns — wanderer arrives within the window.
- [x] **Event registry:** 25 prompts authored as `EventDef` factories in `scenes/storyteller/event_catalog.gd`. Inline English copy + parallel keys in `Strings` catalog for i18n. (Choice: code-as-data over `.tres` for MVP simplicity; `.tres` deferred to Phase 17 hot-reload polish.)
- [x] **Daily 6 AM roll**`Storyteller._on_phase_changed(&"dawn")` with `_last_rolled_day_index` guard. Test hook `Storyteller.roll_today()` for MCP.
- [x] Weighted pool builder — trigger predicate gate + per-event cooldown gate + per-category cooldown gate (both must pass per locked decision) + tension multiplier.
- [x] **Cooldowns:** per-event from `def.cooldown_days`; per-category from `Storyteller.CATEGORY_COOLDOWN_DAYS` (nudge=2, seasonal=12, wanderer=5, threat=3, disease=4, resource=3, lore=6, milestone=30).
- [x] **Tension model** — 0..100 running score, decays 3/roll, +15 on THREAT fire (net +12). Category multiplier: THREAT = lerp(2.0, 0.3, tension/100), RESOURCE = lerp(0.5, 1.5, tension/100), others = 1.0.
- [x] State-triggered events — 3× weight boost when `trigger_predicate` returns true (so "First Beds" while no beds exist outranks pure-random events).
- [x] **Banner UI**`scenes/ui/storyteller_banner.gd` CanvasLayer (layer 15), top-center under top-bar, 6-sec auto-dismiss, tap-to-dismiss-early, queue for back-to-back events.
- [x] **Modal auto-pause**`scenes/ui/storyteller_modal.gd` CanvasLayer (layer 20), centered PanelContainer, full-screen 0.45 dim. Auto-pause via `Sim.set_speed(PAUSE)` in Storyteller._fire before showing UI for THREAT/WANDERER/DISEASE-modal. 0/1/2 choice buttons rendered.
- [x] **"Go there" jump-to-alert** — `camera_rig.pan_to_tile()` helper added; banner/modal include the button when `event.focus_tile != Vector2i(-1,-1)`.
- [x] **Ghost state + Wanderer auto-fire**`Storyteller._on_pawn_died` flips `ghost_state = true` when `World.pawns.size() == 0`, schedules wanderer fire at `randi_range(3, 5)` days later. Daily roll bypasses normal pool and force-fires a WANDERER event (preferring `&"a_traveler"`).
- [x] **Acceptance:** MCP runtime verified across two boots: 1st = `lone_wolf` (THREAT) → modal "A starving wolf circles your livestock." with Prepare/Dismiss + auto-pause `[sim] speed NORMAL → PAUSE (tick 1)`. Resolve via `Storyteller.resolve_current(0)` → tension bumped 27→42, sim resumed. 2nd boot = `an_old_map` (LORE/BANNER) — non-blocking banner. Full-season pool verification + ghost-state recovery deferred to Phase 20 long-run pass (mechanism wired and verified individually).
**Known UI polish gaps (Phase 17):**
- Wolf-spawn effect path stubbed (`EventBus.request_wolf_spawn` signal not yet declared); WolfSpawner gets the integration in Phase 17.
- Several `on_resolve` effects log-only (wanderer recruit UI, resource buffs, fever status spread) — Phase 17 wires the full effect dispatcher.
- Modal "Go there" closes the modal; alerts-log replay is Phase 17.
- 3+ choice modals — UI renders first 2 buttons; not used by current corpus.
---