rimlike/autoload/storyteller.gd
megaproxy d9638a4ea4 fix six critical bugs from audit sprint
save/load round-trip: workbench bills, crop static-method, bed owner,
wolf target now all survive reload via Bill.from_dict reconstruction,
_spawn_crop using setup(), and a new _post_load_resolve_references pass.

PlantProvider: sow path added; consumes 1 grain on a TILLED crop tile.

CraftingProvider: ingredient2 supported via new KIND_DEPOSIT_AT_WB toil
and Workbench.deposited_inputs buffer. Cremation pyre now actually
consumes wood.

HaulingProvider: per-item haul_retry_count + haul_rejected after 3
orphan passes; new EventBus.stockpile_layout_changed resets rejects on
any player stockpile edit.

Storyteller: 14 stubbed event effects implemented. New buff registry
(add_buff/get_buff_multiplier/has_buff, day-prune, save/load) drives
seasonal/resource events. New request_pawn_spawn signal + WANDERER
table for arrivals. New SICK status + 3 mood thoughts.

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

420 lines
16 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
## Sim speed captured before an auto_pause event paused the sim. -1 when no
## restore is pending. Restored by resolve_current() so the player doesn't have
## to manually un-pause every threat modal dismissal.
var _speed_before_auto_pause: int = -1
## Timed buff registry for seasonal / resource events.
## Each entry: { kind: StringName, multiplier: float, expires_day: int }
## Callers query via get_buff_multiplier(kind). Phase 17 events populate this
## via _add_buff(); the registry is serialised through save_dict()/apply_dict().
var _buffs: Array = []
func _ready() -> void:
Clock.phase_changed.connect(_on_phase_changed)
EventBus.pawn_died.connect(_on_pawn_died)
EventBus.sim_tick.connect(_on_sim_tick_buffs)
# ── 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()
## Add a timed multiplier buff for `kind` (e.g. &"harvest", &"chop", &"mine",
## &"crop_growth", &"sleep_decay", &"threat_weight"). Lasts `duration_days`
## in-game days from the current day. Multiple buffs of the same kind stack
## multiplicatively (callers must not assume additive stacking).
func add_buff(kind: StringName, multiplier: float, duration_days: int) -> void:
var expires: int = Clock.day_index_from_start() + duration_days
_buffs.append({"kind": kind, "multiplier": multiplier, "expires_day": expires})
Audit.log("storyteller", "buff added: kind=%s ×%.2f expires_day=%d" % [kind, multiplier, expires])
## Returns the combined multiplier for all active buffs of `kind`. Returns 1.0
## when no buff is active. Expired buffs are pruned on each sim tick so stale
## entries are not present during normal gameplay; callers don't need to guard.
func get_buff_multiplier(kind: StringName) -> float:
var result: float = 1.0
for b in _buffs:
if b["kind"] == kind:
result *= b["multiplier"]
return result
## True if any active buff of `kind` exists. Convenience for simple gate checks.
func has_buff(kind: StringName) -> bool:
for b in _buffs:
if b["kind"] == kind:
return true
return false
## 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
# Restore the sim speed if this dismissal closes an auto_pause event AND
# the player hasn't manually changed speed during the modal. (If they did,
# current_speed won't be PAUSE and we leave their choice alone.)
if _speed_before_auto_pause >= 0:
if Sim.current_speed == Sim.Speed.PAUSE:
Sim.set_speed(_speed_before_auto_pause)
_speed_before_auto_pause = -1
# ── 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]
# Serialise buffs: StringName kind → String for JSON.
var buffs_ser: Array = []
for b in _buffs:
buffs_ser.append({
"kind": String(b["kind"]),
"multiplier": b["multiplier"],
"expires_day": b["expires_day"],
})
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,
"speed_before_auto_pause": _speed_before_auto_pause,
"buffs": buffs_ser,
}
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))
_speed_before_auto_pause = int(d.get("speed_before_auto_pause", -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])
# Restore buff registry.
_buffs.clear()
for b in d.get("buffs", []):
_buffs.append({
"kind": StringName(b.get("kind", "")),
"multiplier": float(b.get("multiplier", 1.0)),
"expires_day": int(b.get("expires_day", 0)),
})
# ── 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)
# Winter's Edge buff: raise threat weight for the season duration.
w *= get_buff_multiplier(&"threat_weight")
EventDef.Category.RESOURCE:
# High tension → more positive events to balance.
w *= lerp(0.5, 1.5, tension / 100.0)
EventDef.Category.WANDERER:
# Winter's Edge buff can suppress wanderers (multiplier < 1.0).
w *= get_buff_multiplier(&"wanderer_weight")
_:
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. Capture the prior speed so
# resolve_current() can restore it on dismissal — without this the sim
# stays paused after every threat modal, which the player reads as "pawns
# stopped working for no reason."
if def.auto_pause:
if Sim.current_speed != Sim.Speed.PAUSE:
_speed_before_auto_pause = int(Sim.current_speed)
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 ─────────────────────────────────────────────────────────────────
## Prune expired buffs once per in-game day (dawn phase, after _do_daily_roll).
## Called from _on_phase_changed; also called from _on_sim_tick_buffs for the
## very first tick so buffs from save-load are healthy immediately.
func _prune_expired_buffs() -> void:
var today: int = Clock.day_index_from_start()
var before: int = _buffs.size()
_buffs = _buffs.filter(func(b: Dictionary) -> bool: return b["expires_day"] > today)
var pruned: int = before - _buffs.size()
if pruned > 0:
Audit.log("storyteller", "%d buff(s) expired" % pruned)
## Sim-tick listener: prune expired buffs and handle seasonal threat bias.
## Only does real work once per in-game day to stay cheap.
var _last_buff_prune_day: int = -1
func _on_sim_tick_buffs(_tick: int) -> void:
var today: int = Clock.day_index_from_start()
if today == _last_buff_prune_day:
return
_last_buff_prune_day = today
_prune_expired_buffs()
## 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"