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:
parent
00cf8f445d
commit
d9638a4ea4
19 changed files with 711 additions and 101 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue