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

@ -63,6 +63,7 @@ signal pawn_priority_changed(pawn, category: StringName, level: int) ## Emitted
signal alert_added(severity: StringName, text: String, focus_tile: Vector2i) ## Emitted by gameplay subsystems to surface a player notice. severity = info | warn | danger.
signal request_wolf_spawn(count: int) ## Phase 15 EventCatalog → WolfSpawner. Decouples threat-event effects from spawner.
signal wolf_spawned(wolves: Array) ## Emitted by WolfSpawner AFTER a raid wave has been instantiated; carries the spawned Wolf nodes. Audio uses this for raid-warning sting.
signal request_pawn_spawn(skills: Dictionary) ## Phase 17 EventCatalog wanderer events → World scene. skills dict is forwarded to pawn.setup; empty dict = random skills.
signal day_ended(summary: Dictionary) ## Emitted by Clock at dusk→night boundary; carries the end-of-day recap dict.
# Phase 19 — Onboarding (hint system + help section).
@ -72,4 +73,5 @@ signal ui_panel_opened(panel_id: StringName) ## Emitted by UI panels (buil
# Phase 18 — Alert wiring (dangling signals surfaced to AlertsLog).
signal no_stockpile_accepts(item_type: StringName, tile: Vector2i) ## Emitted by HaulingProvider when an item needs haul but no stockpile accepts it (rate-limited per item_type).
signal stockpile_layout_changed ## Emitted when any stockpile is added, removed, or has its filter/priority edited. HaulingProvider listens to reset haul_rejected items.
signal bill_blocked(recipe_label: String, reason: StringName, focus_tile: Vector2i) ## Emitted by CraftingProvider when a bill cannot proceed. reason: missing_ingredient | skill_too_low | no_workbench.

View file

@ -41,6 +41,7 @@ const _TORCH_SCENE: PackedScene = preload("res://scenes/entities/torch.t
const _STOCKPILE_SCENE: PackedScene = preload("res://scenes/world/stockpile_zone.tscn")
const _CRATE_SCENE: PackedScene = preload("res://scenes/world/crate.tscn")
const _WOLF_SCENE: PackedScene = preload("res://scenes/entities/wolf.tscn")
const _CROP_SCRIPT: Script = preload("res://scenes/entities/crop.gd")
const _CORPSE_SCENE: PackedScene = preload("res://scenes/entities/corpse.tscn")
# Script-only entities (no .tscn); spawned via Node2D.new() + set_script().
@ -258,6 +259,9 @@ func apply_save(payload: Dictionary, slot: StringName = &"manual") -> void:
if saved_speed_int >= 0 and saved_speed_int < Sim.Speed.size():
saved_speed = saved_speed_int as Sim.Speed
# Resolve cross-entity references that require all nodes to be spawned first.
_post_load_resolve_references()
var ok: bool = error_count == 0
Audit.log("save", "applied slot '%s': %d entities, %d errors, tick=%d, away=%ds" % [
slot, entity_count, error_count, Sim.tick, real_seconds_away
@ -430,6 +434,13 @@ func _spawn_item(world_scene: Node, d: Dictionary) -> void:
)
ent.quality = int(d.get("quality", 1)) as Item.Quality
ent.subtype = StringName(d.get("subtype", ""))
# Hauling-retry state — defaults keep older v2 saves loading cleanly.
ent.haul_retry_count = int(d.get("haul_retry_count", 0))
ent.haul_rejected = bool(d.get("haul_rejected", false))
# haul_rejected items must NOT be in items_needing_haul — undo the
# automatic enqueue that World.register_item() does inside setup().
if ent.haul_rejected:
World.items_needing_haul.erase(ent)
ent.queue_redraw()
@ -499,15 +510,15 @@ func _spawn_workbench(world_scene: Node, d: Dictionary) -> void:
func _spawn_crop(world_scene: Node, d: Dictionary) -> void:
var ent = _CROP_SCENE.instantiate()
world_scene.add_child(ent)
if ent.has_method("from_dict"):
ent.from_dict(d)
else:
# Fallback for pre-from_dict Crop: set up with kind + default stage.
ent.setup(
Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))),
StringName(d.get("kind", "wheat")),
int(d.get("stage", 0))
)
# Crop.from_dict() is static and returns a spec Dictionary — it cannot mutate
# the instance. Use the spec to call setup() so tile/kind/stage are applied.
var spec: Dictionary = _CROP_SCRIPT.from_dict(d) if d else {}
ent.setup(
Vector2i(int(spec.get("tile_x", 0)), int(spec.get("tile_y", 0))),
StringName(spec.get("crop_kind", &"wheat")),
int(spec.get("stage", 0))
)
ent.stage_progress = int(spec.get("stage_progress", 0))
func _spawn_bed(world_scene: Node, d: Dictionary) -> void:
@ -668,6 +679,56 @@ func _apply_tilemap_layers_safe(data: Dictionary) -> void:
Audit.log("save", "apply_tilemap_layers: World helper not yet available — skipping")
# ── post-load reference resolution ────────────────────────────────────────────
## Resolve entity cross-references that require all nodes to be spawned first.
## Called once at the end of apply_save(), before load_finished is emitted.
##
## Beds — _pending_owner_name → _owner_pawn (Pawn node reference).
## Wolves — _pending_target_name → target_pawn (Pawn node reference).
func _post_load_resolve_references() -> void:
# Build a name→pawn lookup once; both bed and wolf passes share it.
var pawn_by_name: Dictionary = {}
for pawn in World.pawns:
if is_instance_valid(pawn):
var n: String = str(pawn.get("pawn_name"))
if n != "":
pawn_by_name[n] = pawn
# Beds: re-wire _owner_pawn from _pending_owner_name.
var beds_resolved: int = 0
for bed in World.beds:
if not is_instance_valid(bed):
continue
var pending: String = str(bed.get("_pending_owner_name"))
if pending == "":
continue
if pawn_by_name.has(pending):
bed._owner_pawn = pawn_by_name[pending]
beds_resolved += 1
else:
Audit.log("save", "bed at %s: owner pawn '%s' not found — left unowned" % [bed.tile, pending])
bed._pending_owner_name = ""
# Wolves: re-wire target_pawn from _pending_target_name.
var wolves_resolved: int = 0
for wolf in World.wolves:
if not is_instance_valid(wolf):
continue
var pending: String = str(wolf.get("_pending_target_name"))
if pending == "":
continue
if pawn_by_name.has(pending):
wolf.target_pawn = pawn_by_name[pending]
wolves_resolved += 1
else:
Audit.log("save", "wolf at %s: target pawn '%s' not found — will pick new target next tick" % [wolf.tile, pending])
wolf._pending_target_name = ""
if beds_resolved > 0 or wolves_resolved > 0:
Audit.log("save", "_post_load_resolve_references: %d bed owners, %d wolf targets re-wired" % [beds_resolved, wolves_resolved])
# ── beauty / dirt helpers ──────────────────────────────────────────────────────
func _save_beauty_safe() -> Dictionary:

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:

View file

@ -243,10 +243,12 @@ func unregister_item(it) -> void:
func register_stockpile(s) -> void:
if not stockpiles.has(s):
stockpiles.append(s)
EventBus.stockpile_layout_changed.emit()
func unregister_stockpile(s) -> void:
stockpiles.erase(s)
EventBus.stockpile_layout_changed.emit()
func mark_item_needs_haul(it) -> void: