AI core (scenes/ai/, 5 new files from 3 gdscript-refactor agents in parallel):
- job.gd (59 lines, Agent A): Job class, RefCounted, label + toils + cursor +
to_dict/from_dict round-trip
- toil.gd (76 lines, Agent A): Toil class, RefCounted; kinds WALK/WAIT/IDLE;
factories walk_to/wait_ticks/idle; Vector2i stored as to_x/to_y ints
because Godot 4 JSON.stringify doesn't round-trip Vector2i
- work_provider.gd (27 lines, Agent A): abstract base, class_name, @export
category/priority, find_best_for() with push_error subclass guard
- job_runner.gd (186 lines, Agent B): Node-derived runner; setup/start_job/
cancel_job/tick; WALK toil delegates to pawn.walk_along_path on first
encounter (sets data.started=true), listens for walk_completed signal;
WAIT decrements ticks_remaining; IDLE never completes; full to_dict/from_dict
- decision.gd (50 lines, Agent C): static pick_next_job(pawn, providers); 5
layers (incapacitation/forced/status/work/idle); layer 1 probes via
has_method to stay future-proof for Phase 9
- rest_provider.gd (31 lines, Agent C): extends WorkProvider; @export rest_tile;
returns [walk_to(rest_tile), idle()] Job
Integration (Opus):
- pawn.gd: added forced_job slot, job_runner ref, _orchestrate_ai called
before _advance_walk on each sim_tick. Calls Decision when forced_job is
queued OR when idle — was a bug initially (only-on-idle never preempted
the never-completing IDLE toil); fixed and caught via MCP runtime test.
Added to_dict/from_dict for save round-trip; captures tile, _path,
_step_progress, _selected, forced_job, job_runner via their serializers.
- selection.gd: rewrote to build a forced-job [walk_to + idle] and set
pawn.forced_job; Decision preempts current job on next tick.
- world.tscn/gd: instantiates RestProvider as child (rest_tile = (50,50)
just outside the stone ring's south-east, reachable from all 3 spawn
tiles); registers via World.register_work_provider; attaches a JobRunner
child to each spawned pawn and wires setup(pawn, pathfinder).
- world.gd autoload: added work_providers list + register/clear methods.
- save_system.gd: write_save walks World.pawns calling to_dict; apply_save
zips dicts to pawns by index (Phase 16 will add stable IDs).
- main.gd: bootstrap log line bumped Phase 2 → Phase 3.
Acceptance — MCP-verified end-to-end:
- 3 pawns boot, Decision assigns each Rest, JobRunner starts each,
all 3 walk to (50,50) on different paths (40/35/30 steps based on
detour around the stone ring), arrive and idle.
- Force Bram to (10,10) via pawn.forced_job; preempt fires:
[decision] Bram: forced 'Go to (10, 10)'. Bram walks while Cora/Edda
stay parked.
- Mid-walk save round-trip (the critical Phase 3 acceptance):
- Paused Bram at (51,10) walking to (70,70) with 79 path steps remaining
- SaveSystem.write_save() → SaveSystem.apply_save(read_save()) after a
mutate-to-(0,0)-with-no-path round-trip
- Restored Bram exactly: tile=(51,10), _path.size=79, walking=true,
job='Go to (70, 70)' at toil_idx=0 (WALK toil with data.started=true)
- Resumed sim → JobRunner's WALK toil saw started=true and did NOT
re-call walk_along_path; the pawn's restored _path continued the walk
naturally → reached (70,26) with 44 steps remaining, still on the
same job. The architecture.md 'mid-toil suspend safe' contract is
provably honored.
Phase 3 gotchas (logged in implementation.md):
- Class-name registration timing bit again (Phase 2 gotcha). Workflow:
agent writes class_name file → MCP reload_project → headless validate.
- Forced-job preempt requires triggering Decision when forced_job != null,
not just when idle (IDLE toil never completes).
- execute_game_script + await Engine.get_main_loop().process_frame is
flaky — MCP auto-recovers but the script's last lines may be lost.
Workaround: split state-inspection into a fresh execute_game_script.
Delegation report this phase:
- gdscript-refactor (Sonnet) Agent A: Job + Toil + WorkProvider abstract
base. 3 files, 162 lines.
- gdscript-refactor (Sonnet) Agent B: JobRunner with toil-execution match
+ walk_completed signal handling + full save round-trip. 1 file, 186
lines.
- gdscript-refactor (Sonnet) Agent C: Decision pipeline + RestProvider.
2 files, 81 lines.
- Opus: Pawn integration (forced_job slot, orchestration, to_dict/from_dict),
Selection rewrite, world.tscn/gd wiring, World autoload work_providers
list, SaveSystem extension, MCP-driven runtime verification including
the mid-walk save round-trip demo, gotcha logging.
~70% of Phase 3's GDScript was written by subagents.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
71 lines
2.3 KiB
GDScript
71 lines
2.3 KiB
GDScript
extends Node
|
|
## Save/load — version field, file IO, save-between-ticks contract.
|
|
##
|
|
## Saves only happen between sim ticks (Sim owns the loop). JobRunner mid-toil
|
|
## state must round-trip from day one — see docs/architecture.md "Save format".
|
|
##
|
|
## Phase 3: pawn states (including mid-walk + JobRunner) round-trip via
|
|
## Pawn.to_dict() / Pawn.from_dict() — gated by has_method() so the pre-Phase-3
|
|
## Pawn (which lacked the methods) didn't break here.
|
|
## Phase 16 expands to tilemap data, storyteller state, items, furniture, etc.
|
|
|
|
const SAVE_VERSION: int = 1
|
|
const SAVE_PATH: String = "user://save_slot.json"
|
|
|
|
|
|
func write_save() -> bool:
|
|
var pawn_dicts: Array = []
|
|
for p in World.pawns:
|
|
if p.has_method("to_dict"):
|
|
pawn_dicts.append(p.to_dict())
|
|
|
|
var payload := {
|
|
"version": SAVE_VERSION,
|
|
"sim_tick": Sim.tick,
|
|
"game_state": GameState.save_dict(),
|
|
"pawns": pawn_dicts,
|
|
}
|
|
var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
|
|
if f == null:
|
|
push_error("SaveSystem.write_save: cannot open %s" % SAVE_PATH)
|
|
return false
|
|
f.store_string(JSON.stringify(payload))
|
|
Audit.log("save", "wrote %d pawns at tick %d" % [pawn_dicts.size(), Sim.tick])
|
|
return true
|
|
|
|
|
|
func read_save() -> Dictionary:
|
|
if not FileAccess.file_exists(SAVE_PATH):
|
|
return {}
|
|
var f := FileAccess.open(SAVE_PATH, FileAccess.READ)
|
|
if f == null:
|
|
return {}
|
|
var raw := f.get_as_text()
|
|
var parsed: Variant = JSON.parse_string(raw)
|
|
if typeof(parsed) != TYPE_DICTIONARY:
|
|
push_error("SaveSystem.read_save: corrupt save")
|
|
return {}
|
|
if int(parsed.get("version", 0)) != SAVE_VERSION:
|
|
push_warning(
|
|
"SaveSystem.read_save: version mismatch (%s vs %s)" %
|
|
[parsed.get("version", "?"), SAVE_VERSION]
|
|
)
|
|
return parsed
|
|
|
|
|
|
## Apply a previously-loaded save payload onto live world state.
|
|
## Pawn dicts are zipped against World.pawns by index — Phase 3 simplicity;
|
|
## Phase 16 will introduce stable pawn IDs.
|
|
func apply_save(payload: Dictionary) -> void:
|
|
if payload.has("sim_tick"):
|
|
Sim.tick = int(payload["sim_tick"])
|
|
if payload.has("game_state"):
|
|
GameState.apply_dict(payload["game_state"])
|
|
|
|
var pawn_dicts: Array = payload.get("pawns", [])
|
|
var n: int = min(pawn_dicts.size(), World.pawns.size())
|
|
for i in n:
|
|
var p = World.pawns[i]
|
|
if p.has_method("from_dict"):
|
|
p.from_dict(pawn_dicts[i])
|
|
Audit.log("save", "applied %d pawns at tick %d" % [n, Sim.tick])
|