Phase 3 — Decision pipeline + JobRunner + RestProvider + save round-trip
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>
This commit is contained in:
parent
cd265b87c0
commit
5bf0f51efb
20 changed files with 613 additions and 25 deletions
|
|
@ -11,6 +11,15 @@ extends Node2D
|
|||
##
|
||||
## Render: _process() lerps world-position between current and next tile every
|
||||
## render frame at 60 Hz — motion is smooth even at low sim Hz.
|
||||
##
|
||||
## Phase 3 additions:
|
||||
## - `forced_job` slot (player override via Selection)
|
||||
## - `job_runner` Node child wired externally by the World scene
|
||||
## - On each sim tick: orchestrate AI first (Decision → JobRunner.tick), then
|
||||
## advance the walk. The walk is still owned by the Pawn — JobRunner's WALK
|
||||
## toil delegates to `walk_along_path()` and listens for `walk_completed`.
|
||||
## - to_dict() / from_dict() round-trip the entire mid-walk + mid-toil state
|
||||
## (architecture.md "Save format" — mid-tick suspend safe).
|
||||
|
||||
class_name Pawn
|
||||
|
||||
|
|
@ -25,6 +34,14 @@ signal arrived_at_destination(tile: Vector2i)
|
|||
|
||||
var tile: Vector2i = Vector2i.ZERO
|
||||
|
||||
# Player override slot — set by Selection; consumed by Decision on next sim tick.
|
||||
# Untyped to dodge the autoload-class-name-ordering trap (Phase 2 gotcha).
|
||||
var forced_job = null
|
||||
|
||||
# JobRunner node ref. Set externally by World during pawn spawn (so the runner
|
||||
# can be paired with the pathfinder). May be null in tests / pre-Phase-3 scenes.
|
||||
var job_runner = null
|
||||
|
||||
var _path: Array[Vector2i] = []
|
||||
var _step_progress: float = 0.0
|
||||
var _selected: bool = false
|
||||
|
|
@ -77,9 +94,75 @@ func is_selected() -> bool:
|
|||
return _selected
|
||||
|
||||
|
||||
# ── sim tick ────────────────────────────────────────────────────────────────
|
||||
# ── save / load ─────────────────────────────────────────────────────────────
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
var path_data: Array = []
|
||||
for v in _path:
|
||||
path_data.append([v.x, v.y])
|
||||
return {
|
||||
"name": pawn_name,
|
||||
"tile_x": tile.x,
|
||||
"tile_y": tile.y,
|
||||
"path": path_data,
|
||||
"step_progress": _step_progress,
|
||||
"selected": _selected,
|
||||
"forced_job": forced_job.to_dict() if forced_job != null else null,
|
||||
"job_runner": job_runner.to_dict() if job_runner != null else null,
|
||||
}
|
||||
|
||||
|
||||
func from_dict(d: Dictionary) -> void:
|
||||
pawn_name = d.get("name", "")
|
||||
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
|
||||
|
||||
_path.clear()
|
||||
for entry in d.get("path", []):
|
||||
if entry is Array and entry.size() == 2:
|
||||
_path.append(Vector2i(int(entry[0]), int(entry[1])))
|
||||
_step_progress = float(d.get("step_progress", 0.0))
|
||||
_selected = bool(d.get("selected", false))
|
||||
|
||||
var fj_dict: Variant = d.get("forced_job")
|
||||
forced_job = Job.from_dict(fj_dict) if fj_dict is Dictionary else null
|
||||
|
||||
var jr_dict: Variant = d.get("job_runner")
|
||||
if jr_dict is Dictionary and job_runner != null:
|
||||
job_runner.from_dict(jr_dict)
|
||||
|
||||
_name_label.text = pawn_name
|
||||
_state_label.text = Strings.t(&"pawn.state.walking") if is_walking() else Strings.t(&"pawn.state.idle")
|
||||
position = _tile_to_world(tile)
|
||||
queue_redraw()
|
||||
Audit.log("pawn", "%s restored at %s (walking=%s, path len=%d)" % [pawn_name, tile, is_walking(), _path.size()])
|
||||
|
||||
|
||||
# ── sim tick: orchestrate AI, then advance walk ─────────────────────────────
|
||||
|
||||
func _on_sim_tick(_tick_number: int) -> void:
|
||||
_orchestrate_ai()
|
||||
_advance_walk()
|
||||
|
||||
|
||||
func _orchestrate_ai() -> void:
|
||||
# Phase 3: ask Decision for a job when the pawn is idle OR when a forced job
|
||||
# is queued (forced_job preempts the current job — player override semantics).
|
||||
# Decision's layer 2 consumes the forced_job slot; layer 4 falls back to work
|
||||
# providers when no override is queued.
|
||||
if job_runner == null:
|
||||
return
|
||||
if forced_job != null or not job_runner.has_job():
|
||||
var next_job = Decision.pick_next_job(self, World.work_providers)
|
||||
if next_job != null:
|
||||
job_runner.start_job(next_job)
|
||||
# Tick the runner (a freshly-started job's first toil executes here in the
|
||||
# same sim tick — WALK calls pawn.walk_along_path so _advance_walk below
|
||||
# immediately starts moving on this tick).
|
||||
if job_runner.has_job():
|
||||
job_runner.tick()
|
||||
|
||||
|
||||
func _advance_walk() -> void:
|
||||
if not is_walking():
|
||||
return
|
||||
_step_progress += 1.0 / float(STEP_TICKS)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue