rimlike/autoload/storyteller.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

320 lines
12 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.

extends Node
## Storyteller — daily 6 AM event roll, cooldown gating, tension model, ghost state.
##
## Public API (locked — Agents A + C compile against these names):
## const CATEGORY_COOLDOWN_DAYS: Dictionary — category-snake → in-game days
## tension: float — running 0..100 score
## ghost_state: bool
## register_event(def: EventDef) — EventCatalog calls at boot
## fire_event(def: EventDef, focus_tile) — immediate dispatch, skips roll/cooldowns
## roll_today() — daily roll (also public test hook)
## resolve_current(choice_index: int) — called by UI after player choice
## save_dict() / apply_dict() — round-trips all runtime state
##
## Emits EventBus signals: storyteller_event_fired, storyteller_event_resolved,
## storyteller_tension_changed, storyteller_ghost_state_entered/exited.
## Per-category cooldown floors. design.md §"Storyteller": no two threats within
## 3 days, no two wanderers within 5 days. Other categories quieter.
const CATEGORY_COOLDOWN_DAYS: Dictionary = {
&"nudge": 2,
&"seasonal": 12,
&"wanderer": 5,
&"threat": 3,
&"disease": 4,
&"resource": 3,
&"lore": 6,
&"milestone": 30,
}
## Running 0..100 score; bumped by threats, decays slowly each roll.
var tension: float = 30.0 # mid-pool start
## True when all colonists are dead/gone. Wanderer recovery clock starts.
var ghost_state: bool = false
## Registry of all known EventDefs, keyed by StringName id.
var _events: Dictionary = {}
## The currently active (un-resolved) event, or null.
var _current_event: EventDef = null
## Guards one roll per in-game day. -1 = never rolled.
var _last_rolled_day_index: int = -1
## event_id → day_index of last fire. Prevents repeating an event too soon.
var _event_last_fired: Dictionary = {} # StringName → int
## category_snake → day_index of last fire. Per-category cooldown gate.
var _category_last_fired: Dictionary = {} # StringName → int
## Target day_index for the ghost-state wanderer auto-fire. -1 = not scheduled.
var _ghost_wanderer_target_day: int = -1
func _ready() -> void:
Clock.phase_changed.connect(_on_phase_changed)
EventBus.pawn_died.connect(_on_pawn_died)
# ── public API ────────────────────────────────────────────────────────────────
## Called by EventCatalog at boot to populate the event pool.
func register_event(def: EventDef) -> void:
if def == null or def.id == &"":
push_warning("Storyteller.register_event: null or empty-id def")
return
_events[def.id] = def
## Immediate dispatch: skip cooldown/predicate gates, jump straight to fire.
## Used by tests and ghost-state wanderer auto-fire.
func fire_event(def: EventDef, focus_tile: Vector2i = Vector2i(-1, -1)) -> void:
if def == null:
push_warning("Storyteller.fire_event: null def")
return
_fire(def, focus_tile)
## Trigger the daily roll regardless of current phase. Public for MCP runtime tests.
func roll_today() -> void:
_do_daily_roll()
## Called by UI (banner/modal) after the player makes a choice or dismisses.
func resolve_current(choice_index: int = 0) -> void:
if _current_event == null:
push_warning("Storyteller.resolve_current: no active event — double-dismiss?")
return
if _current_event.on_resolve.is_valid():
_current_event.on_resolve.call(choice_index)
EventBus.storyteller_event_resolved.emit(_current_event, choice_index)
_current_event = null
# ── save / load ───────────────────────────────────────────────────────────────
func save_dict() -> Dictionary:
# Convert StringName keys to plain strings for JSON round-trip.
var event_fired_str: Dictionary = {}
for k: StringName in _event_last_fired:
event_fired_str[String(k)] = _event_last_fired[k]
var cat_fired_str: Dictionary = {}
for k: StringName in _category_last_fired:
cat_fired_str[String(k)] = _category_last_fired[k]
return {
"tension": tension,
"ghost_state": ghost_state,
"last_rolled_day_index": _last_rolled_day_index,
"event_last_fired": event_fired_str,
"category_last_fired": cat_fired_str,
"ghost_wanderer_target_day": _ghost_wanderer_target_day,
}
func apply_dict(d: Dictionary) -> void:
tension = d.get("tension", 30.0)
ghost_state = d.get("ghost_state", false)
_last_rolled_day_index = int(d.get("last_rolled_day_index", -1))
_ghost_wanderer_target_day = int(d.get("ghost_wanderer_target_day", -1))
# Restore StringName keyed dicts.
_event_last_fired.clear()
for k: String in d.get("event_last_fired", {}):
_event_last_fired[StringName(k)] = int(d["event_last_fired"][k])
_category_last_fired.clear()
for k: String in d.get("category_last_fired", {}):
_category_last_fired[StringName(k)] = int(d["category_last_fired"][k])
# ── phase listener — triggers the daily 6 AM roll at dawn ────────────────────
func _on_phase_changed(phase: StringName) -> void:
if phase != Clock.PHASE_DAWN:
return
var today: int = Clock.day_index_from_start()
if today == _last_rolled_day_index:
return # Already rolled today (e.g. fast-speed bounce through dawn twice).
_last_rolled_day_index = today
_do_daily_roll()
# ── core roll logic ───────────────────────────────────────────────────────────
func _do_daily_roll() -> void:
var today: int = Clock.day_index_from_start()
# Ghost-state wanderer auto-fire check.
if ghost_state and _ghost_wanderer_target_day >= 0 and today >= _ghost_wanderer_target_day:
_try_fire_ghost_wanderer()
return # Ghost-state fires one special event; skip normal pool roll.
# Decay tension before building the pool (net = decay 3, then threat adds +15 if fired).
tension = maxf(0.0, tension - 3.0)
# Build eligible pool.
var pool: Array = [] # Each entry: {def: EventDef, weight: float}
for id: StringName in _events:
var def: EventDef = _events[id]
if not _is_eligible(def, today):
continue
var w: float = _compute_weight(def)
pool.append({"def": def, "weight": w})
if pool.is_empty():
Audit.log("storyteller", "[storyteller] day %d roll → no eligible events" % today)
return
var picked: EventDef = _weighted_pick(pool)
Audit.log("storyteller", "[storyteller] day %d roll → '%s' (cat=%s, weight=%.2f, tension=%.1f)" % [
today,
String(picked.id),
EventDef.Category.keys()[picked.category],
_compute_weight(picked),
tension,
])
_fire(picked)
func _is_eligible(def: EventDef, today: int) -> bool:
# Per-event cooldown gate.
var last_event: int = _event_last_fired.get(def.id, -9999)
if today - last_event < def.cooldown_days:
return false
# Per-category cooldown gate.
var cat_key: StringName = _category_to_str(def.category)
var cat_floor: int = CATEGORY_COOLDOWN_DAYS.get(cat_key, 0)
var last_cat: int = _category_last_fired.get(cat_key, -9999)
if today - last_cat < cat_floor:
return false
# Trigger predicate gate.
if def.trigger_predicate.is_valid() and not def.trigger_predicate.call():
return false
return true
func _compute_weight(def: EventDef) -> float:
var w: float = def.base_weight
# Tension modifier per category.
match def.category:
EventDef.Category.THREAT:
# Low tension → boost threats (exciting); high tension → suppress (breathing room).
w *= lerp(2.0, 0.3, tension / 100.0)
EventDef.Category.RESOURCE:
# High tension → more positive events to balance.
w *= lerp(0.5, 1.5, tension / 100.0)
_:
pass # Other categories: weight unmodified.
# State-triggered events (predicate is set AND currently true) get a 3× boost.
if def.trigger_predicate.is_valid() and def.trigger_predicate.call():
w *= 3.0
return w
func _weighted_pick(pool: Array) -> EventDef:
var total: float = 0.0
for entry in pool:
total += entry["weight"]
var roll: float = randf() * total
var cumulative: float = 0.0
for entry in pool:
cumulative += entry["weight"]
if roll <= cumulative:
return entry["def"]
# Fallback: last entry (floating-point edge case).
return pool[pool.size() - 1]["def"]
# ── fire + resolve helpers ────────────────────────────────────────────────────
func _fire(def: EventDef, focus_tile: Vector2i = Vector2i(-1, -1)) -> void:
_current_event = def
# Mark cooldowns.
var today: int = Clock.day_index_from_start()
_event_last_fired[def.id] = today
_category_last_fired[_category_to_str(def.category)] = today
# Tension: threats bump up by +15 (net +12 after the 3 decay applied pre-roll).
# Direct fire (fire_event / ghost) also bumps since it's still a threat.
if def.category == EventDef.Category.THREAT:
tension = minf(100.0, tension + 15.0)
EventBus.storyteller_tension_changed.emit(tension)
# Auto-pause for modal events that require it.
if def.auto_pause:
Sim.set_speed(Sim.Speed.PAUSE)
# For banners with no choices, resolve immediately after a short delay is
# handled by the UI layer (Agent C). We just emit.
EventBus.storyteller_event_fired.emit(def)
# ── ghost state ───────────────────────────────────────────────────────────────
func _on_pawn_died(_pawn, _cause: StringName) -> void:
# Check on next frame so the pawn registry has time to unregister.
call_deferred("_check_ghost_state")
func _check_ghost_state() -> void:
if World.pawns.size() == 0 and not ghost_state:
ghost_state = true
_ghost_wanderer_target_day = Clock.day_index_from_start() + randi_range(3, 5)
Audit.log("storyteller", "ghost state entered — wanderer recovery clock starts (target day %d)" % _ghost_wanderer_target_day)
EventBus.storyteller_ghost_state_entered.emit()
elif World.pawns.size() > 0 and ghost_state:
_exit_ghost_state()
func _exit_ghost_state() -> void:
ghost_state = false
_ghost_wanderer_target_day = -1
EventBus.storyteller_ghost_state_exited.emit()
Audit.log("storyteller", "ghost state exited — colony has survivors again")
func _try_fire_ghost_wanderer() -> void:
# Find a WANDERER event; prefer &"a_traveler".
var wanderer_def: EventDef = null
for id: StringName in _events:
var def: EventDef = _events[id]
if def.category != EventDef.Category.WANDERER:
continue
if id == &"a_traveler":
wanderer_def = def
break
if wanderer_def == null:
wanderer_def = def # Any wanderer as fallback.
if wanderer_def == null:
Audit.log("storyteller", "ghost state: no WANDERER event registered — cannot auto-fire wanderer")
return
Audit.log("storyteller", "ghost state: auto-firing '%s' on day %d" % [String(wanderer_def.id), Clock.day_index_from_start()])
_ghost_wanderer_target_day = -1 # Clear schedule before firing.
fire_event(wanderer_def)
# ── utilities ─────────────────────────────────────────────────────────────────
## Convert a Category enum value to the snake-case StringName used in
## CATEGORY_COOLDOWN_DAYS and _category_last_fired.
func _category_to_str(cat: EventDef.Category) -> StringName:
match cat:
EventDef.Category.NUDGE: return &"nudge"
EventDef.Category.SEASONAL: return &"seasonal"
EventDef.Category.WANDERER: return &"wanderer"
EventDef.Category.THREAT: return &"threat"
EventDef.Category.DISEASE: return &"disease"
EventDef.Category.RESOURCE: return &"resource"
EventDef.Category.LORE: return &"lore"
EventDef.Category.MILESTONE: return &"milestone"
_:
push_warning("Storyteller._category_to_str: unknown category %d" % cat)
return &"nudge"