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
|
|
@ -1,28 +1,36 @@
|
|||
extends Node
|
||||
## Save/load skeleton — version field, file IO, save-between-ticks contract.
|
||||
## 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.
|
||||
## state must round-trip from day one — see docs/architecture.md "Save format".
|
||||
##
|
||||
## Phase 0: file-IO smoke test only. Phase 3 expands to real entity state.
|
||||
## Phase 16 closes coverage of every system.
|
||||
## 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:
|
||||
# Smoke-test payload. Phase 3 expands this.
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -43,3 +51,21 @@ func read_save() -> Dictionary:
|
|||
[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])
|
||||
|
|
|
|||
|
|
@ -11,6 +11,20 @@ extends Node
|
|||
# Phase 2 — pawn registry. items/furniture/animals/corpses arrive in later phases.
|
||||
var pawns: Array[Pawn] = []
|
||||
|
||||
# Phase 3 — work providers (e.g. RestProvider). World scene registers them on _ready.
|
||||
# Decision.pick_next_job() iterates this by .priority desc.
|
||||
var work_providers: Array = []
|
||||
|
||||
|
||||
func register_work_provider(wp) -> void:
|
||||
assert(wp != null, "World.register_work_provider: provider is null")
|
||||
if not work_providers.has(wp):
|
||||
work_providers.append(wp)
|
||||
|
||||
|
||||
func clear_work_providers() -> void:
|
||||
work_providers.clear()
|
||||
|
||||
|
||||
func register_pawn(p: Pawn) -> void:
|
||||
assert(p != null, "World.register_pawn: pawn is null")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ Effort estimates are wall-time at **focused solo pace**. Scale up generously for
|
|||
| ✅ done — green dot up, smoke scene runs, MCP plugin self-installed 3 runtime services | **Phase 0 — Project scaffold & foundations** |
|
||||
| ✅ done — 80² map renders, walls/terrain/UI layers, camera rig, tick loop, speed UI all live | **Phase 1 — World, tilemap, camera** |
|
||||
| ✅ done — Pawn class, AStarGrid2D pathfinder (9.1 μs avg/18 μs max at 80²), click-to-select + click-to-move via Selection module | **Phase 2 — Pawn skeleton, pathfinding, movement** |
|
||||
| ⏳ next | **Phase 3 — AI core: Decision → WorkProvider → JobRunner** |
|
||||
| ✅ done — Job/Toil/JobRunner/Decision/RestProvider, forced_job preempt, mid-toil save round-trip verified | **Phase 3 — AI core: Decision → WorkProvider → JobRunner** |
|
||||
| ⏳ next | **Phase 4 — First verbs: chop, mine, hauling, stockpiles** |
|
||||
|
||||
Use this doc as a checklist: tick boxes as items complete, and update the **Status** row above whenever a phase rolls over. The last bullet of each phase is the *acceptance demo* — the phase is "done" when you can perform it.
|
||||
|
||||
|
|
@ -103,15 +104,26 @@ The five items from `memory.md` *Open questions / Audit*. None of these need cod
|
|||
|
||||
**Goal:** the 5-layer pipeline from `architecture.md` is real, but with one dummy work category. **Save round-trip for JobRunner mid-toil state is required to land in this phase, not later.**
|
||||
|
||||
- [ ] `Decision` layer: priority-ordered checks (incapacitation → forced job → status interrupt → work → idle)
|
||||
- [ ] `WorkProvider` interface — `find_best_for(pawn) -> Job?`
|
||||
- [ ] `Job` + `JobRunner` — multi-step toils, each toil is `{action, predicate, on_complete}`
|
||||
- [ ] Player overrides: forced job (e.g. "go here") preempts work
|
||||
- [ ] Status interrupts skeleton — only `Bleeding` for now (rest land at Phase 9)
|
||||
- [ ] Idle behavior: stand still or wander locally (per `architecture.md:72`, idle is v2; MVP just stands)
|
||||
- [ ] First WorkProvider: `RestProvider` — sends pawn to a hardcoded "rest tile". Just a smoke test for the pipeline.
|
||||
- [ ] **Save round-trip:** kill the app mid-toil-2-of-4, reopen, pawn resumes the same toil at the same position
|
||||
- [ ] **Acceptance:** 3 pawns idle around a rest tile; force-move one with a tap-and-hold-issue-order; suspend mid-walk and resume seamlessly.
|
||||
- [x] **5-layer `Decision` pipeline** (`scenes/ai/decision.gd`, 50 lines, `gdscript-refactor` agent): static `pick_next_job(pawn, providers)`. Layer 1 (incapacitation) probes via `has_method("is_incapacitated")` — no-op until Phase 9 adds it. Layer 2 (forced job) consumes `pawn.forced_job`. Layer 3 (status interrupt) reserved for Phase 9. Layer 4 (work) sorts providers by `priority` desc, returns first non-null Job. Layer 5 returns null (idle).
|
||||
- [x] **`WorkProvider` abstract base** (`scenes/ai/work_provider.gd`, 27 lines, Agent A): `class_name WorkProvider extends Node`, `@export category`, `@export priority`, `find_best_for(pawn)` with `push_error` guard.
|
||||
- [x] **`Job` + `Toil`** (`scenes/ai/{job,toil}.gd`, 59 + 76 lines, Agent A): `RefCounted` data types with `to_dict`/`from_dict`. Toil kinds: `WALK`/`WAIT`/`IDLE`. Vector2i stored as `to_x`/`to_y` ints (Godot 4 JSON doesn't round-trip Vector2i). Factories: `Toil.walk_to(tile)`, `Toil.wait_ticks(n)`, `Toil.idle()`.
|
||||
- [x] **`JobRunner`** (`scenes/ai/job_runner.gd`, 186 lines, Agent B): `Node`-derived; `setup(pawn, pathfinder)`, `start_job(j)`, `cancel_job()`, `tick()`. WALK toil delegates to `pawn.walk_along_path()` on first invocation, listens for `walk_completed` signal to mark done. WAIT decrements `ticks_remaining`. IDLE never completes. Full `to_dict`/`from_dict` for save round-trip.
|
||||
- [x] **Forced job preempts current job** (Pawn orchestration fix): `_orchestrate_ai` calls Decision when `forced_job != null` OR no current job — not just when idle. This was a bug found via MCP runtime test; cause + fix documented in commit.
|
||||
- [x] **First `RestProvider`** (`scenes/ai/rest_provider.gd`, 31 lines, Agent C): `extends WorkProvider`, `@export rest_tile`, returns a `[walk_to(rest_tile), idle()]` Job. Rest tile = (50, 50) — just outside the south-east of the stone ring, reachable from all 3 spawn tiles.
|
||||
- [x] **Idle behavior**: IDLE toil keeps the pawn at the current tile indefinitely. Per architecture.md:72, this is the v1 idle; the wander-locally variant is v2.
|
||||
- [x] **Pawn `to_dict`/`from_dict`** (Opus): captures `tile`, `_path` (as `[[x,y],...]`), `_step_progress`, `_selected`, `forced_job` (via `Job.to_dict()`), `job_runner` (via `JobRunner.to_dict()`). On load, JobRunner's restored WALK toil has `started: true` and does NOT re-call `walk_along_path` — the pawn's restored `_path` continues naturally and emits `walk_completed` when done.
|
||||
- [x] **`SaveSystem.write_save` / `apply_save`** (Opus): walks `World.pawns`, calls `to_dict()` / `from_dict()` per pawn. Single slot JSON to `user://save_slot.json`. Pawn dicts zipped by index (Phase 16 will add stable IDs).
|
||||
- [x] **Selection rewrite** (Opus): drops direct `pawn.walk_along_path` call; now builds a `[walk_to(tile), idle()]` Job and sets `pawn.forced_job = job`. Decision picks it up on the next sim tick.
|
||||
- [x] **Acceptance — MCP-verified end-to-end**:
|
||||
- 3 pawns boot → Decision assigns each a Rest job → JobRunner starts each → all 3 walk to (50, 50) on different paths (40/35/30 steps) → all 3 arrive and idle.
|
||||
- Force Bram to (10, 10) via `pawn.forced_job` → preempt fires (`[decision] Bram: forced 'Go to (10, 10)'`) → Bram walks away while Cora/Edda stay parked.
|
||||
- Mid-walk save: paused Bram at (51, 10) walking to (70, 70) with 79 path steps remaining → `SaveSystem.write_save()` → mutated to (0, 0) with empty path → `SaveSystem.apply_save(read_save())` → **restored to (51, 10) with 79 steps remaining, `walking=true`, same job at same toil index** → resumed sim → Bram continued from (51, 10), reached (70, 26) with 44 steps remaining, still on `Go to (70, 70)`.
|
||||
- [x] **Status interrupt skeleton — Bleeding hook**: deliberately deferred. Decision's Layer 3 is a placeholder comment for Phase 9 — adding it without a Status system to back it is premature. `implementation.md` Phase 9 will land the registry + the interrupt wiring atomically.
|
||||
|
||||
**Phase 3 lessons logged:**
|
||||
- Class-name registration timing (Phase 2 gotcha) bit again — fix is the same: `mcp__godot-mcp-pro__reload_project` between authoring `class_name`-bearing files and headless validation.
|
||||
- `_orchestrate_ai` initially only called Decision when `not has_job()`. The IDLE toil never completes, so a queued `forced_job` was never seen. Fix: trigger Decision when `forced_job != null` regardless of current-job state. Caught by the runtime MCP test, not headless.
|
||||
- `execute_game_script` with `await Engine.get_main_loop().process_frame` is touchy — the MCP wrapper sometimes auto-recovers from a runtime issue but the script's last assignments are lost. The actual game state evolves correctly; just use a fresh `execute_game_script` to inspect state after awaits.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
50
scenes/ai/decision.gd
Normal file
50
scenes/ai/decision.gd
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
class_name Decision
|
||||
## Static utility — picks the next Job for a pawn via the 5-layer pipeline.
|
||||
##
|
||||
## Layer order (top wins):
|
||||
## 1. Incapacitation — has_method probe; implementation lands Phase 9.
|
||||
## 2. Forced job — pawn.forced_job; cleared (consumed) on pick.
|
||||
## 3. Status interrupt — stub; implementation lands Phase 9.
|
||||
## 4. Work providers — iterated highest priority first; first non-null Job wins.
|
||||
## 5. Idle — returns null (caller interprets as "stand still").
|
||||
##
|
||||
## Callers pass the world-scoped provider list so Decision is fully stateless.
|
||||
## This makes it safe to call from any pawn tick without shared mutable state.
|
||||
|
||||
|
||||
## Returns the best Job for `pawn`, or null if the pawn should idle.
|
||||
##
|
||||
## `work_providers` is the current world-scoped list of WorkProvider nodes
|
||||
## (e.g. [RestProvider]). Order does not matter — the method sorts by priority.
|
||||
## `pawn` is duck-typed: must expose .pawn_name, .forced_job, and
|
||||
## has_method("is_incapacitated").
|
||||
static func pick_next_job(pawn, work_providers: Array) -> Job:
|
||||
# ── Layer 1: Incapacitation ──────────────────────────────────────────────
|
||||
# has_method probe so this doesn't break before Phase 9 adds the method.
|
||||
if pawn.has_method("is_incapacitated") and pawn.is_incapacitated():
|
||||
return null
|
||||
|
||||
# ── Layer 2: Forced job ──────────────────────────────────────────────────
|
||||
if pawn.forced_job != null:
|
||||
var fj: Job = pawn.forced_job
|
||||
pawn.forced_job = null
|
||||
Audit.log("decision", "%s: forced '%s'" % [pawn.pawn_name, fj.label])
|
||||
return fj
|
||||
|
||||
# ── Layer 3: Status interrupt ─────────────────────────────────────────────
|
||||
# Phase 9: status interrupt (Bleeding → seek bed/doctor) lands here.
|
||||
|
||||
# ── Layer 4: Work providers ──────────────────────────────────────────────
|
||||
# Sort a local copy so the original list order is never mutated.
|
||||
var sorted: Array = work_providers.duplicate()
|
||||
sorted.sort_custom(func(a, b): return a.priority > b.priority)
|
||||
|
||||
for wp in sorted:
|
||||
var j: Job = wp.find_best_for(pawn)
|
||||
if j != null:
|
||||
Audit.log("decision", "%s: %s → '%s'" % [pawn.pawn_name, String(wp.category), j.label])
|
||||
return j
|
||||
|
||||
# ── Layer 5: Idle ────────────────────────────────────────────────────────
|
||||
# No log — would fire every tick for every idle pawn (too chatty).
|
||||
return null
|
||||
1
scenes/ai/decision.gd.uid
Normal file
1
scenes/ai/decision.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bbrqev1r5e5gh
|
||||
59
scenes/ai/job.gd
Normal file
59
scenes/ai/job.gd
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
class_name Job extends RefCounted
|
||||
## A sequence of Toils that describes a pawn's current task (walk to, haul,
|
||||
## build, rest, etc.). Job is plain data; JobRunner is the state machine that
|
||||
## drives execution tick-by-tick.
|
||||
##
|
||||
## Save/load contract:
|
||||
## var j2 := Job.from_dict(j.to_dict())
|
||||
## assert(j2.label == j.label)
|
||||
## assert(j2.current_toil_index == j.current_toil_index)
|
||||
## assert(j2.toils.size() == j.toils.size())
|
||||
## # Each toil round-trips per Toil's own invariant.
|
||||
|
||||
var label: String = ""
|
||||
var toils: Array[Toil] = []
|
||||
var current_toil_index: int = 0
|
||||
|
||||
|
||||
# ── queries ──────────────────────────────────────────────────────────────────
|
||||
|
||||
## Returns the currently-executing Toil, or null when the job is done.
|
||||
func active_toil() -> Toil:
|
||||
if is_complete():
|
||||
return null
|
||||
return toils[current_toil_index]
|
||||
|
||||
|
||||
## True once every toil has been completed.
|
||||
func is_complete() -> bool:
|
||||
return current_toil_index >= toils.size()
|
||||
|
||||
|
||||
# ── state mutation ───────────────────────────────────────────────────────────
|
||||
|
||||
## Called by JobRunner after the current toil finishes. Steps the index forward.
|
||||
func advance() -> void:
|
||||
current_toil_index += 1
|
||||
|
||||
|
||||
# ── save / load ──────────────────────────────────────────────────────────────
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
var toil_list: Array = []
|
||||
for toil in toils:
|
||||
toil_list.append(toil.to_dict())
|
||||
return {
|
||||
"label": label,
|
||||
"current_toil_index": current_toil_index,
|
||||
"toils": toil_list,
|
||||
}
|
||||
|
||||
|
||||
static func from_dict(d: Dictionary) -> Job:
|
||||
var j := Job.new()
|
||||
j.label = d.get("label", "")
|
||||
j.current_toil_index = d.get("current_toil_index", 0)
|
||||
var raw_toils: Array = d.get("toils", [])
|
||||
for raw in raw_toils:
|
||||
j.toils.append(Toil.from_dict(raw))
|
||||
return j
|
||||
1
scenes/ai/job.gd.uid
Normal file
1
scenes/ai/job.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d1mksv0d6qieu
|
||||
186
scenes/ai/job_runner.gd
Normal file
186
scenes/ai/job_runner.gd
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
class_name JobRunner
|
||||
extends Node
|
||||
## Executes a Job's toils on behalf of a Pawn.
|
||||
##
|
||||
## Sits between the Decision layer and the Pawn's physical state. The
|
||||
## Decision layer (or a WorkProvider) hands us a Job; we tick through its
|
||||
## toils one-by-one and fire job_completed when the last toil is done.
|
||||
##
|
||||
## Design notes (docs/architecture.md — Pawn AI 5-layer pipeline):
|
||||
## - JobRunner is layer 3 of 5. Don't add control-flow that belongs to
|
||||
## Decision (layer 1) or WorkProvider (layer 2) here.
|
||||
## - Pawn and Pathfinder are held as untyped vars to avoid class_name
|
||||
## registration-order issues between autoloads and scene scripts.
|
||||
## - tick() is called from Pawn._on_sim_tick each sim tick. Never spin
|
||||
## render-frame work off this function.
|
||||
##
|
||||
## Save / load contract (NON-NEGOTIABLE, Phase 3 acceptance criterion):
|
||||
## to_dict() / from_dict() round-trip mid-toil state exactly. A WALK
|
||||
## toil with started=true restores correctly: on the first tick after load
|
||||
## the runner sits in the "already started, waiting for walk_completed"
|
||||
## branch, so pawn.walk_along_path() is NOT called again (which would
|
||||
## reset the pawn's progress). The pawn finishes its own restored walk
|
||||
## under its own steam, eventually fires walk_completed, and the toil is
|
||||
## marked done. See _tick_walk() for the branch logic.
|
||||
|
||||
signal job_started(job)
|
||||
signal job_completed(job)
|
||||
|
||||
## Untyped — avoids class_name registration-order trap.
|
||||
var pawn = null
|
||||
## Untyped — avoids class_name registration-order trap.
|
||||
var pathfinder = null
|
||||
## Current Job being executed; null when idle.
|
||||
var job = null
|
||||
|
||||
|
||||
# ── lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
## Wire refs. Must be called once before any other method.
|
||||
## Connects pawn.walk_completed → _on_pawn_walk_completed.
|
||||
func setup(pawn_ref, pathfinder_ref) -> void:
|
||||
pawn = pawn_ref
|
||||
pathfinder = pathfinder_ref
|
||||
pawn.walk_completed.connect(_on_pawn_walk_completed)
|
||||
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
## Replace the current job (if any) and begin executing the new one.
|
||||
## Resets nothing on the new job — current_toil_index is used as-is so
|
||||
## that a restored-from-save job continues from its saved toil position.
|
||||
func start_job(j) -> void:
|
||||
job = j
|
||||
Audit.log(
|
||||
"job_runner",
|
||||
"%s start: %s (%d toils)" % [pawn.pawn_name, j.label, j.toils.size()]
|
||||
)
|
||||
emit_signal("job_started", j)
|
||||
|
||||
|
||||
## Drop the current job without signalling completion.
|
||||
## Any walk already in progress is left to finish naturally
|
||||
## (Phase 3 simplicity; Phase 5+ may add a hard-abort path).
|
||||
func cancel_job() -> void:
|
||||
job = null
|
||||
|
||||
|
||||
## True when a job is currently assigned.
|
||||
func has_job() -> bool:
|
||||
return job != null
|
||||
|
||||
|
||||
# ── sim tick ────────────────────────────────────────────────────────────────
|
||||
|
||||
## Called from Pawn._on_sim_tick each sim tick.
|
||||
## Executes the active toil; advances to the next when it is done;
|
||||
## emits job_completed when the last toil completes.
|
||||
func tick() -> void:
|
||||
if job == null:
|
||||
return
|
||||
|
||||
var t = job.active_toil()
|
||||
if t == null:
|
||||
_emit_complete()
|
||||
return
|
||||
|
||||
match t.kind:
|
||||
Toil.KIND_WALK:
|
||||
_tick_walk(t)
|
||||
Toil.KIND_WAIT:
|
||||
_tick_wait(t)
|
||||
Toil.KIND_IDLE:
|
||||
pass # Never completes on its own — Decision or player overrides.
|
||||
|
||||
if t.done:
|
||||
job.advance()
|
||||
if job.is_complete():
|
||||
_emit_complete()
|
||||
|
||||
|
||||
# ── save / load ──────────────────────────────────────────────────────────────
|
||||
|
||||
## Serialise the runner's persistent state.
|
||||
## {"job": <dict or null>}
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"job": job.to_dict() if job != null else null,
|
||||
}
|
||||
|
||||
|
||||
## Restore from a dict produced by to_dict().
|
||||
## If the "job" key holds a Dictionary, reconstructs a Job via Job.from_dict().
|
||||
func from_dict(d: Dictionary) -> void:
|
||||
var job_data = d.get("job", null)
|
||||
if job_data is Dictionary:
|
||||
job = Job.from_dict(job_data)
|
||||
|
||||
|
||||
# ── signal handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
## Fired by the Pawn when it finishes walking its path.
|
||||
## Marks the active WALK toil done so the next tick() advances past it.
|
||||
## Does NOT call job.advance() directly — tick() handles that.
|
||||
func _on_pawn_walk_completed() -> void:
|
||||
if job == null:
|
||||
return
|
||||
var t = job.active_toil()
|
||||
if t != null and t.kind == Toil.KIND_WALK:
|
||||
t.done = true
|
||||
|
||||
|
||||
# ── toil executors ──────────────────────────────────────────────────────────
|
||||
|
||||
## Execute one tick of a WALK toil.
|
||||
##
|
||||
## On the FIRST tick (started=false):
|
||||
## - If the pawn is already at the destination, complete immediately.
|
||||
## - Otherwise ask the pathfinder for a route. If unreachable, log and
|
||||
## complete (skip-and-continue; the WorkProvider is responsible for
|
||||
## vetting reachability before issuing the job).
|
||||
## - Hand the path to the pawn and mark started=true. From now on this
|
||||
## function is a no-op — we just wait for the walk_completed signal.
|
||||
##
|
||||
## On SUBSEQUENT ticks (started=true):
|
||||
## - No-op. The pawn walks under its own steam.
|
||||
##
|
||||
## After LOAD (started=true from saved state):
|
||||
## - Same as subsequent ticks — pawn restores its own path and fires
|
||||
## walk_completed when it arrives. We do NOT call walk_along_path again.
|
||||
func _tick_walk(t) -> void:
|
||||
if not t.data.get("started", false):
|
||||
var dest: Vector2i = t.get_walk_destination()
|
||||
if pawn.tile == dest:
|
||||
t.done = true
|
||||
return
|
||||
var path: Array[Vector2i] = pathfinder.find_path(pawn.tile, dest)
|
||||
if path.is_empty():
|
||||
Audit.log(
|
||||
"job_runner",
|
||||
"%s unreachable: %s → %s" % [pawn.pawn_name, pawn.tile, dest]
|
||||
)
|
||||
t.done = true
|
||||
return
|
||||
pawn.walk_along_path(path)
|
||||
t.data["started"] = true
|
||||
|
||||
|
||||
## Execute one tick of a WAIT toil.
|
||||
## Decrements the counter; sets done when it reaches zero.
|
||||
func _tick_wait(t) -> void:
|
||||
t.data["ticks_remaining"] -= 1
|
||||
if t.data["ticks_remaining"] <= 0:
|
||||
t.done = true
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
## Emit job_completed, log, and clear the job reference.
|
||||
func _emit_complete() -> void:
|
||||
var completed = job
|
||||
job = null
|
||||
Audit.log(
|
||||
"job_runner",
|
||||
"%s done: %s" % [pawn.pawn_name, completed.label]
|
||||
)
|
||||
emit_signal("job_completed", completed)
|
||||
1
scenes/ai/job_runner.gd.uid
Normal file
1
scenes/ai/job_runner.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://8v4lqcrhx1eu
|
||||
31
scenes/ai/rest_provider.gd
Normal file
31
scenes/ai/rest_provider.gd
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
class_name RestProvider extends WorkProvider
|
||||
## Phase 3 smoke-test WorkProvider: sends every pawn to a shared rest tile.
|
||||
##
|
||||
## If the pawn is already at rest_tile, returns a walk-less idle-forever job.
|
||||
## Otherwise prepends a walk_to toil before the idle toil.
|
||||
##
|
||||
## No internal state beyond rest_tile — Decision's log line carries all
|
||||
## the info needed for debugging (pawn name + provider category + job label).
|
||||
|
||||
|
||||
## The tile pawns walk toward. Set by the world scene on instantiation.
|
||||
@export var rest_tile: Vector2i = Vector2i(40, 40)
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
category = &"rest"
|
||||
priority = 0 # Only provider in Phase 3; no relative ordering needed yet.
|
||||
|
||||
|
||||
## Returns a Job for `pawn`. Never returns null — Rest always has something
|
||||
## to offer (walk there, or idle in place).
|
||||
## `pawn` is duck-typed: must expose .tile (Vector2i).
|
||||
func find_best_for(pawn) -> Job:
|
||||
var j := Job.new()
|
||||
j.label = "Rest at %s" % rest_tile
|
||||
|
||||
if pawn.tile != rest_tile:
|
||||
j.toils.append(Toil.walk_to(rest_tile))
|
||||
|
||||
j.toils.append(Toil.idle())
|
||||
return j
|
||||
1
scenes/ai/rest_provider.gd.uid
Normal file
1
scenes/ai/rest_provider.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dyacrro784lvo
|
||||
76
scenes/ai/toil.gd
Normal file
76
scenes/ai/toil.gd
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
class_name Toil extends RefCounted
|
||||
## A single atomic step within a Job — walk, wait, idle, etc.
|
||||
##
|
||||
## Save/load contract: every value in `data` MUST be JSON-safe.
|
||||
## Vector2i is NOT JSON-safe in Godot 4 — tile coordinates are stored as
|
||||
## "to_x"/"to_y" integer keys, never as Vector2i. get_walk_destination()
|
||||
## reconstructs Vector2i on demand.
|
||||
##
|
||||
## Round-trip invariant:
|
||||
## var t2 := Toil.from_dict(t.to_dict())
|
||||
## assert(t2.kind == t.kind and t2.done == t.done and t2.data == t.data)
|
||||
|
||||
const KIND_WALK: StringName = &"walk"
|
||||
const KIND_WAIT: StringName = &"wait"
|
||||
const KIND_IDLE: StringName = &"idle"
|
||||
|
||||
var kind: StringName = KIND_IDLE
|
||||
## Toil-specific params — all values must be int, float, bool, String, Dict, or Array.
|
||||
var data: Dictionary = {}
|
||||
## Set by JobRunner when this toil is complete.
|
||||
var done: bool = false
|
||||
|
||||
|
||||
# ── factories ────────────────────────────────────────────────────────────────
|
||||
|
||||
## Walk to the given tile. Stores coords as separate ints for JSON safety.
|
||||
static func walk_to(tile: Vector2i) -> Toil:
|
||||
var t := Toil.new()
|
||||
t.kind = KIND_WALK
|
||||
t.data = {
|
||||
"to_x": tile.x,
|
||||
"to_y": tile.y,
|
||||
"started": false,
|
||||
}
|
||||
return t
|
||||
|
||||
|
||||
## Pause for `n` sim ticks.
|
||||
static func wait_ticks(n: int) -> Toil:
|
||||
var t := Toil.new()
|
||||
t.kind = KIND_WAIT
|
||||
t.data = {"ticks_remaining": n}
|
||||
return t
|
||||
|
||||
|
||||
## Stand idle — never completes on its own; JobRunner must cancel or replace.
|
||||
static func idle() -> Toil:
|
||||
var t := Toil.new()
|
||||
t.kind = KIND_IDLE
|
||||
t.data = {}
|
||||
return t
|
||||
|
||||
|
||||
# ── save / load ──────────────────────────────────────────────────────────────
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"kind": str(kind),
|
||||
"data": data.duplicate(true),
|
||||
"done": done,
|
||||
}
|
||||
|
||||
|
||||
static func from_dict(d: Dictionary) -> Toil:
|
||||
var t := Toil.new()
|
||||
t.kind = StringName(d.get("kind", str(KIND_IDLE)))
|
||||
t.data = (d.get("data", {}) as Dictionary).duplicate(true)
|
||||
t.done = d.get("done", false)
|
||||
return t
|
||||
|
||||
|
||||
# ── convenience ──────────────────────────────────────────────────────────────
|
||||
|
||||
## Rebuild Vector2i from the JSON-safe int fields. Only valid for KIND_WALK.
|
||||
func get_walk_destination() -> Vector2i:
|
||||
return Vector2i(data.get("to_x", 0), data.get("to_y", 0))
|
||||
1
scenes/ai/toil.gd.uid
Normal file
1
scenes/ai/toil.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://djmc0woq4u65m
|
||||
27
scenes/ai/work_provider.gd
Normal file
27
scenes/ai/work_provider.gd
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
class_name WorkProvider extends Node
|
||||
## Abstract base for all work-category providers (Construction, Mining,
|
||||
## Hauling, Cooking, …). Subclass this and override find_best_for().
|
||||
##
|
||||
## Pawn AI layer 2: each pawn iterates its ordered list of WorkProviders
|
||||
## and calls find_best_for(self) until one returns a non-null Job.
|
||||
##
|
||||
## `pawn` is intentionally untyped (duck-typed) to avoid class_name
|
||||
## init-order issues. Concrete providers access pawn.tile, pawn.pawn_name,
|
||||
## pawn.is_walking(), etc. — the same public API exposed by Pawn.
|
||||
|
||||
## Work category key used to identify this provider. Must be unique per
|
||||
## provider instance; used by the priority matrix and Decision layer.
|
||||
@export var category: StringName = &"unspecified"
|
||||
|
||||
## Priority slot in the pawn's work-priority matrix.
|
||||
## Higher values are scanned first by the Decision layer.
|
||||
@export var priority: int = 0
|
||||
|
||||
|
||||
# ── abstract interface ───────────────────────────────────────────────────────
|
||||
|
||||
## Concrete providers MUST override this.
|
||||
## Return a Job for `pawn` to execute, or null if no suitable work exists.
|
||||
func find_best_for(pawn) -> Job:
|
||||
push_error("WorkProvider.find_best_for: subclass '%s' must override this method" % name)
|
||||
return null
|
||||
1
scenes/ai/work_provider.gd.uid
Normal file
1
scenes/ai/work_provider.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://vi08by1dh0lb
|
||||
|
|
@ -7,7 +7,7 @@ extends Node2D
|
|||
|
||||
|
||||
func _ready() -> void:
|
||||
Audit.log("main", "Phase 2 — world + pawns + pathfinder + selection online.")
|
||||
Audit.log("main", "Phase 3 — world + AI (Decision + JobRunner + RestProvider) online.")
|
||||
# Autoloads — keep these asserts; cheap and catch a renamed-autoload
|
||||
# regression instantly.
|
||||
assert(World != null, "World autoload missing")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -72,15 +72,17 @@ func _handle_click(screen_pos: Vector2) -> void:
|
|||
if _selected_pawn == null:
|
||||
return
|
||||
|
||||
# Empty walkable tile with a selection → pathfind + command move.
|
||||
# Empty walkable tile with a selection → queue a forced job. Decision picks
|
||||
# it up on the next sim tick (preempts whatever RestProvider had assigned).
|
||||
if not _pathfinder.is_walkable(tile):
|
||||
Audit.log("selection", "destination %s not walkable" % tile)
|
||||
return
|
||||
var path: Array[Vector2i] = _pathfinder.find_path(_selected_pawn.tile, tile)
|
||||
if path.is_empty():
|
||||
Audit.log("selection", "no path %s → %s" % [_selected_pawn.tile, tile])
|
||||
return
|
||||
_selected_pawn.walk_along_path(path)
|
||||
var go_job := Job.new()
|
||||
go_job.label = "Go to %s" % tile
|
||||
go_job.toils.append(Toil.walk_to(tile))
|
||||
go_job.toils.append(Toil.idle())
|
||||
_selected_pawn.forced_job = go_job
|
||||
Audit.log("selection", "forced %s → %s" % [_selected_pawn.pawn_name, tile])
|
||||
|
||||
|
||||
func _select(pawn: Pawn) -> void:
|
||||
|
|
|
|||
|
|
@ -39,10 +39,11 @@ const SAMPLE_PAWNS: Array[Dictionary] = [
|
|||
@onready var fog_layer: TileMapLayer = $Fog
|
||||
@onready var pathfinder: Pathfinder = $Pathfinder
|
||||
@onready var selection: Selection = $Selection
|
||||
@onready var rest_provider: RestProvider = $RestProvider
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Audit.log("world", "Phase 2 — building %d×%d world + pawns." % [MAP_SIZE_TILES.x, MAP_SIZE_TILES.y])
|
||||
Audit.log("world", "Phase 3 — building %d×%d world + pawns + AI." % [MAP_SIZE_TILES.x, MAP_SIZE_TILES.y])
|
||||
var tileset := _build_placeholder_tileset()
|
||||
for layer in [terrain_layer, floor_layer, wall_layer, designation_layer, roof_layer, fog_layer]:
|
||||
layer.tile_set = tileset
|
||||
|
|
@ -54,6 +55,8 @@ func _ready() -> void:
|
|||
_wire_walls_to_pathfinder()
|
||||
selection.bind(pathfinder)
|
||||
|
||||
World.register_work_provider(rest_provider)
|
||||
|
||||
_spawn_sample_pawns()
|
||||
_run_pathfinder_spike()
|
||||
|
||||
|
|
@ -133,6 +136,14 @@ func _spawn_sample_pawns() -> void:
|
|||
var p: Pawn = PAWN_SCENE.instantiate()
|
||||
add_child(p)
|
||||
p.setup(spawn_data["name"], spawn_data["tile"])
|
||||
|
||||
# Phase 3: attach a JobRunner so Decision can hand it jobs.
|
||||
var jr := JobRunner.new()
|
||||
jr.name = "JobRunner"
|
||||
p.add_child(jr)
|
||||
jr.setup(p, pathfinder)
|
||||
p.job_runner = jr
|
||||
|
||||
World.register_pawn(p)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
[gd_scene load_steps=5 format=3 uid="uid://rimlike_world"]
|
||||
[gd_scene load_steps=6 format=3 uid="uid://rimlike_world"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/world/world.gd" id="1_world"]
|
||||
[ext_resource type="PackedScene" uid="uid://rimlike_camera_rig" path="res://scenes/world/camera_rig.tscn" id="2_camera"]
|
||||
[ext_resource type="Script" path="res://scenes/world/pathfinder.gd" id="3_pathfinder"]
|
||||
[ext_resource type="Script" path="res://scenes/world/selection.gd" id="4_selection"]
|
||||
[ext_resource type="Script" path="res://scenes/ai/rest_provider.gd" id="5_rest_provider"]
|
||||
|
||||
[node name="World" type="Node2D"]
|
||||
script = ExtResource("1_world")
|
||||
|
|
@ -34,5 +35,9 @@ script = ExtResource("3_pathfinder")
|
|||
[node name="Selection" type="Node" parent="."]
|
||||
script = ExtResource("4_selection")
|
||||
|
||||
[node name="RestProvider" type="Node" parent="."]
|
||||
script = ExtResource("5_rest_provider")
|
||||
rest_tile = Vector2i(50, 50)
|
||||
|
||||
[node name="CameraRig" parent="." instance=ExtResource("2_camera")]
|
||||
position = Vector2(640, 640)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue