Pawn work loop: claim, navigate, work, complete #120

Merged
liamjd merged 6 commits from 75-pawn-work-loop into main 2026-09-19 14:05:03 +01:00
Owner

Closes #75

The consumer side of the task system. Pawns.on_tick() walks every pawn one step through IDLING -> MOVING -> WORKING: an idle pawn asks next_for(), claims, and either sets off for the target cell or starts work where it stands; a working pawn contributes EFFORT_PER_TICK and completes the task through the manager when work_remaining reaches zero. game.gd connects it to GameClock.tick.

Most of the surrounding machinery already existed — next_for() from #67, move_to() from milestone #4, the arrival transition from #112, and the manager's signals clearing a task off its pawn. What was missing was anything driving it, so a placed building sat PLANNED forever with a task in the queue nobody asked for.

Unreachable work is blocked by the queue, not by the pawn

The issue's AC said an unreachable target should be released and marked BLOCKED by the pawn. That does not work, and finding out why changed the design.

BLOCKED is a derived label, not a state. next_for() does not skip blocked tasks — it re-tests each one with can_start() and returns it to PENDING the moment that passes. So a BLOCKED written by the work loop survives only until the next pawn asks, in the same tick. A walled-off high-priority task would be re-offered forever while reachable work waited behind it: a livelock, not a slow retry.

Reachability therefore moved into can_start(), gated on TerrainGrid._main_region — the largest connected walkable region, which the map already maintains and recomputes on every block and unblock. A target outside it cannot be reached by a pawn inside it, which is the case that matters while the colony is on one landmass. Blocked-ness stays derived, with one source of truth, and a task un-blocks itself the moment the route reopens.

Consequences:

  • TaskContext.map is populated after all — the commented-out line annotated "required for #75" was right
  • TerrainGrid.is_in_main_region(cell) is new
  • next_for() now calls can_start() rather than _can_start(), or the gate would be invisible to it, and its inline children check is gone because can_start() already does it

move_to() returning false remains, as the case it actually is: this pawn is stranded, not this task is unworkable. The loop releases the task back to PENDING for somebody better placed and does not mark it.

Verification

  • Headless: 557 passing, 1 known pending (#85), 0 failing. 21 new tests in test/unit/test_pawn_work_loop.gd, including an end-to-end one that places a building, ticks the pawn to it and through the work, and asserts BUILT — through a real ConstructionTask and Buildings registry rather than the stub, so the status transitions are proven to land on the building record
  • Headed: a pawn claims a construction task, walks to the building's entrance and begins work. Completion was not watched to the end — one pawn-day is 1200 ticks, two minutes of real time at 1x — so BUILT is proven by the headless test rather than by eye

Decisions worth knowing

  • game.gd owns the GameClock.tick connection, not Pawns._ready(). GameClock is an autoload and keeps ticking through the test run; a self-subscribing registry would have the live clock driving work inside every other pawn test, making them pass or fail on how long the suite took
  • One transition per tick. A pawn claiming a task while standing on the site becomes WORKING that tick and works on the next. A tenth of a second at 10 tps, and it keeps the loop a match with no fallthrough
  • A pawn that loses its task mid-walk is left walking. #112 deferred this here; stopping every taskless walker would freeze debug_scatter_pawns()
  • One pawn contributes 1.0 effort per tick, which is what makes #88's pawn-day unit true rather than aspirational. Skill multipliers are #74; multiple pawns per task is #117

Left undone

  • The debug panel is deliberately crude. Pawn and task rows, no styling. Two known rough edges: a pawn spawned while the panel is open gets no row until debug mode is toggled (the task list creates rows on demand; the pawn list does not), and the prune loop reaches removed pawns through Pawns.get_pawn(), which push_errors on an unknown id before returning null. Neither is player-facing
  • game.gd types debug_layer as CanvasLayer, so debug_layer.tasks = task_manager is an unsafe property access. Typing it DebugLayer would make it checked
  • Feedback for a permanently blocked task is #119, filed off the back of playing this: an unreachable site is now correctly refused, but silently, and looks identical to work nobody has got to yet
  • is_in_main_region() is an Array.has() linear scan over ~500 cells, now called per task per pawn per tick. Fine at ten pawns; a Dictionary-as-set is the fix if it ever shows up
  • Multi-pawn work slots are #117; Task.assignee stays single-valued, decided in #111

🤖 Generated with Claude Code

Closes #75 The consumer side of the task system. `Pawns.on_tick()` walks every pawn one step through IDLING -> MOVING -> WORKING: an idle pawn asks `next_for()`, claims, and either sets off for the target cell or starts work where it stands; a working pawn contributes `EFFORT_PER_TICK` and completes the task through the manager when `work_remaining` reaches zero. game.gd connects it to `GameClock.tick`. Most of the surrounding machinery already existed — `next_for()` from #67, `move_to()` from milestone #4, the arrival transition from #112, and the manager's signals clearing a task off its pawn. What was missing was anything driving it, so a placed building sat PLANNED forever with a task in the queue nobody asked for. ## Unreachable work is blocked by the queue, not by the pawn The issue's AC said an unreachable target should be released and marked BLOCKED by the pawn. That does not work, and finding out why changed the design. BLOCKED is a **derived label, not a state**. `next_for()` does not skip blocked tasks — it re-tests each one with `can_start()` and returns it to PENDING the moment that passes. So a BLOCKED written by the work loop survives only until the next pawn asks, in the same tick. A walled-off high-priority task would be re-offered forever while reachable work waited behind it: a livelock, not a slow retry. Reachability therefore moved into `can_start()`, gated on `TerrainGrid._main_region` — the largest connected walkable region, which the map already maintains and recomputes on every block and unblock. A target outside it cannot be reached by a pawn inside it, which is the case that matters while the colony is on one landmass. Blocked-ness stays derived, with one source of truth, and a task un-blocks itself the moment the route reopens. Consequences: - `TaskContext.map` is populated after all — the commented-out line annotated "required for #75" was right - `TerrainGrid.is_in_main_region(cell)` is new - `next_for()` now calls `can_start()` rather than `_can_start()`, or the gate would be invisible to it, and its inline children check is gone because `can_start()` already does it `move_to()` returning false remains, as the case it actually is: *this pawn* is stranded, not *this task* is unworkable. The loop releases the task back to PENDING for somebody better placed and does not mark it. ## Verification - **Headless: 557 passing, 1 known pending (#85), 0 failing.** 21 new tests in `test/unit/test_pawn_work_loop.gd`, including an end-to-end one that places a building, ticks the pawn to it and through the work, and asserts BUILT — through a real `ConstructionTask` and `Buildings` registry rather than the stub, so the status transitions are proven to land on the building record - **Headed:** a pawn claims a construction task, walks to the building's entrance and begins work. Completion was not watched to the end — one pawn-day is 1200 ticks, two minutes of real time at 1x — so BUILT is proven by the headless test rather than by eye ## Decisions worth knowing - **game.gd owns the `GameClock.tick` connection**, not `Pawns._ready()`. GameClock is an autoload and keeps ticking through the test run; a self-subscribing registry would have the live clock driving work inside every other pawn test, making them pass or fail on how long the suite took - **One transition per tick.** A pawn claiming a task while standing on the site becomes WORKING that tick and works on the next. A tenth of a second at 10 tps, and it keeps the loop a `match` with no fallthrough - **A pawn that loses its task mid-walk is left walking.** #112 deferred this here; stopping every taskless walker would freeze `debug_scatter_pawns()` - **One pawn contributes 1.0 effort per tick**, which is what makes #88's pawn-day unit true rather than aspirational. Skill multipliers are #74; multiple pawns per task is #117 ## Left undone - **The debug panel is deliberately crude.** Pawn and task rows, no styling. Two known rough edges: a pawn spawned while the panel is open gets no row until debug mode is toggled (the task list creates rows on demand; the pawn list does not), and the prune loop reaches removed pawns through `Pawns.get_pawn()`, which push_errors on an unknown id before returning null. Neither is player-facing - `game.gd` types `debug_layer` as `CanvasLayer`, so `debug_layer.tasks = task_manager` is an unsafe property access. Typing it `DebugLayer` would make it checked - **Feedback for a permanently blocked task is #119**, filed off the back of playing this: an unreachable site is now correctly refused, but silently, and looks identical to work nobody has got to yet - `is_in_main_region()` is an `Array.has()` linear scan over ~500 cells, now called per task per pawn per tick. Fine at ten pawns; a Dictionary-as-set is the fix if it ever shows up - Multi-pawn work slots are #117; `Task.assignee` stays single-valued, decided in #111 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Tests first, against the seam rather than an implementation: Pawns.on_tick()
and Pawns.EFFORT_PER_TICK are declared with an empty body so the spec compiles
and every test fails on behaviour, not on a parse error.

18 of the 20 tests fail; the two that pass are the two that assert nothing
happens - an empty queue and a registry with no task manager - which a no-op
loop satisfies correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BLOCKED is a derived label, not a state: next_for() re-tests every task with
_can_start() and flips it back to PENDING, so a BLOCKED written by the work
loop survives only until the next pawn asks, in the same tick. The earlier
draft of this spec had the loop claim, walk, fail and mark BLOCKED, which
therefore livelocks - a walled-off high-priority task is re-offered forever
while reachable work waits behind it.

Reachability moves into can_start(), gated on TerrainGrid._main_region, which
the map already maintains and recomputes on every block and unblock. So
TaskContext.map is required for #75 after all, and next_for() has to call
can_start() rather than _can_start() for the gate to be seen.

move_to() failing stays as the case it actually is: this pawn is stranded, not
this task is unworkable - release to PENDING, do not mark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_make_registry() predates TaskContext.map, so every task in the spec was
checked for reachability against a null map and waved through. The three
walled-off tests failed with the gate correctly implemented on both sides of
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Connect `GameClock.tick` to `Pawns.on_tick()` and implement the pawn work loop to transition pawns between IDLING, MOVING, and WORKING states. Ensure unreachable tasks are blocked via `Task.can_start()` using `TerrainGrid._main_region`. Update `TaskContext` and `move_to()` logic, and improve task claim/release handling.
liamjd merged commit 5c21d5b808 into main 2026-09-19 14:05:03 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
liamjd/UntitledColonyBuilder!120
No description provided.