rimlike/scenes/world/selection.gd
megaproxy 5bf0f51efb 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>
2026-05-10 21:05:50 +01:00

95 lines
2.9 KiB
GDScript

extends Node
class_name Selection
## Pawn selection + click-to-move input handler.
##
## A click on a pawn selects it; a click on a walkable tile while a pawn is
## selected pathfinds + commands the walk. Drags belong to the camera (pan)
## — we discriminate clicks from drags by motion + duration thresholds.
##
## Lives as a child of World; `_unhandled_input` runs after the camera rig
## and after any CanvasLayer UI swallows its own clicks.
const CLICK_MAX_DRIFT_PX: float = 8.0
const CLICK_MAX_DURATION_MS: int = 300
var _pathfinder: Pathfinder = null
var _selected_pawn: Pawn = null
var _press_screen_pos: Vector2 = Vector2.ZERO
var _press_time_ms: int = 0
var _pressing: bool = false
func bind(pathfinder: Pathfinder) -> void:
assert(pathfinder != null, "Selection.bind: pathfinder is null")
_pathfinder = pathfinder
func selected() -> Pawn:
return _selected_pawn
func _unhandled_input(event: InputEvent) -> void:
if not (event is InputEventMouseButton):
return
if event.button_index != MOUSE_BUTTON_LEFT:
return
if event.pressed:
_press_screen_pos = event.position
_press_time_ms = Time.get_ticks_msec()
_pressing = true
return
if not _pressing:
return
_pressing = false
var drift: float = event.position.distance_to(_press_screen_pos)
var dt_ms: int = Time.get_ticks_msec() - _press_time_ms
# Anything that drifted more than a few pixels or sat for more than 300 ms
# is the camera's drag-pan; ignore it as a select/move action.
if drift > CLICK_MAX_DRIFT_PX or dt_ms > CLICK_MAX_DURATION_MS:
return
_handle_click(event.position)
func _handle_click(screen_pos: Vector2) -> void:
if _pathfinder == null:
Audit.log("selection", "click before bind() — ignored")
return
var world_pos: Vector2 = get_viewport().get_canvas_transform().affine_inverse() * screen_pos
var tile: Vector2i = Vector2i(
floori(world_pos.x / float(Pawn.TILE_SIZE_PX)),
floori(world_pos.y / float(Pawn.TILE_SIZE_PX)),
)
# Click on a pawn → select.
var hit_pawn: Pawn = World.pawn_at_tile(tile)
if hit_pawn != null:
_select(hit_pawn)
return
# Empty tile with no current selection → no-op.
if _selected_pawn == null:
return
# 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 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:
if _selected_pawn == pawn:
return
if _selected_pawn != null:
_selected_pawn.set_selected(false)
_selected_pawn = pawn
pawn.set_selected(true)
Audit.log("selection", "selected %s at %s" % [pawn.pawn_name, pawn.tile])