extends Node ## SaveSystem v2 — full entity serialization + clear-and-respawn load. ## ## Save contract (docs/architecture.md "Save format"): ## - Saves only happen between sim ticks (Sim owns the loop). apply_save() ## pauses the sim so no tick fires mid-rebuild. ## - Every entity's to_dict() must include a "class_id" field (Agent A's work). ## The factory registry here dispatches on that key. ## - Save files: user://save_.json (slot = StringName, e.g. &"manual"). ## - Autosave uses slot &"autosave"; manual save uses &"manual". ## ## Public slot API (Agent C surface): ## save_to_slot(slot) → bool ## load_from_slot(slot) → bool ## has_save(slot) → bool ## delete_save(slot) → bool ## peek_save_metadata(slot) → {exists, saved_at_unix, version} ## ## Phase 16 — replaces the Phase 3 pawn-only stub. const SAVE_VERSION: int = 2 # ── PackedScene preloads (mirror world.gd constants) ────────────────────────── # Keep in sync with scenes/world/world.gd SCENE constants. # If a scene path changes, update both files. const _PAWN_SCENE: PackedScene = preload("res://scenes/pawn/pawn.tscn") const _TREE_SCENE: PackedScene = preload("res://scenes/entities/tree.tscn") const _ROCK_SCENE: PackedScene = preload("res://scenes/entities/rock.tscn") const _BIG_ROCK_SCENE: PackedScene = preload("res://scenes/entities/big_rock.tscn") const _BIG_ROCK_NODE_SCENE: PackedScene = preload("res://scenes/entities/big_rock_node.tscn") const _ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn") const _WALL_SCENE: PackedScene = preload("res://scenes/entities/wall.tscn") const _FLOOR_SCENE: PackedScene = preload("res://scenes/entities/floor.tscn") const _DOOR_SCENE: PackedScene = preload("res://scenes/entities/door.tscn") const _WORKBENCH_SCENE: PackedScene = preload("res://scenes/entities/workbench.tscn") const _CROP_SCENE: PackedScene = preload("res://scenes/entities/crop.tscn") const _BED_SCENE: PackedScene = preload("res://scenes/entities/bed.tscn") const _TORCH_SCENE: PackedScene = preload("res://scenes/entities/torch.tscn") 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(). const _GRAVEYARD_ZONE_SCRIPT: Script = preload("res://scenes/world/graveyard_zone.gd") const _GRAVE_SLOT_SCRIPT: Script = preload("res://scenes/entities/grave_slot.gd") const _GRAVE_MARKER_SCRIPT: Script = preload("res://scenes/entities/grave_marker.gd") # ── factory registry ─────────────────────────────────────────────────────────── # Keys are the class_id strings that entities embed in their to_dict() output. # Values are Callables: func(world_scene: Node, d: Dictionary) -> void. # Populated in _ready(); dispatch via _spawn_entity(). var _factories: Dictionary = {} func _ready() -> void: _register_factories() # ── public slot API ──────────────────────────────────────────────────────────── ## Write the current world state to the named slot. Returns true on success. func save_to_slot(slot: StringName) -> bool: return write_save(slot) ## Load the named slot and apply it to the live world. Returns true on success. func load_from_slot(slot: StringName) -> bool: var payload := read_save(slot) if payload.is_empty(): EventBus.load_finished.emit(slot, false, 0) return false apply_save(payload, slot) return true ## True if a save file for this slot exists on disk. func has_save(slot: StringName) -> bool: return FileAccess.file_exists(_path_for(slot)) ## Delete the save file for the given slot. Returns true if the file was removed. func delete_save(slot: StringName) -> bool: var path := _path_for(slot) if not FileAccess.file_exists(path): return false var err := DirAccess.remove_absolute(path) if err != OK: push_error("SaveSystem.delete_save: cannot delete '%s' (err %d)" % [path, err]) return false Audit.log("save", "deleted slot '%s'" % slot) return true ## Peek at a slot's metadata without a full load. Returns a Dictionary: ## {exists: bool, saved_at_unix: int, version: int} func peek_save_metadata(slot: StringName) -> Dictionary: var path := _path_for(slot) if not FileAccess.file_exists(path): return {exists = false, saved_at_unix = 0, version = 0} var f := FileAccess.open(path, FileAccess.READ) if f == null: return {exists = false, saved_at_unix = 0, version = 0} var parsed: Variant = JSON.parse_string(f.get_as_text()) if typeof(parsed) != TYPE_DICTIONARY: return {exists = true, saved_at_unix = 0, version = 0} return { exists = true, saved_at_unix = int(parsed.get("saved_at_unix", 0)), version = int(parsed.get("version", 0)), } # ── core write / read / apply ────────────────────────────────────────────────── func write_save(slot: StringName = &"manual") -> bool: # Pause the sim so we snapshot between ticks (never mid-tick). var prev_speed: Sim.Speed = Sim.current_speed Sim.set_speed(Sim.Speed.PAUSE) EventBus.save_started.emit(slot) var entities := _collect_entities() var payload: Dictionary = { "version": SAVE_VERSION, "saved_at_unix": int(Time.get_unix_time_from_system()), "saved_at_tick": Sim.tick, "sim_speed": int(prev_speed), "game_state": GameState.save_dict(), "clock": Clock.save_dict(), "weather": Weather.save_dict(), "storyteller": Storyteller.save_dict(), "tilemap_layers": _save_tilemap_layers_safe(), "beauty_map": _save_beauty_safe(), "dirt_map": _save_dirt_safe(), "entities": entities, } var ok := _write_json(slot, payload) if ok: Audit.log("save", "wrote slot '%s': %d entities at tick %d" % [ slot, entities.size(), Sim.tick ]) else: push_error("SaveSystem.write_save: file write failed for slot '%s'" % slot) EventBus.save_finished.emit(slot, ok) Sim.set_speed(prev_speed) return ok func read_save(slot: StringName = &"manual") -> Dictionary: var path := _path_for(slot) if not FileAccess.file_exists(path): return {} var f := FileAccess.open(path, FileAccess.READ) if f == null: push_error("SaveSystem.read_save: cannot open '%s'" % path) return {} var parsed: Variant = JSON.parse_string(f.get_as_text()) if typeof(parsed) != TYPE_DICTIONARY: push_error("SaveSystem.read_save: corrupt save at '%s'" % path) return {} var ver: int = int(parsed.get("version", 0)) if ver != SAVE_VERSION: push_warning( "SaveSystem.read_save: version mismatch in '%s' (file v%d vs code v%d) — best-effort load" % [path, ver, SAVE_VERSION] ) return parsed ## Apply a loaded payload onto the live world. slot is used only for signal emission. ## On any per-entity error: logs via push_error, skips that entity, keeps going. ## On catastrophic failure (no world scene): emits load_finished with ok=false; never crashes. func apply_save(payload: Dictionary, slot: StringName = &"manual") -> void: EventBus.load_started.emit(slot) var prev_speed: Sim.Speed = Sim.current_speed Sim.set_speed(Sim.Speed.PAUSE) var real_seconds_away: int = max( 0, int(Time.get_unix_time_from_system()) - int(payload.get("saved_at_unix", 0)) ) # Clear every entity registry + queue_free their nodes. World.clear_all() # Restore sim tick counter. Sim.tick = int(payload.get("saved_at_tick", 0)) # Restore autoload state in dependency order. if payload.has("game_state"): GameState.apply_dict(payload["game_state"]) if payload.has("clock"): Clock.apply_dict(payload["clock"]) if payload.has("weather"): Weather.apply_dict(payload["weather"]) if payload.has("storyteller"): Storyteller.apply_dict(payload["storyteller"]) # Restore TileMap layers (Agent A adds World.save/apply_tilemap_layers). var tilemap_data: Dictionary = payload.get("tilemap_layers", {}) if not tilemap_data.is_empty(): _apply_tilemap_layers_safe(tilemap_data) # Locate the world scene node (parent of all entity children). var world_scene := _get_world_scene() if world_scene == null: push_error("SaveSystem.apply_save: cannot locate World scene node — aborting entity spawn") EventBus.load_finished.emit(slot, false, real_seconds_away) Sim.set_speed(prev_speed) return # Respawn entities, sorted so dependencies land before dependents. var entity_dicts: Array = payload.get("entities", []) var ordered := _sort_by_spawn_priority(entity_dicts) var entity_count := 0 var error_count := 0 for d in ordered: if typeof(d) != TYPE_DICTIONARY: continue var cid: StringName = StringName(d.get("class_id", "")) if cid == &"": push_error("SaveSystem.apply_save: entity dict missing class_id — skipped: %s" % d) error_count += 1 continue if not _factories.has(cid): push_error("SaveSystem.apply_save: no factory for class_id '%s' — skipped" % cid) error_count += 1 continue var factory: Callable = _factories[cid] factory.call(world_scene, d) entity_count += 1 # Restore beauty / dirt maps after entities are back. var beauty_data: Dictionary = payload.get("beauty_map", {}) if not beauty_data.is_empty() and World.beauty_system != null: if World.beauty_system.has_method("apply_dict"): World.beauty_system.apply_dict(beauty_data) var dirt_data: Dictionary = payload.get("dirt_map", {}) if not dirt_data.is_empty() and World.dirtiness_system != null: if World.dirtiness_system.has_method("apply_dict"): World.dirtiness_system.apply_dict(dirt_data) # Restore sim speed to what it was when the save was taken. var saved_speed_int: int = int(payload.get("sim_speed", int(Sim.Speed.NORMAL))) var saved_speed: Sim.Speed = Sim.Speed.NORMAL 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 ]) EventBus.load_finished.emit(slot, ok, real_seconds_away) Sim.set_speed(saved_speed) # ── entity collection ────────────────────────────────────────────────────────── ## Walk every entity registry on World; call to_dict() on each valid node. ## Concatenated into a single flat Array[Dictionary] — class_id is the dispatcher key. func _collect_entities() -> Array: var out: Array = [] # Registries in stable order. Pawn last so dependents (workbenches, beds) precede. var registries: Array = [ World.trees, World.rocks, World.big_rock_nodes, World.items, World.build_queue, # ghost walls / floors / doors / grave_slots World.doors, World.workbenches, World.crops, World.beds, World.light_sources, # torches (hearth is a workbench — also in workbenches) World.stockpiles, World.wolves, World.corpses, World.grave_markers, World.pawns, ] for reg in registries: for ent in reg: if not is_instance_valid(ent): continue if ent.has_method("to_dict"): var d: Dictionary = ent.to_dict() if d.has("class_id"): out.append(d) else: push_warning("SaveSystem._collect_entities: %s.to_dict() missing class_id — skipped" % ent) return out # ── spawn-order sorting ──────────────────────────────────────────────────────── ## Spawn priority tiers (lower = spawns first so dependencies land before dependents). ## 0 — static structures: tree, rock, wall, floor ## 1 — doors (erase wall stamp in _complete) ## 2 — containers + furniture: crate, workbench, stockpile_zone, bed, torch, crop ## 3 — burial entities: grave_slot, grave_marker, graveyard_zone ## 4 — items + mobile non-pawn entities: item, wolf, corpse ## 5 — pawns (may reference job targets from earlier tiers) const _SPAWN_PRIORITY: Dictionary = { &"tree": 0, &"rock": 0, &"big_rock": 0, &"big_rock_node": 0, &"wall": 0, &"floor": 0, &"door": 1, &"crate": 2, &"workbench": 2, &"stockpile_zone": 2, &"bed": 2, &"torch": 2, &"crop": 2, &"grave_slot": 3, &"grave_marker": 3, &"graveyard_zone": 3, &"item": 4, &"wolf": 4, &"corpse": 4, &"pawn": 5, } func _sort_by_spawn_priority(entity_dicts: Array) -> Array: var copy := entity_dicts.duplicate() copy.sort_custom(func(a, b) -> bool: var pa: int = _SPAWN_PRIORITY.get(StringName(a.get("class_id", "")), 99) var pb: int = _SPAWN_PRIORITY.get(StringName(b.get("class_id", "")), 99) return pa < pb ) return copy # ── factory registry setup ───────────────────────────────────────────────────── func _register_factories() -> void: _factories[&"tree"] = _spawn_tree _factories[&"rock"] = _spawn_rock _factories[&"big_rock"] = _spawn_big_rock _factories[&"big_rock_node"] = _spawn_big_rock_node _factories[&"item"] = _spawn_item _factories[&"wall"] = _spawn_wall _factories[&"floor"] = _spawn_floor _factories[&"door"] = _spawn_door _factories[&"workbench"] = _spawn_workbench _factories[&"crop"] = _spawn_crop _factories[&"bed"] = _spawn_bed _factories[&"torch"] = _spawn_torch _factories[&"stockpile_zone"] = _spawn_stockpile_zone _factories[&"crate"] = _spawn_crate _factories[&"wolf"] = _spawn_wolf _factories[&"corpse"] = _spawn_corpse _factories[&"grave_marker"] = _spawn_grave_marker _factories[&"grave_slot"] = _spawn_grave_slot _factories[&"graveyard_zone"] = _spawn_graveyard_zone _factories[&"pawn"] = _spawn_pawn # ── per-class factory functions ──────────────────────────────────────────────── # Signature: func(world_scene: Node, d: Dictionary) -> void. # Pattern: instantiate (or new()+set_script), add_child (triggers _ready + # self-registration), apply entity-specific from_dict or field restore. func _spawn_tree(world_scene: Node, d: Dictionary) -> void: var ent = _TREE_SCENE.instantiate() world_scene.add_child(ent) # Pass growth_stage to setup() so _refresh_sprite() picks the right visual. # Default 3 (STAGE_MATURE) so pre-growth-system saves load as mature trees. var gs: int = int(d.get("growth_stage", 3)) ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))), gs) ent.chop_progress = int(d.get("chop_progress", 0)) ent.growth_progress = int(d.get("growth_progress", 0)) ent.chop_designated = bool(d.get("chop_designated", false)) ent.pending_plant = bool(d.get("pending_plant", false)) ent._plant_progress = int(d.get("plant_progress", 0)) # Re-register as build site if planting is still in progress. if ent.pending_plant: World.register_build_site(ent) ent.queue_redraw() func _spawn_rock(world_scene: Node, d: Dictionary) -> void: var ent = _ROCK_SCENE.instantiate() world_scene.add_child(ent) ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))) ent.mine_progress = int(d.get("mine_progress", 0)) ent.queue_redraw() func _spawn_big_rock(world_scene: Node, d: Dictionary) -> void: var ent = _BIG_ROCK_SCENE.instantiate() world_scene.add_child(ent) ent.setup(Vector2i(int(d.get("origin_x", 0)), int(d.get("origin_y", 0)))) ent.mine_progress = int(d.get("mine_progress", 0)) ent.queue_redraw() func _spawn_big_rock_node(world_scene: Node, d: Dictionary) -> void: var ent = _BIG_ROCK_NODE_SCENE.instantiate() world_scene.add_child(ent) ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))) ent.is_quarry_site = bool(d.get("is_quarry_site", false)) ent.queue_redraw() func _spawn_item(world_scene: Node, d: Dictionary) -> void: var ent = _ITEM_SCENE.instantiate() world_scene.add_child(ent) ent.setup( StringName(d.get("type", "wood")), int(d.get("stack_size", 1)), Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))) ) 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() func _spawn_wall(world_scene: Node, d: Dictionary) -> void: var ent = _WALL_SCENE.instantiate() world_scene.add_child(ent) ent.setup( Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))), StringName(d.get("material", "stone")) ) ent.build_progress = int(d.get("build_progress", 0)) if bool(d.get("completed", false)): # Mirror _spawn_complete_wall in world.gd: set progress to BUILD_TICKS # then call on_build_tick() once to trigger _complete(). ent.build_progress = ent.BUILD_TICKS ent.on_build_tick() else: ent.queue_redraw() func _spawn_floor(world_scene: Node, d: Dictionary) -> void: var ent = _FLOOR_SCENE.instantiate() world_scene.add_child(ent) ent.setup( Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))), StringName(d.get("material", "wood")) ) ent.build_progress = int(d.get("build_progress", 0)) if bool(d.get("completed", false)): ent.build_progress = ent.BUILD_TICKS ent.on_build_tick() else: ent.queue_redraw() func _spawn_door(world_scene: Node, d: Dictionary) -> void: var ent = _DOOR_SCENE.instantiate() world_scene.add_child(ent) ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))) ent.build_progress = int(d.get("build_progress", 0)) if bool(d.get("completed", false)): ent.build_progress = ent.BUILD_TICKS ent.on_build_tick() # Restore open state after possible completion (Door._complete sets is_open = true). ent.is_open = bool(d.get("is_open", true)) if not bool(d.get("completed", false)): ent.queue_redraw() func _spawn_workbench(world_scene: Node, d: Dictionary) -> void: var ent = _WORKBENCH_SCENE.instantiate() world_scene.add_child(ent) ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))) if d.has("label_text"): ent.label_text = str(d["label_text"]) if d.has("accepted_skill"): ent.accepted_skill = StringName(d["accepted_skill"]) ent.build_progress = int(d.get("build_progress", 0)) if bool(d.get("completed", false)): ent.build_progress = ent.BUILD_TICKS ent.on_build_tick() # from_dict restores bills + any other Workbench-specific state. if ent.has_method("from_dict"): ent.from_dict(d) func _spawn_crop(world_scene: Node, d: Dictionary) -> void: var ent = _CROP_SCENE.instantiate() world_scene.add_child(ent) # 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: var ent = _BED_SCENE.instantiate() world_scene.add_child(ent) ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))) ent.build_progress = int(d.get("build_progress", 0)) if bool(d.get("completed", false)): ent.build_progress = ent.BUILD_TICKS ent.on_build_tick() if ent.has_method("from_dict"): ent.from_dict(d) func _spawn_torch(world_scene: Node, d: Dictionary) -> void: var ent = _TORCH_SCENE.instantiate() world_scene.add_child(ent) ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))) ent.build_progress = int(d.get("build_progress", 0)) if bool(d.get("completed", false)): ent.build_progress = ent.BUILD_TICKS ent.on_build_tick() if ent.has_method("from_dict"): ent.from_dict(d) func _spawn_stockpile_zone(world_scene: Node, d: Dictionary) -> void: var ent = _STOCKPILE_SCENE.instantiate() world_scene.add_child(ent) if ent.has_method("from_dict"): ent.from_dict(d) else: # Fallback: wire minimal fields directly when from_dict not yet present. ent.region = Rect2i( int(d.get("region_x", 0)), int(d.get("region_y", 0)), int(d.get("region_w", 4)), int(d.get("region_h", 4)) ) ent.label = str(d.get("label", "Stockpile")) var pri_int: int = int(d.get("priority", 2)) if pri_int >= 0 and pri_int < StorageDestination.Priority.size(): ent.priority = pri_int as StorageDestination.Priority var types_raw: Array = d.get("accepted_types", []) var types: Array[StringName] = [] for t in types_raw: types.append(StringName(t)) ent.accepted_types = types ent.queue_redraw() func _spawn_crate(world_scene: Node, d: Dictionary) -> void: var ent = _CRATE_SCENE.instantiate() world_scene.add_child(ent) ent.setup(Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))) ent.build_progress = int(d.get("build_progress", 0)) if bool(d.get("completed", false)): # Crate uses a loop (mirrors world.gd's pre-build pattern). while ent.is_buildable(): ent.on_build_tick() if ent.has_method("from_dict"): ent.from_dict(d) func _spawn_wolf(world_scene: Node, d: Dictionary) -> void: var ent = _WOLF_SCENE.instantiate() world_scene.add_child(ent) if ent.has_method("from_dict"): ent.from_dict(d) else: # Fallback for pre-from_dict Wolf. ent.tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))) ent.hp = float(d.get("hp", 40.0)) ent.position = Vector2(ent.tile.x * 16 + 8.0, ent.tile.y * 16 + 8.0) ent.queue_redraw() func _spawn_corpse(world_scene: Node, d: Dictionary) -> void: var ent = _CORPSE_SCENE.instantiate() world_scene.add_child(ent) if ent.has_method("from_dict"): ent.from_dict(d) func _spawn_grave_marker(world_scene: Node, d: Dictionary) -> void: var ent := Node2D.new() ent.set_script(_GRAVE_MARKER_SCRIPT) ent.name = "GraveMarker_%s" % str(d.get("pawn_name", "Unknown")) world_scene.add_child(ent) if ent.has_method("from_dict"): ent.from_dict(d) func _spawn_grave_slot(world_scene: Node, d: Dictionary) -> void: var ent := Node2D.new() ent.set_script(_GRAVE_SLOT_SCRIPT) var tx: int = int(d.get("tile_x", 0)) var ty: int = int(d.get("tile_y", 0)) ent.name = "GraveSlot_%d_%d" % [tx, ty] world_scene.add_child(ent) if ent.has_method("setup"): ent.setup(Vector2i(tx, ty)) if ent.has_method("from_dict"): ent.from_dict(d) func _spawn_graveyard_zone(world_scene: Node, d: Dictionary) -> void: var ent := Node2D.new() ent.set_script(_GRAVEYARD_ZONE_SCRIPT) var rx: int = int(d.get("region_x", 0)) var ry: int = int(d.get("region_y", 0)) ent.name = "GraveyardZone_%d_%d" % [rx, ry] world_scene.add_child(ent) if ent.has_method("from_dict"): ent.from_dict(d) else: ent.region = Rect2i(rx, ry, int(d.get("region_w", 1)), int(d.get("region_h", 1))) ent.label = str(d.get("label", "Graveyard")) ent.queue_redraw() func _spawn_pawn(world_scene: Node, d: Dictionary) -> void: var ent: Pawn = _PAWN_SCENE.instantiate() world_scene.add_child(ent) # Attach JobRunner — mirrors world.gd's _spawn_sample_pawns pattern. if World.pathfinder != null: var jr := JobRunner.new() jr.name = "JobRunner" ent.add_child(jr) jr.setup(ent, World.pathfinder) ent.job_runner = jr # Minimal setup so tile / position is sane before from_dict overwrites everything. ent.setup(str(d.get("name", "Unknown")), Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))) # Full state restore — overwrites setup() defaults. if ent.has_method("from_dict"): ent.from_dict(d) World.register_pawn(ent) # ── tilemap helpers (defensive wrappers for Agent A's additions) ─────────────── # World.save_tilemap_layers() and World.apply_tilemap_layers() are added by # Agent A in the Phase 16 parallel pass. has_method() guards keep this file # clean when Agent A's work has not yet landed. func _save_tilemap_layers_safe() -> Dictionary: if World.has_method("save_tilemap_layers"): return World.save_tilemap_layers() return {} func _apply_tilemap_layers_safe(data: Dictionary) -> void: if World.has_method("apply_tilemap_layers"): World.apply_tilemap_layers(data) else: 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: if World.beauty_system != null and World.beauty_system.has_method("save_dict"): return World.beauty_system.save_dict() return {} func _save_dirt_safe() -> Dictionary: if World.dirtiness_system != null and World.dirtiness_system.has_method("save_dict"): return World.dirtiness_system.save_dict() return {} # ── file I/O ────────────────────────────────────────────────────────────────── func _write_json(slot: StringName, payload: Dictionary) -> bool: var path := _path_for(slot) var f := FileAccess.open(path, FileAccess.WRITE) if f == null: push_error("SaveSystem._write_json: cannot open '%s' for writing" % path) return false f.store_string(JSON.stringify(payload, "\t")) return true func _path_for(slot: StringName) -> String: return "user://save_%s.json" % String(slot) # ── world scene locator ──────────────────────────────────────────────────────── ## Returns the root Node2D of the World scene — the parent of all entity children. ## The World autoload is a data-only Node; the scene instance is a Node2D. ## Searches the scene tree root for a node named "World" first, then falls back ## to the first Node2D child of root (which is the active scene on most projects). func _get_world_scene() -> Node: var root := get_tree().root var candidate := root.get_node_or_null("World") if candidate != null: return candidate # Fallback: first Node2D child of root. for child in root.get_children(): if child is Node2D: return child push_error("SaveSystem._get_world_scene: cannot locate World scene node") return null