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
|
|
@ -24,7 +24,7 @@ class_name Bed extends Node2D
|
|||
## Save/load:
|
||||
## to_dict() serialises all persistent fields. _occupant_pawn is always saved
|
||||
## as null (sleep is mid-toil state; the JobRunner saves its own side). Re-wiring
|
||||
## owner_pawn from name → Pawn reference is deferred to Phase 16's full save layer.
|
||||
## owner_pawn from name → Pawn reference is resolved in SaveSystem._post_load_resolve_references().
|
||||
##
|
||||
## World registration: World.register_bed / World.unregister_bed called from
|
||||
## _ready / _exit_tree.
|
||||
|
|
@ -103,6 +103,11 @@ var _owner_pawn = null
|
|||
## has an active sleep job; the JobRunner handles reconnection).
|
||||
var _occupant_pawn = null
|
||||
|
||||
## Transient: set by from_dict() to the saved owner's pawn_name string.
|
||||
## SaveSystem._post_load_resolve_references() walks World.pawns, matches by
|
||||
## pawn_name, assigns _owner_pawn, then clears this field.
|
||||
var _pending_owner_name: String = ""
|
||||
|
||||
|
||||
# ── lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -220,7 +225,9 @@ func to_dict() -> Dictionary:
|
|||
|
||||
|
||||
## Restore from a dict produced by to_dict().
|
||||
## owner_pawn re-wiring (name → Pawn reference) is deferred to Phase 16.
|
||||
## _owner_pawn is re-wired by SaveSystem._post_load_resolve_references() after
|
||||
## all pawns are spawned: it reads _pending_owner_name, matches pawn_name,
|
||||
## assigns _owner_pawn, then clears _pending_owner_name.
|
||||
func from_dict(d: Dictionary) -> void:
|
||||
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
||||
quality = int(d.get("quality", 1))
|
||||
|
|
@ -228,9 +235,10 @@ func from_dict(d: Dictionary) -> void:
|
|||
is_medical = bool(d.get("is_medical", false))
|
||||
build_progress = int(d.get("build_progress", 0))
|
||||
_completed = bool(d.get("completed", false))
|
||||
# owner_pawn: Phase 16 will walk World.pawns and match by pawn_name.
|
||||
_owner_pawn = null
|
||||
_occupant_pawn = null
|
||||
# Store name for the post-load fixup pass; cleared there once resolved.
|
||||
_pending_owner_name = str(d.get("owner_pawn_name", ""))
|
||||
setup(tile)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,15 @@ var tile: Vector2i = Vector2i.ZERO
|
|||
## carry indicator instead.
|
||||
var being_carried: bool = false
|
||||
|
||||
## Hauling-retry fallback (memory.md 2026-05-10 design lock).
|
||||
## Incremented each sim-tick pass in which HaulingProvider finds no valid
|
||||
## destination for this item. After MAX_HAUL_RETRIES failures the provider
|
||||
## stops offering the item and sets haul_rejected = true, surfacing the
|
||||
## "no_stockpile_accepts" alert. Both fields reset when stockpile layout changes.
|
||||
const MAX_HAUL_RETRIES: int = 3
|
||||
var haul_retry_count: int = 0
|
||||
var haul_rejected: bool = false
|
||||
|
||||
|
||||
# ── lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -166,6 +175,8 @@ func to_dict() -> Dictionary:
|
|||
"tile_x": tile.x,
|
||||
"tile_y": tile.y,
|
||||
"quality": int(quality),
|
||||
"haul_retry_count": haul_retry_count,
|
||||
"haul_rejected": haul_rejected,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -180,6 +191,9 @@ static func from_dict(d: Dictionary) -> Dictionary:
|
|||
"tile_x": int(d.get("tile_x", 0)),
|
||||
"tile_y": int(d.get("tile_y", 0)),
|
||||
"quality": int(d.get("quality", Quality.NORMAL)),
|
||||
# Hauling-retry fields — default 0/false so older v2 saves load cleanly.
|
||||
"haul_retry_count": int(d.get("haul_retry_count", 0)),
|
||||
"haul_rejected": bool(d.get("haul_rejected", false)),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,13 @@ var _path: Array[Vector2i] = []
|
|||
var _step_progress: float = 0.0
|
||||
var _attack_cooldown: int = 0
|
||||
|
||||
## Transient: set by from_dict() to the saved target's pawn_name string.
|
||||
## SaveSystem._post_load_resolve_references() walks World.pawns, matches by
|
||||
## pawn_name, assigns target_pawn, then clears this field.
|
||||
## If the named pawn no longer exists the field stays "" and target_pawn stays
|
||||
## null — the AI will pick a new target on the next sim tick.
|
||||
var _pending_target_name: String = ""
|
||||
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -207,9 +214,11 @@ func from_dict(d: Dictionary) -> void:
|
|||
state = int(d.get("state", State.APPROACH)) as State
|
||||
_step_progress = float(d.get("step_progress", 0.0))
|
||||
_attack_cooldown = int(d.get("attack_cooldown", 0))
|
||||
# target_pawn: re-resolved by the loader after all pawns are restored.
|
||||
# Store the name in a temporary string; caller sets target_pawn post-load.
|
||||
target_pawn = null # caller must re-resolve from "target_pawn_name"
|
||||
# target_pawn is re-wired by SaveSystem._post_load_resolve_references() after
|
||||
# all pawns are spawned. Store the name for that pass; if the pawn no longer
|
||||
# exists target_pawn stays null and the AI picks a new target next tick.
|
||||
target_pawn = null
|
||||
_pending_target_name = str(d.get("target_pawn_name", ""))
|
||||
_path.clear()
|
||||
for entry in d.get("path", []):
|
||||
if entry is Array and entry.size() == 2:
|
||||
|
|
|
|||
|
|
@ -125,6 +125,12 @@ var current_work_progress: int = 0
|
|||
## enabled only after _complete() fires.
|
||||
var _light: PointLight2D = null
|
||||
|
||||
## Per-recipe input buffer for multi-ingredient crafts (two-trip strategy).
|
||||
## Keyed by StringName item_type → int count already deposited.
|
||||
## Populated by _tick_deposit_at_wb; consumed and cleared when the craft
|
||||
## completes or is interrupted. Not stocked by single-ingredient recipes.
|
||||
var deposited_inputs: Dictionary = {}
|
||||
|
||||
|
||||
# ── lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -261,6 +267,30 @@ func on_craft_complete() -> void:
|
|||
func on_craft_interrupted() -> void:
|
||||
current_bill = null
|
||||
current_work_progress = 0
|
||||
deposited_inputs.clear()
|
||||
|
||||
|
||||
## Called by _tick_deposit_at_wb to stash ingredient1 into the per-recipe buffer.
|
||||
## Accumulates count; the same item type may be deposited in multiple trips if
|
||||
## ingredient1_count > 1 (Phase 14: always 1 for corpse, but flexible for future).
|
||||
func add_deposited_input(item_type: StringName, count: int) -> void:
|
||||
deposited_inputs[item_type] = deposited_inputs.get(item_type, 0) + count
|
||||
|
||||
|
||||
## Returns true if the deposited_inputs buffer holds at least `count` of `item_type`.
|
||||
func has_deposited_input(item_type: StringName, count: int) -> bool:
|
||||
return deposited_inputs.get(item_type, 0) >= count
|
||||
|
||||
|
||||
## Consume (remove) exactly `count` of `item_type` from deposited_inputs.
|
||||
## Clamps to zero; does nothing if not present.
|
||||
func consume_deposited_input(item_type: StringName, count: int) -> void:
|
||||
var held: int = deposited_inputs.get(item_type, 0)
|
||||
var remaining: int = held - count
|
||||
if remaining <= 0:
|
||||
deposited_inputs.erase(item_type)
|
||||
else:
|
||||
deposited_inputs[item_type] = remaining
|
||||
|
||||
|
||||
# ── Phase 11: light-source duck-type interface ────────────────────────────────
|
||||
|
|
@ -293,6 +323,11 @@ func to_dict() -> Dictionary:
|
|||
var bills_data: Array = []
|
||||
for b in bills:
|
||||
bills_data.append(b.to_dict())
|
||||
# Serialise deposited_inputs: StringName keys → int values.
|
||||
# Store as Array of [String, int] pairs for JSON safety.
|
||||
var deposited_array: Array = []
|
||||
for k in deposited_inputs:
|
||||
deposited_array.append([String(k), deposited_inputs[k]])
|
||||
return {
|
||||
"class_id": &"workbench",
|
||||
"tile_x": tile.x,
|
||||
|
|
@ -303,13 +338,15 @@ func to_dict() -> Dictionary:
|
|||
"completed": _completed,
|
||||
"current_work_progress": current_work_progress,
|
||||
"bills": bills_data,
|
||||
"deposited_inputs": deposited_array,
|
||||
}
|
||||
|
||||
|
||||
## Restore from a dict produced by to_dict().
|
||||
## Bill objects are reconstructed by the caller using Bill.from_dict() once the
|
||||
## Bill class is registered. current_bill is left null — JobRunner reconnects
|
||||
## from its own saved state on the next sim tick.
|
||||
## Bills are reconstructed here using Bill.from_dict(). current_bill is left
|
||||
## null — JobRunner reconnects from its own saved state on the next sim tick.
|
||||
const _BILL_SCRIPT: Script = preload("res://scenes/ai/bill.gd")
|
||||
|
||||
func from_dict(d: Dictionary) -> void:
|
||||
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
||||
label_text = str(d.get("label_text", "Workbench"))
|
||||
|
|
@ -317,8 +354,18 @@ func from_dict(d: Dictionary) -> void:
|
|||
build_progress = int(d.get("build_progress", 0))
|
||||
_completed = bool(d.get("completed", false))
|
||||
current_work_progress = int(d.get("current_work_progress", 0))
|
||||
# Bills are re-populated by World.load_workbenches() after Bill class loads.
|
||||
# Raw dicts are kept in the dict; the caller handles reconstruction.
|
||||
# Reconstruct bills from the saved array. Clear first so this is idempotent.
|
||||
bills.clear()
|
||||
for bill_dict in d.get("bills", []):
|
||||
if bill_dict is Dictionary:
|
||||
var b: Bill = _BILL_SCRIPT.from_dict(bill_dict)
|
||||
if b != null:
|
||||
bills.append(b)
|
||||
# Restore deposited_inputs from the [String, int] pair array.
|
||||
deposited_inputs.clear()
|
||||
for pair in d.get("deposited_inputs", []):
|
||||
if pair is Array and pair.size() == 2:
|
||||
deposited_inputs[StringName(str(pair[0]))] = int(pair[1])
|
||||
setup(tile)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue