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

Closed
opened 2026-09-07 20:37:24 +01:00 by liamjd · 1 comment
Owner

Part of milestone #5. Depends on #65, #67 and #70. Blocked by milestone #4 for navigation.

The consumer side of the task system. Everything before this is testable without a pawn moving; this is where it becomes visible.

AC:

  • An idle pawn asks the manager for next_for(self) on each GameClock tick (not every frame)
  • On receiving a task it claims it and navigates to target_cell
  • Arrived, it calls task.work() once per tick until work_remaining reaches zero, then completes the task through the manager
  • If the target is unreachable, the task is released and marked BLOCKED and the pawn returns to idle
  • If the task is cancelled mid-work, the pawn stops and returns to idle
  • Pawn state (IDLE, MOVING, WORKING) is visible in the debug panel
  • Unit test the state machine with a stub navigator; verify the full loop headed with a placed building becoming BUILT
Part of milestone #5. Depends on #65, #67 and #70. **Blocked by milestone #4** for navigation. The consumer side of the task system. Everything before this is testable without a pawn moving; this is where it becomes visible. AC: - An idle pawn asks the manager for `next_for(self)` on each `GameClock` tick (not every frame) - On receiving a task it claims it and navigates to `target_cell` - Arrived, it calls `task.work()` once per tick until `work_remaining` reaches zero, then completes the task through the manager - If the target is unreachable, the task is released and marked BLOCKED and the pawn returns to idle - If the task is cancelled mid-work, the pawn stops and returns to idle - Pawn state (IDLE, MOVING, WORKING) is visible in the debug panel - Unit test the state machine with a stub navigator; verify the full loop headed with a placed building becoming BUILT
Author
Owner

Spec written, on branch 75-pawn-work-loop: test/unit/test_pawn_work_loop.gd, 20 tests, 18 red. The two that pass assert that nothing happens — an empty queue, and a registry with no TaskManager — which a no-op loop satisfies correctly.

Most of this issue already exists. next_for() hands out tasks (#67), move_to() walks a pawn to a cell, arrival flips it to WORKING when it lands on the target cell (#112), and the manager's completed/cancelled/released signals clear the task off the pawn again. What is missing is the driver: nothing in the game connects to GameClock.tick, so a placed building sits PLANNED forever with a task in the queue nobody asks for.

The seam

Pawns.on_tick(run_tick: int), declared with an empty body so the spec compiles, plus Pawns.EFFORT_PER_TICK = 1.0. The loop is a match on pawn.state:

  • IDLING — next_for(pawn.id); null ends the tick. Otherwise claim(), assign_task(), then either move_to(target_cell) or, if the pawn is already standing on it, WORKING directly.
  • MOVING — nothing. The view and the arrival handler own the walk.
  • WORKING — start() if the task is still CLAIMED, then work(ctx, EFFORT_PER_TICK), then complete() when work_remaining hits zero.

Decisions taken in the spec

Each of these is pinned by a test, so overruling one means changing a test rather than discovering it later.

  1. game.gd owns the GameClock.tick connection, not Pawns._ready() and not the tasks setter. GameClock is an autoload and keeps ticking through the test run; a registry that subscribed itself would have the live clock driving work in the middle of every other pawn test, and those tests would pass or fail on how long the suite took. It also matches how map and tasks already arrive.
  2. One transition per tick. A pawn that claims a task while standing on the site becomes WORKING that tick and does its first work on the next. Costs a tenth of a second at 10 tps; buys a match with no fallthrough between the arms.
  3. Unreachable is discovered by walking. can_start() takes a context and no pawn, so it cannot answer "reachable from where" — two pawns either side of a severed map disagree about the same task. The loop claims, calls move_to(), and reads false as the answer: release, mark BLOCKED, pawn back to idle. So TaskContext.map — the commented line annotated "required for #75" — is not required. Leave it commented or delete it; this issue should not add it.
  4. A task blocked that way is retried every tick, because next_for() re-evaluates BLOCKED tasks back to PENDING. That is a real loop rather than an oversight, and it is cheap at ten pawns. Pinned by a test so the event-driven unblocking noted in #67 changes a test instead of arriving by surprise.
  5. A pawn that loses its task mid-walk is left walking. #112 deferred this call to here, and the answer is no: debug_scatter_pawns() sends pawns walking with no task at all, and a loop that stopped every taskless walker would freeze them where they stand. Arrival is what idles it.
  6. One pawn, one unit of effort per tick. work_remaining is in work-ticks, so a pawn working a day of ticks discharges one pawn-day — which is what makes #88's unit true rather than aspirational. Skill multipliers are #74; a second pawn on the same task is #117.

Not covered by the spec

The debug panel pawn rows AC. ui/debug_layer.gd currently shows seed, terrain preset, tick and the command list, and reaches its collaborators through $"../Map" and $"../Pawns", so it cannot be built standalone in a test. The rows (id, state, cell, task) are implementation plus a headed check, not something the suite can hold.

The end-to-end AC — a placed building becoming BUILT — is covered headless, through a real ConstructionTask and a real Buildings registry sharing the manager: place, tick to claim, walk, and tick until BUILT. A headed run is still worth doing for the pawn rows and to watch it happen.

Spec written, on branch `75-pawn-work-loop`: `test/unit/test_pawn_work_loop.gd`, 20 tests, 18 red. The two that pass assert that nothing happens — an empty queue, and a registry with no `TaskManager` — which a no-op loop satisfies correctly. Most of this issue already exists. `next_for()` hands out tasks (#67), `move_to()` walks a pawn to a cell, arrival flips it to WORKING when it lands on the target cell (#112), and the manager's completed/cancelled/released signals clear the task off the pawn again. What is missing is the driver: nothing in the game connects to `GameClock.tick`, so a placed building sits PLANNED forever with a task in the queue nobody asks for. ## The seam `Pawns.on_tick(run_tick: int)`, declared with an empty body so the spec compiles, plus `Pawns.EFFORT_PER_TICK = 1.0`. The loop is a `match` on `pawn.state`: - **IDLING** — `next_for(pawn.id)`; null ends the tick. Otherwise `claim()`, `assign_task()`, then either `move_to(target_cell)` or, if the pawn is already standing on it, WORKING directly. - **MOVING** — nothing. The view and the arrival handler own the walk. - **WORKING** — `start()` if the task is still CLAIMED, then `work(ctx, EFFORT_PER_TICK)`, then `complete()` when `work_remaining` hits zero. ## Decisions taken in the spec Each of these is pinned by a test, so overruling one means changing a test rather than discovering it later. 1. **game.gd owns the `GameClock.tick` connection**, not `Pawns._ready()` and not the `tasks` setter. GameClock is an autoload and keeps ticking through the test run; a registry that subscribed itself would have the live clock driving work in the middle of every other pawn test, and those tests would pass or fail on how long the suite took. It also matches how `map` and `tasks` already arrive. 2. **One transition per tick.** A pawn that claims a task while standing on the site becomes WORKING that tick and does its first work on the next. Costs a tenth of a second at 10 tps; buys a `match` with no fallthrough between the arms. 3. **Unreachable is discovered by walking.** `can_start()` takes a context and no pawn, so it cannot answer "reachable from where" — two pawns either side of a severed map disagree about the same task. The loop claims, calls `move_to()`, and reads `false` as the answer: release, mark BLOCKED, pawn back to idle. **So `TaskContext.map` — the commented line annotated "required for #75" — is not required.** Leave it commented or delete it; this issue should not add it. 4. **A task blocked that way is retried every tick**, because `next_for()` re-evaluates BLOCKED tasks back to PENDING. That is a real loop rather than an oversight, and it is cheap at ten pawns. Pinned by a test so the event-driven unblocking noted in #67 changes a test instead of arriving by surprise. 5. **A pawn that loses its task mid-walk is left walking.** #112 deferred this call to here, and the answer is no: `debug_scatter_pawns()` sends pawns walking with no task at all, and a loop that stopped every taskless walker would freeze them where they stand. Arrival is what idles it. 6. **One pawn, one unit of effort per tick.** `work_remaining` is in work-ticks, so a pawn working a day of ticks discharges one pawn-day — which is what makes #88's unit true rather than aspirational. Skill multipliers are #74; a second pawn on the same task is #117. ## Not covered by the spec The **debug panel pawn rows** AC. `ui/debug_layer.gd` currently shows seed, terrain preset, tick and the command list, and reaches its collaborators through `$"../Map"` and `$"../Pawns"`, so it cannot be built standalone in a test. The rows (id, state, cell, task) are implementation plus a headed check, not something the suite can hold. The end-to-end AC — a placed building becoming BUILT — *is* covered headless, through a real `ConstructionTask` and a real `Buildings` registry sharing the manager: place, tick to claim, walk, and tick until BUILT. A headed run is still worth doing for the pawn rows and to watch it happen.
Sign in to join this conversation.
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.

Blocks
Reference
liamjd/UntitledColonyBuilder#75
No description provided.