rimlike/scenes/ai/job_runner.gd
megaproxy 91bceeebe8 Phase 4 — Trees, Rocks, Items, Stockpiles, Hauling
Three gdscript-refactor agents in parallel + Opus integration.

Entities (scenes/entities/, Agent A — 3 scripts + 3 .tscn, ~460 lines):
- item.gd: 16-type StringName registry (matches design.md filter chips);
  Node2D + _draw() colored square + stack-count badge; to_dict/from_dict
- tree.gd: class_name HarvestableTree (Godot 4 ships a built-in 'Tree'
  Control class — renamed to avoid the shadow); CHOP_TICKS=80; on_chop_tick
  advances progress, fells when complete, drops 3 wood items at tile +
  walkable neighbours
- rock.gd: MINE_TICKS=120; angular polygon _draw; mined() drops 1 stone

Toil + provider extensions (scenes/ai/, Agent B — 4 files modified/added,
~250 lines):
- Toil: new KIND_INTERACT (timed entity action), KIND_PICKUP, KIND_DEPOSIT
- JobRunner: _tick_interact resolves NodePath, calls target.<method>()
  each tick, marks done when is_choppable/is_mineable returns false;
  _tick_pickup finds Item at pawn.tile, transfers to pawn.carried_item;
  _tick_deposit places carried_item at pawn.tile + clears the
  items_needing_haul dirty flag
- ChopProvider (priority=5): nearest choppable tree; Job=[walk_to + interact]
- MineProvider (priority=4): same for rocks

Hauling system (scenes/world/ + scenes/ai/, Agent C — 4 files, ~330 lines):
- StorageDestination: abstract Node2D base; Priority enum CRITICAL=0..OFF=4;
  accepted_types (empty=wildcard); _filter_accepts() helper
- StockpileZone: concrete rect-region zone; _draw paints priority-tinted
  overlay (z_index=-1); find_drop_position scans for free cells respecting
  one-stack-per-tile rule
- HaulingProvider (priority=3): nearest dirty item × best destination →
  4-toil job [walk → pickup → walk → deposit]; sweep_for_better_destinations
  enables the priority cascade (items in lower-priority zones re-mark dirty
  when a higher-priority destination opens up)

Opus integration (~200 lines):
- World autoload: trees/rocks/items/items_needing_haul/stockpiles registries
  + register/unregister methods; pathfinder reference exposed for entity
  code (tree.fell needs is_walkable for neighbour drops)
- Pawn: carried_item slot + carry-indicator (small colored rect upper-right
  of body) via queue_redraw in _on_sim_tick
- World scene: registers chop/mine/haul/rest providers; spawns 6 trees
  (cluster east-north), 4 rocks (south-east), 2 stockpile zones (Zone A
  wood-only NORMAL, Zone B wildcard HIGH); periodic
  hauling_provider.sweep_for_better_destinations every 100 sim ticks

Acceptance — MCP-verified end-to-end (the full Phase 4 loop):
- 3 pawns boot, Decision picks chop (highest priority work), all walk to
  nearest tree, chop in parallel (3× speed because all 3 call on_chop_tick
  per tick). Trees fell, drop wood (18 items). Pawns move to rocks, mine,
  drop stone (4 items). Total 22 items spawn.
- HaulingProvider routes wood + stone toward Zone B (wildcard HIGH > Zone
  A's wood-only NORMAL). Pawns carry items one at a time, visual indicator
  shows during transit. Items deposit, items_needing_haul dirty flag
  clears.
- **Priority cascade test:** Zone A promoted from NORMAL to CRITICAL.
  Manually-triggered sweep marks 3 wood items in Zone B for re-haul.
  Within a few thousand ticks: Zone A has 5 wood (cascaded from Zone B),
  Zone B has 4 stone only (wood left, stone stayed because Zone A rejects
  stone). Filter + priority cascade working exactly per design.md spec.

Phase 4 gotchas (logged in implementation.md):
- 'Tree' shadows Godot 4's built-in Tree Control class — class_name had to
  be renamed to HarvestableTree. Scene/file names stayed as 'tree' since
  the game concept is still 'tree'; the rename only affects code-side
  type references.
- draw_colored_polygon(points, color) takes a SINGLE Color, not a
  PackedColorArray. Agent C had to be reminded; draw_polygon(points, colors)
  is the variant that takes per-vertex colors.
- Godot's class-name cache lags behind file changes — a full editor scan
  ('godot --headless --editor --quit') is needed to flush. Even after
  reload_project, type-annotation assignments can fail; duck-typed
  variables ('var x = scene.instantiate()') sidestep the issue.
- JobRunner's _tick_deposit had to explicitly call
  World.clear_item_haul_flag — the dirty set persisted otherwise and
  items appeared 'needing haul' even after deposit.

Delegation report this phase:
- Agent A (Sonnet, gdscript-refactor): Tree + Rock + Item entities + i18n
  keys. ~460 lines.
- Agent B (Sonnet, gdscript-refactor): Toil extensions + JobRunner handlers
  + ChopProvider + MineProvider. ~250 lines.
- Agent C (Sonnet, gdscript-refactor): StorageDestination + StockpileZone
  + HaulingProvider with cascade sweep. ~330 lines.
- Opus: World autoload extensions (entity registries + pathfinder ref),
  Pawn carry slot + visual, world.tscn/gd wiring, the Tree rename, the
  draw_colored_polygon fix, the dirty-set-clear fix, MCP-driven runtime
  verification including the full chop-mine-haul loop and the priority
  cascade demo.

~75% of Phase 4's GDScript was subagent-authored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:32:39 +01:00

295 lines
10 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
Toil.KIND_INTERACT:
_tick_interact(t)
Toil.KIND_PICKUP:
_tick_pickup(t)
Toil.KIND_DEPOSIT:
_tick_deposit(t)
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
## Execute one tick of an INTERACT toil.
##
## First tick: resolve the target node from the stored NodePath string.
## If the target is gone or freed, log and skip immediately (done=true).
## Otherwise mark started and log the action start.
##
## Every subsequent tick: call tick_method on the target. After the call,
## check whether the target has been consumed (is_choppable/is_mineable
## returns false, or the node was freed). If so, mark done.
func _tick_interact(t) -> void:
var target_path := NodePath(t.data.get("target", ""))
var target = get_tree().get_root().get_node_or_null(target_path)
if not t.data.get("started", false):
if target == null or not is_instance_valid(target):
Audit.log(
"job_runner",
"%s interact target gone — skipping" % pawn.pawn_name
)
t.done = true
return
t.data["started"] = true
Audit.log(
"job_runner",
"%s interact start: %s.%s" % [pawn.pawn_name, target.name, t.data.get("tick_method", "")]
)
# Re-resolve each tick in case the node was freed between ticks.
target = get_tree().get_root().get_node_or_null(target_path)
if target == null or not is_instance_valid(target):
t.done = true
return
target.call(StringName(t.data.get("tick_method", "")))
# Re-check validity after the call (the call may have freed the node).
if target == null or not is_instance_valid(target):
t.done = true
return
if target.has_method("is_choppable") and not target.is_choppable():
Audit.log("job_runner", "%s interact done: %s chopped" % [pawn.pawn_name, target.name])
t.done = true
return
if target.has_method("is_mineable") and not target.is_mineable():
Audit.log("job_runner", "%s interact done: %s mined" % [pawn.pawn_name, target.name])
t.done = true
## Execute one tick of a PICKUP toil.
##
## Finds the first unheld Item at pawn.tile in World.items.
## Transfers it into pawn.carried_item via set_being_carried(true).
## Completes in a single tick.
func _tick_pickup(t) -> void:
var item = null
for it in World.items:
if it.tile == pawn.tile and not it.being_carried:
item = it
break
if item == null:
Audit.log(
"job_runner",
"%s pickup: no item at %s" % [pawn.pawn_name, pawn.tile]
)
t.done = true
return
pawn.carried_item = item
item.set_being_carried(true)
Audit.log(
"job_runner",
"%s pickup: %s ×%d" % [pawn.pawn_name, item.item_type, item.stack_size]
)
t.done = true
## Execute one tick of a DEPOSIT toil.
##
## Places pawn.carried_item at pawn.tile (pixel-centred in the 16 px grid).
## Clears pawn.carried_item. Completes in a single tick.
func _tick_deposit(t) -> void:
if pawn.carried_item == null:
Audit.log(
"job_runner",
"%s deposit: nothing to deposit" % pawn.pawn_name
)
t.done = true
return
var item = pawn.carried_item
item.tile = pawn.tile
item.position = Vector2(pawn.tile.x * 16 + 8, pawn.tile.y * 16 + 8)
item.set_being_carried(false)
pawn.carried_item = null
# Phase 4: clear the haul-dirty flag — item has landed at its destination.
# The periodic sweep_for_better_destinations will re-mark it if a higher-
# priority destination opens up later.
World.clear_item_haul_flag(item)
Audit.log(
"job_runner",
"%s deposit: %s ×%d at %s" % [pawn.pawn_name, item.item_type, item.stack_size, pawn.tile]
)
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)