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>
This commit is contained in:
megaproxy 2026-05-16 18:06:55 +01:00
parent 00cf8f445d
commit d9638a4ea4
19 changed files with 711 additions and 101 deletions

View file

@ -56,10 +56,17 @@ var _ghost_wanderer_target_day: int = -1
## 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 ────────────────────────────────────────────────────────────────
@ -86,6 +93,35 @@ 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:
@ -114,6 +150,14 @@ func save_dict() -> Dictionary:
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,
@ -122,6 +166,7 @@ func save_dict() -> Dictionary:
"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,
}
@ -138,6 +183,14 @@ func apply_dict(d: Dictionary) -> void:
_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 ────────────────────
@ -217,9 +270,14 @@ func _compute_weight(def: EventDef) -> float:
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.
@ -322,6 +380,29 @@ func _try_fire_ghost_wanderer() -> void:
# ── 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: