rimlike/autoload/storyteller.gd
megaproxy 2afca16299 wire buff consumers, sick penalty, multi-count cremation
A: Storyteller.multiply_drops() stochastic-rounding helper drives
crop_growth, harvest_yield, chop, mine consumption sites. sleep_decay
multiplied in Pawn sleep tick.

B: Pawn._sick_speed_penalty() (0% healthy → 75% severity 3, clamped to
25% min speed). JobRunner._work_speed_mult coin-flips per-tick progress
on INTERACT/BUILD/CRAFT toils. Sleep/eat/treat unaffected.

C: CraftingProvider builds N deposit trips for ingredient2_count > 1.
JobRunner._tick_craft validates+consumes the full count from buffer.
Cremation now actually requires and consumes 5 wood.

crop._stage_accum round-trips through save/load to preserve buff-
accumulated fractional growth.

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

432 lines
17 KiB
GDScript
Raw Permalink 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()
## Stochastic rounding for fractional drop counts.
## Converts a float drop amount to an int: floor(amt) drops are guaranteed;
## the fractional remainder is a probability of one extra drop. This way a
## multiplier of 1.25 on a base of 4 yields 5 drops 25% of the time and 4
## drops 75% of the time — expected value exactly matches the multiplier.
static func multiply_drops(base: int, mult: float) -> int:
var raw: float = float(base) * mult
var guaranteed: int = int(floor(raw))
var frac: float = raw - float(guaranteed)
return guaranteed + (1 if randf() < frac else 0)
## 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"