Multi-pawn work slots, opted into per site #117

Closed
opened 2026-09-19 09:59:04 +01:00 by liamjd · 1 comment
Owner

Part of milestone #5. Deferred out of #111, which kept Task.assignee single-valued so #75's work loop had one unambiguous answer to "who is working this task".

Multi-pawn construction is wanted, but as a player decision rather than as automatic swarming. The shape agreed:

  • Each task type declares the maximum number of pawns that may work it — construction 3, hauling 1. It is a property of the kind of work, not of the individual task: two pawns cannot carry one crate between them, but three can raise one wall.
  • A newly created task opens one slot. It does not start claimable by its type's maximum.
  • The player opens the extra slots deliberately, by selecting the construction site and choosing to do so. Throwing the whole crew at one building is a choice with an opportunity cost elsewhere in the colony, and that cost is the interesting part.

This is what makes BuildingType.build_days pawn-days rather than days: with one worker the elapsed time equals the authored figure, and opening slots is how the player buys it down.

Depends on #75 (the work loop that assigns a pawn to a task at all) and on #78 (selecting something on the map, which the slot UI needs to hang off).

AC:

  • max_workers on the task type, defaulting to 1; ConstructionTask sets 3
  • open_slots (or equivalent) on the task instance, starting at 1 and capped at max_workers
  • Task.assignee: int becomes assignees: Array[int]; claim() appends, release(id, pawn_id) removes one pawn without disturbing the others
  • next_for() skips a task whose assignees have filled its open slots, rather than displacing a pawn already on it
  • Every assigned pawn contributes its effort to the same task each tick, so work_remaining falls proportionally to the headcount
  • Selecting a construction site offers the extra slots, up to the type maximum
  • A pawn released or removed mid-build frees its slot without cancelling the task
  • Unit tests: two pawns on one task, neither displacing the other; a third refused while only two slots are open; effort accumulating from several assignees in one tick; release of one assignee leaving the rest working
Part of milestone #5. Deferred out of #111, which kept `Task.assignee` single-valued so #75's work loop had one unambiguous answer to "who is working this task". Multi-pawn construction is wanted, but as a player decision rather than as automatic swarming. The shape agreed: - Each **task type** declares the maximum number of pawns that may work it — construction 3, hauling 1. It is a property of the kind of work, not of the individual task: two pawns cannot carry one crate between them, but three can raise one wall. - A newly created task opens **one** slot. It does not start claimable by its type's maximum. - The player opens the extra slots deliberately, by selecting the construction site and choosing to do so. Throwing the whole crew at one building is a choice with an opportunity cost elsewhere in the colony, and that cost is the interesting part. This is what makes `BuildingType.build_days` pawn-days rather than days: with one worker the elapsed time equals the authored figure, and opening slots is how the player buys it down. Depends on #75 (the work loop that assigns a pawn to a task at all) and on #78 (selecting something on the map, which the slot UI needs to hang off). AC: - `max_workers` on the task type, defaulting to 1; `ConstructionTask` sets 3 - `open_slots` (or equivalent) on the task instance, starting at 1 and capped at `max_workers` - `Task.assignee: int` becomes `assignees: Array[int]`; `claim()` appends, `release(id, pawn_id)` removes one pawn without disturbing the others - `next_for()` skips a task whose assignees have filled its open slots, rather than displacing a pawn already on it - Every assigned pawn contributes its effort to the same task each tick, so `work_remaining` falls proportionally to the headcount - Selecting a construction site offers the extra slots, up to the type maximum - A pawn released or removed mid-build frees its slot without cancelling the task - Unit tests: two pawns on one task, neither displacing the other; a third refused while only two slots are open; effort accumulating from several assignees in one tick; release of one assignee leaving the rest working
Author
Owner

Spec (drafted with Claude)

Decisions made on 2026-09-28

  • UI split out. This issue delivers the model, the work loop and a debug command. The player-facing control on a selected site is #136, which depends on #78. The "Selecting a construction site offers the extra slots" criterion moves there.
  • Lowering slots below the headcount releases the newest pawns first. Everyone else stays. Pawns are released in reverse claim order until the headcount fits.
  • DeconstructionTask.max_workers() is 3, the same as construction. Hauling (#73) will be 1, which is the default.
  • Debug command O ("Open worker slot") adds one slot to every task whose type allows more than one worker, up to its maximum. W was not available because it is camera pan up.

Also in scope, found while outlining

  • task_released gains the pawn id. Pawns currently responds to it by clearing every pawn on that task, which is correct only while a task has one assignee. With several, releasing one pawn would clear them all.
  • Pawns.remove() releases the removed pawn's slot. Today it does not, so a removed pawn leaves its task claimed indefinitely. This is a pre-existing bug, and the AC "a pawn released or removed mid-build frees its slot" covers it.
  • BuildingType.build_days' docstring becomes true ("3.3 days with three pawns"), once it says the three have to be let in. This PR therefore also closes #111, whose remaining AC was correcting that docstring.

Acceptance criteria (revised)

  • Task.max_workers() -> int defaults to 1. ConstructionTask and DeconstructionTask return 3.
  • Task.open_slots starts at 1. TaskManager.set_open_slots() clamps it to 1..max_workers() and releases the newest pawns when the headcount exceeds the new value.
  • Task.assignee: int becomes assignees: Array[int], in claim order. claim() appends, and release(id, pawn_id) removes one pawn without disturbing the others.
  • claim() never displaces anyone. A claim on a full task, or by a pawn already assigned, is refused with an error.
  • next_for() offers a task with a free slot to a pawn not already on it. It skips a full task. A task with pawns on it is never relabelled BLOCKED underneath them.
  • Every WORKING assignee contributes its effort each tick, so work_remaining falls by headcount × EFFORT_PER_TICK.
  • A pawn that is released, removed, or dropped by a lowered cap frees its slot without cancelling the task. The task returns to PENDING only when its last assignee leaves.
  • task_released(task, pawn_id) clears only that pawn.
  • Debug command O opens slots. The debug task list shows workers n/m (checked by eye).
  • BuildingType.build_days docstring updated. Closes #111.
  • Specs: test_work_slots.gd (new), plus additions to test_pawn_work_loop.gd, test_construction_task.gd and test_deconstruction_task.gd, and the existing assignee tests migrated.

2.1 Files

File New / changed Change
entities/tasks/task.gd changed assignee → assignees; add open_slots, max_workers(), has_free_slot()
entities/tasks/construction_task.gd changed override max_workers() → 3
entities/tasks/deconstruction_task.gd changed override max_workers() → 3
entities/tasks/task_manager.gd changed task_released gains pawn_id; claim, release, cancel, next_for for many assignees; new set_open_slots()
entities/pawns/pawns.gd changed new _on_task_released() handler; _start_task() passes the pawn to release(); remove() releases the slot
ui/debug_layer.gd changed O command _open_worker_slots(); _task_text() shows workers
entities/buildings/BuildingType.gd changed build_days docstring only
test/support/stub_task.gd changed (Claude) var workers: int = 1 and a max_workers() override, so tests can script a multi-slot task
test/unit/test_work_slots.gd new (Claude) the model: slots, claim, release, next_for, set_open_slots
test/unit/test_pawn_work_loop.gd, test_task.gd, test_task_manager.gd, test_task_tree.gd, test_pawn_task_arrival.gd, test_construction_task.gd, test_deconstruction_task.gd changed (Claude) migrate assignee / release(id); add the multi-pawn loop tests

No scenes change.

2.2 API: full signatures

# entities/tasks/task.gd - @abstract class_name Task extends Entity (Entity extends RefCounted)
var assignees: Array[int] = []  # pawn ids, in claim order: [0] is the oldest, back() the newest. Replaces `assignee`.
var open_slots: int = 1         # how many pawns may hold this task at once. Written only by TaskManager.set_open_slots().
func max_workers() -> int       # virtual, NOT @abstract: the base returns 1
func has_free_slot() -> bool    # assignees.size() < open_slots

# entities/tasks/construction_task.gd - class_name ConstructionTask extends Task
func max_workers() -> int       # override: returns 3

# entities/tasks/deconstruction_task.gd - class_name DeconstructionTask extends Task
func max_workers() -> int       # override: returns 3

# entities/tasks/task_manager.gd - class_name TaskManager extends RefCounted
signal task_released(task: Task, pawn_id: int)          # CHANGED: was task_released(task: Task)
func claim(id: int, pawn_id: int) -> Error              # same signature, new rules (below)
func release(id: int, pawn_id: int) -> Error            # CHANGED: gains pawn_id
func set_open_slots(id: int, count: int) -> int         # NEW: returns the value applied after clamping; -1 for an unknown id
func next_for(pawn_id: int) -> Task                     # same signature; null when nothing is offerable (unchanged)
func cancel(id: int) -> void                            # same signature; clears assignees

# entities/pawns/pawns.gd - class_name Pawns extends Node3D
func _on_task_released(task: Task, pawn_id: int) -> void  # NEW handler for task_released
func remove(id: int) -> void                              # same signature; now releases the pawn's slot first

# ui/debug_layer.gd - class_name DebugLayer extends CanvasLayer
func _open_worker_slots() -> void                         # NEW: the O command

Why max_workers() is a method and not a const. It is a property of the type, which in Kotlin would be an open val overridden per subclass. GDScript has no overridable constant. A subclass that redeclares a parent's const is a parse error (The member "MAX_WORKERS" already exists in parent class; checked on 4.7.2). The idiom is a method the subclass overrides, which is exactly how _can_start() already works, and task.max_workers() on a Task-typed variable dispatches to the subclass.

The return codes claim() and release() use (Godot's Error enum, as today):

Call Case Returns push_error?
claim unknown id FAILED yes (unchanged)
claim pawn_id already in assignees ERR_ALREADY_IN_USE yes
claim no free slot ERR_CANT_ACQUIRE_RESOURCE yes (unchanged code)
claim PENDING OK: → CLAIMED, append no
claim CLAIMED or IN_PROGRESS, with a free slot OK: append, status unchanged no
claim BLOCKED / COMPLETE / CANCELLED ERR_BUG, via transition_to()'s own push_error yes (unchanged)
release unknown id FAILED yes (unchanged)
release pawn_id not in assignees ERR_INVALID_PARAMETER yes
release an assignee, others remain OK: erase, status unchanged, emit no
release the last assignee OK: erase, → PENDING, emit no

Error text the tests match on (as a substring, via assert_push_error): a refused claim or release names the pawn as Pawn <id>. The existing "full" message already does this. An unknown id in set_open_slots names the id, as the other unknown-id errors do.

The status is not rewound when a second pawn joins, and it has to stay that way: transition_to() has no IN_PROGRESS → CLAIMED edge, and a task being worked is still being worked when somebody else starts walking over.

2.3 Each method's job, in one line

New or changed:

  • Task.max_workers(): the most pawns this type of work can use. The base returns 1.
  • Task.has_free_slot(): true while fewer pawns hold the task than it has open slots.
  • ConstructionTask.max_workers() / DeconstructionTask.max_workers(): return 3.
  • TaskManager.claim(id, pawn_id): add the pawn to assignees if there is a free slot and it is not already there. PENDING becomes CLAIMED; otherwise the status is left alone. See the table in 2.2.
  • TaskManager.release(id, pawn_id): take one pawn off the task and emit task_released(task, pawn_id). When it was the last one, the task goes back to PENDING.
  • TaskManager.set_open_slots(id, count): set open_slots to clampi(count, 1, task.max_workers()). Then, while the headcount exceeds it, release(id, assignees.back()), which releases the newest first. Return the applied value.
  • TaskManager.cancel(id): as today, but assignees.clear() where it set assignee = -1.
  • TaskManager.next_for(pawn_id): build the candidate list with these rules, in order, then sort as today.
    1. Skip CANCELLED, as today.
    2. Skip a task that already lists pawn_id in assignees.
    3. Skip a task with no free slot. This replaces the current "skip CLAIMED / skip IN_PROGRESS" lines, and gives the same result while open_slots is 1.
    4. For a task that has assignees, call evaluate_block(context). If it is not NONE, skip the task without changing its status or emitting anything. Pawns are already on it, so relabelling it BLOCKED would strand them, and a newcomer simply is not offered it.
    5. For an unassigned task, the existing BLOCKED / unblock logic is unchanged.
  • Pawns._on_task_released(task, pawn_id): if that pawn exists and its current_task_id == task.id, call clear_task(pawn_id). Only that pawn.
  • Pawns.remove(id): before erasing, if tasks != null and the pawn holds a task that tasks.get_task() still finds, call tasks.release(task_id, id). The rest is unchanged.
  • Pawns._start_task(pawn, task): the one change is tasks.release(task.id) → tasks.release(task.id, pawn.id).
  • DebugLayer._open_worker_slots(): if tasks is null, return. Otherwise, for every task in tasks.all() whose max_workers() > 1, call tasks.set_open_slots(task.id, task.open_slots + 1). It is generic and does no type checks, so #73's HaulTask (max 1) is left alone without being named.
  • DebugLayer._task_text(task): append a line workers %d/%d with assignees.size() and open_slots.

Existing code this leans on (unchanged):

  • Pawns.on_tick(run_tick): for each pawn, an IDLING pawn asks next_for() and calls _start_task(). A WORKING pawn calls tasks.start(id), then task.work(ctx, EFFORT_PER_TICK), then tasks.complete(id) once work_remaining == 0.0. Every WORKING assignee does this separately, which is what makes effort add up; the loop needs no change. A pawn still walking (MOVING) contributes nothing.
  • TaskManager.start(id): CLAIMED → IN_PROGRESS. When the task is already IN_PROGRESS this is a valid no-op, because transition_to() returns true for the same status. So the second pawn to start is harmless.
  • TaskManager.complete(id): → COMPLETE, then on_complete, archive, emit task_completed, and the parent re-check. It does not touch assignees; the archived task keeps a record of who finished it.
  • Pawns._release_pawn_from_task(task): clears every pawn whose current_task_id is the task. It stays connected to task_completed and task_cancelled, where that is right, and is disconnected from task_released.
  • Pawns.clear_task(pawn_id): sets current_task_id = -1, and WORKING → IDLING. A MOVING pawn keeps walking, as #75 decided; it arrives, finds no task and idles.
  • Pawns._on_view_arrived(id): a pawn standing on task.target_cell becomes WORKING. Every assignee targets the same cell, the task's target_cell (the doorstep), so several pawns stand on one tile. That looks odd, but nothing breaks; see "Out of scope".
  • Task.transition_to(new): same status → returns true, a no-op. There is no IN_PROGRESS → CLAIMED edge.

2.4 Signals

Signal Declared on Custom / built-in Emitted by (method) Listened to by (method) Connected where
task_released(task: Task, pawn_id: int) changed TaskManager custom TaskManager.release() (and so, indirectly, set_open_slots()) Pawns._on_task_released() Pawns.tasks setter (disconnect the old manager, connect the new)
task_completed(task: Task) TaskManager custom TaskManager.complete() Pawns._release_pawn_from_task() Pawns.tasks setter (unchanged)
task_cancelled(task: Task) TaskManager custom TaskManager.cancel() Pawns._release_pawn_from_task() Pawns.tasks setter (unchanged)
task_blocked / task_unblocked TaskManager custom next_for() and complete(), only for unassigned tasks (rule 4) Buildings, ToastNotifier unchanged
tick(run_tick: int) GameClock custom GameClock Pawns.on_tick() game.gd (unchanged)

Nothing else listens to task_released today (checked with grep), so changing its arity breaks only the one connection in the Pawns.tasks setter. That connection must change in both places, the disconnect and the connect. A handler with the wrong number of parameters errors when the signal is emitted, not when it is connected.

2.5 Wiring pattern

TaskManager.release(id, pawn_id)                     # or set_open_slots(), which calls release()
    erase pawn_id; if none left: -> PENDING
    task_released.emit(task, pawn_id)
        -> Pawns._on_task_released(task, pawn_id)   # connected in the Pawns.tasks setter
           if that pawn holds this task: clear_task(pawn_id)

Pawns.remove(id)
    tasks.release(current_task_id, id)              # BEFORE _pawns.erase(id), so the handler still finds the pawn
    ... erase, despawn view, GameState.pawn_removed.emit(pawn)   # unchanged

DebugLayer._unhandled_input(KEY_O) -> _open_worker_slots()
    tasks.set_open_slots(task.id, task.open_slots + 1)   # a method call: the value comes back as the return
  • There are no builders here, so nothing to keep pure. set_open_slots() is a command. Its return value is for the caller, the future #136 panel, to show what was actually applied.
  • The manager emits; Pawns reacts. The manager never reaches into Pawns, and Pawns never edits assignees directly. It goes through claim() / release().
  • Signals ignore return values. _on_task_released() returns void. If the manager needed an answer from Pawns, that would be a method call, not a signal.

2.6 Scene structure

None: no .tscn changes. The O command is added to DebugLayer._debug_commands in _ready(), next to R / T / X, as KEY_O: {"desc": "Open worker slot", "action": _open_worker_slots}. The tests reach no nodes by name.

2.7 Tests

  • test_work_slots.gd (new, manager level, using StubTask with workers set):
    • Defaults: assignees empty, open_slots 1, max_workers() 1, has_free_slot() true.
    • set_open_slots: raises; clamps to max_workers() and to 1; returns the applied value; an unknown id gives push_error and -1.
    • Lowering: below the headcount it releases the newest first, emitting task_released(task, pawn) for each; at or above the headcount it releases nobody.
    • claim: the first claim moves to CLAIMED; a second appends with the status unchanged, from both CLAIMED and IN_PROGRESS; a claim on a full task is refused and nobody is displaced; the same pawn twice is refused.
    • release: one of two leaves the other and keeps the status; the last goes to PENDING; a non-assignee is refused.
    • next_for: offers a free slot to a second pawn; never offers a task to its own assignee; a third pawn is refused while two slots are full; a task with assignees that becomes unstartable is skipped, stays IN_PROGRESS, and emits no task_blocked.
    • cancel clears every assignee.
  • test_pawn_work_loop.gd (additions): two pawns on one site both WORKING, neither displacing the other; effort accumulating from two pawns in one tick; one assignee released leaves the other working; completing with two pawns clears both and emits task_completed once; remove() of an assignee frees its slot without cancelling; remove() of the sole assignee returns the task to PENDING; task_released for one pawn does not clear another.
  • test_construction_task.gd / test_deconstruction_task.gd: max_workers() is 3.
  • Migrations: assignee == -1 → assignees.is_empty(), assignee == X → assignees == [X], and release(id) → release(id, pawn).

Covered by tests: every AC except the debug command and the debug task line, which are checked by eye (headed game: place a building, press O twice, watch three pawns build it, and watch workers 3/3 in the debug panel).

2.8 Checked

  • Every signature is above, with types and return codes.
  • task_released has an emitter, a listener and a connection site; so do the unchanged signals.
  • There are no builders, and nothing relies on a signal's return value.
  • There are no nodes; the tests reach none.
  • The existing methods the change leans on (on_tick, start, complete, _release_pawn_from_task, clear_task, _on_view_arrived, transition_to) are in 2.3.
  • Design constraints: none of the CLAUDE.md schema constraints apply. The one this serves is "labour priority as a choice": extra slots are opened by the player, never automatically.

Out of scope / follow-ups

  • The player-facing slot control: #136.
  • Pawns sharing one doorstep tile. Every assignee walks to target_cell, so two or three stand in the same spot. Spreading them over the free cells around the footprint is a visual follow-up, to be filed if it bothers in play.
  • Skill-weighted effort (#74) and specialisation (#69). They change what on_tick passes to work(), not who calls it.
  • Pulling max_workers() up into #126's BuildingTask once that lands, rather than overriding it in both subclasses.
## Spec (drafted with Claude) ### Decisions made on 2026-09-28 - **UI split out.** This issue delivers the model, the work loop and a debug command. The player-facing control on a selected site is #136, which depends on #78. The "Selecting a construction site offers the extra slots" criterion moves there. - **Lowering slots below the headcount releases the newest pawns first.** Everyone else stays. Pawns are released in reverse claim order until the headcount fits. - **`DeconstructionTask.max_workers()` is 3**, the same as construction. Hauling (#73) will be 1, which is the default. - **Debug command O** ("Open worker slot") adds one slot to every task whose type allows more than one worker, up to its maximum. W was not available because it is camera pan up. ### Also in scope, found while outlining - **`task_released` gains the pawn id.** `Pawns` currently responds to it by clearing **every** pawn on that task, which is correct only while a task has one assignee. With several, releasing one pawn would clear them all. - **`Pawns.remove()` releases the removed pawn's slot.** Today it does not, so a removed pawn leaves its task claimed indefinitely. This is a pre-existing bug, and the AC "a pawn released or removed mid-build frees its slot" covers it. - **`BuildingType.build_days`' docstring becomes true** ("3.3 days with three pawns"), once it says the three have to be let in. This PR therefore also **closes #111**, whose remaining AC was correcting that docstring. ### Acceptance criteria (revised) - [ ] `Task.max_workers() -> int` defaults to 1. `ConstructionTask` and `DeconstructionTask` return 3. - [ ] `Task.open_slots` starts at 1. `TaskManager.set_open_slots()` clamps it to `1..max_workers()` and releases the newest pawns when the headcount exceeds the new value. - [ ] `Task.assignee: int` becomes `assignees: Array[int]`, in claim order. `claim()` appends, and `release(id, pawn_id)` removes one pawn without disturbing the others. - [ ] `claim()` never displaces anyone. A claim on a full task, or by a pawn already assigned, is refused with an error. - [ ] `next_for()` offers a task with a free slot to a pawn not already on it. It skips a full task. A task with pawns on it is never relabelled BLOCKED underneath them. - [ ] Every WORKING assignee contributes its effort each tick, so `work_remaining` falls by headcount × `EFFORT_PER_TICK`. - [ ] A pawn that is released, removed, or dropped by a lowered cap frees its slot without cancelling the task. The task returns to PENDING only when its last assignee leaves. - [ ] `task_released(task, pawn_id)` clears only that pawn. - [ ] Debug command O opens slots. The debug task list shows `workers n/m` (checked by eye). - [ ] `BuildingType.build_days` docstring updated. Closes #111. - [ ] Specs: `test_work_slots.gd` (new), plus additions to `test_pawn_work_loop.gd`, `test_construction_task.gd` and `test_deconstruction_task.gd`, and the existing `assignee` tests migrated. --- ### 2.1 Files | File | New / changed | Change | |---|---|---| | `entities/tasks/task.gd` | changed | `assignee` → `assignees`; add `open_slots`, `max_workers()`, `has_free_slot()` | | `entities/tasks/construction_task.gd` | changed | override `max_workers()` → 3 | | `entities/tasks/deconstruction_task.gd` | changed | override `max_workers()` → 3 | | `entities/tasks/task_manager.gd` | changed | `task_released` gains `pawn_id`; `claim`, `release`, `cancel`, `next_for` for many assignees; new `set_open_slots()` | | `entities/pawns/pawns.gd` | changed | new `_on_task_released()` handler; `_start_task()` passes the pawn to `release()`; `remove()` releases the slot | | `ui/debug_layer.gd` | changed | O command `_open_worker_slots()`; `_task_text()` shows workers | | `entities/buildings/BuildingType.gd` | changed | `build_days` docstring only | | `test/support/stub_task.gd` | changed (Claude) | `var workers: int = 1` and a `max_workers()` override, so tests can script a multi-slot task | | `test/unit/test_work_slots.gd` | new (Claude) | the model: slots, claim, release, `next_for`, `set_open_slots` | | `test/unit/test_pawn_work_loop.gd`, `test_task.gd`, `test_task_manager.gd`, `test_task_tree.gd`, `test_pawn_task_arrival.gd`, `test_construction_task.gd`, `test_deconstruction_task.gd` | changed (Claude) | migrate `assignee` / `release(id)`; add the multi-pawn loop tests | No scenes change. ### 2.2 API: full signatures ```gdscript # entities/tasks/task.gd - @abstract class_name Task extends Entity (Entity extends RefCounted) var assignees: Array[int] = [] # pawn ids, in claim order: [0] is the oldest, back() the newest. Replaces `assignee`. var open_slots: int = 1 # how many pawns may hold this task at once. Written only by TaskManager.set_open_slots(). func max_workers() -> int # virtual, NOT @abstract: the base returns 1 func has_free_slot() -> bool # assignees.size() < open_slots # entities/tasks/construction_task.gd - class_name ConstructionTask extends Task func max_workers() -> int # override: returns 3 # entities/tasks/deconstruction_task.gd - class_name DeconstructionTask extends Task func max_workers() -> int # override: returns 3 # entities/tasks/task_manager.gd - class_name TaskManager extends RefCounted signal task_released(task: Task, pawn_id: int) # CHANGED: was task_released(task: Task) func claim(id: int, pawn_id: int) -> Error # same signature, new rules (below) func release(id: int, pawn_id: int) -> Error # CHANGED: gains pawn_id func set_open_slots(id: int, count: int) -> int # NEW: returns the value applied after clamping; -1 for an unknown id func next_for(pawn_id: int) -> Task # same signature; null when nothing is offerable (unchanged) func cancel(id: int) -> void # same signature; clears assignees # entities/pawns/pawns.gd - class_name Pawns extends Node3D func _on_task_released(task: Task, pawn_id: int) -> void # NEW handler for task_released func remove(id: int) -> void # same signature; now releases the pawn's slot first # ui/debug_layer.gd - class_name DebugLayer extends CanvasLayer func _open_worker_slots() -> void # NEW: the O command ``` **Why `max_workers()` is a method and not a `const`.** It is a property of the *type*, which in Kotlin would be an `open val` overridden per subclass. GDScript has no overridable constant. A subclass that redeclares a parent's `const` is a **parse error** (`The member "MAX_WORKERS" already exists in parent class`; checked on 4.7.2). The idiom is a method the subclass overrides, which is exactly how `_can_start()` already works, and `task.max_workers()` on a `Task`-typed variable dispatches to the subclass. **The return codes `claim()` and `release()` use** (Godot's `Error` enum, as today): | Call | Case | Returns | push_error? | |---|---|---|---| | `claim` | unknown id | `FAILED` | yes (unchanged) | | `claim` | `pawn_id` already in `assignees` | `ERR_ALREADY_IN_USE` | yes | | `claim` | no free slot | `ERR_CANT_ACQUIRE_RESOURCE` | yes (unchanged code) | | `claim` | PENDING | `OK`: → CLAIMED, append | no | | `claim` | CLAIMED or IN_PROGRESS, with a free slot | `OK`: append, **status unchanged** | no | | `claim` | BLOCKED / COMPLETE / CANCELLED | `ERR_BUG`, via `transition_to()`'s own push_error | yes (unchanged) | | `release` | unknown id | `FAILED` | yes (unchanged) | | `release` | `pawn_id` not in `assignees` | `ERR_INVALID_PARAMETER` | yes | | `release` | an assignee, others remain | `OK`: erase, status unchanged, emit | no | | `release` | the last assignee | `OK`: erase, → PENDING, emit | no | **Error text the tests match on** (as a substring, via `assert_push_error`): a refused `claim` or `release` names the pawn as `Pawn <id>`. The existing "full" message already does this. An unknown id in `set_open_slots` names the id, as the other unknown-id errors do. The status is not rewound when a second pawn joins, and it has to stay that way: `transition_to()` has no IN_PROGRESS → CLAIMED edge, and a task being worked is still being worked when somebody else starts walking over. ### 2.3 Each method's job, in one line **New or changed:** - `Task.max_workers()`: the most pawns this *type* of work can use. The base returns 1. - `Task.has_free_slot()`: true while fewer pawns hold the task than it has open slots. - `ConstructionTask.max_workers()` / `DeconstructionTask.max_workers()`: return 3. - `TaskManager.claim(id, pawn_id)`: add the pawn to `assignees` if there is a free slot and it is not already there. PENDING becomes CLAIMED; otherwise the status is left alone. See the table in 2.2. - `TaskManager.release(id, pawn_id)`: take one pawn off the task and emit `task_released(task, pawn_id)`. When it was the last one, the task goes back to PENDING. - `TaskManager.set_open_slots(id, count)`: set `open_slots` to `clampi(count, 1, task.max_workers())`. Then, while the headcount exceeds it, `release(id, assignees.back())`, which releases the newest first. Return the applied value. - `TaskManager.cancel(id)`: as today, but `assignees.clear()` where it set `assignee = -1`. - `TaskManager.next_for(pawn_id)`: build the candidate list with these rules, in order, then sort as today. 1. Skip CANCELLED, as today. 2. Skip a task that already lists `pawn_id` in `assignees`. 3. Skip a task with no free slot. This replaces the current "skip CLAIMED / skip IN_PROGRESS" lines, and gives the same result while `open_slots` is 1. 4. **For a task that has assignees**, call `evaluate_block(context)`. If it is not `NONE`, skip the task **without** changing its status or emitting anything. Pawns are already on it, so relabelling it BLOCKED would strand them, and a newcomer simply is not offered it. 5. For an unassigned task, the existing BLOCKED / unblock logic is unchanged. - `Pawns._on_task_released(task, pawn_id)`: if that pawn exists and its `current_task_id == task.id`, call `clear_task(pawn_id)`. Only that pawn. - `Pawns.remove(id)`: before erasing, if `tasks != null` and the pawn holds a task that `tasks.get_task()` still finds, call `tasks.release(task_id, id)`. The rest is unchanged. - `Pawns._start_task(pawn, task)`: the one change is `tasks.release(task.id)` → `tasks.release(task.id, pawn.id)`. - `DebugLayer._open_worker_slots()`: if `tasks` is null, return. Otherwise, for every task in `tasks.all()` whose `max_workers() > 1`, call `tasks.set_open_slots(task.id, task.open_slots + 1)`. It is generic and does no type checks, so #73's HaulTask (max 1) is left alone without being named. - `DebugLayer._task_text(task)`: append a line `workers %d/%d` with `assignees.size()` and `open_slots`. **Existing code this leans on (unchanged):** - `Pawns.on_tick(run_tick)`: for each pawn, an IDLING pawn asks `next_for()` and calls `_start_task()`. A WORKING pawn calls `tasks.start(id)`, then `task.work(ctx, EFFORT_PER_TICK)`, then `tasks.complete(id)` once `work_remaining == 0.0`. **Every WORKING assignee does this separately, which is what makes effort add up; the loop needs no change.** A pawn still walking (MOVING) contributes nothing. - `TaskManager.start(id)`: CLAIMED → IN_PROGRESS. When the task is already IN_PROGRESS this is a valid no-op, because `transition_to()` returns true for the same status. So the second pawn to start is harmless. - `TaskManager.complete(id)`: → COMPLETE, then `on_complete`, archive, emit `task_completed`, and the parent re-check. It does **not** touch `assignees`; the archived task keeps a record of who finished it. - `Pawns._release_pawn_from_task(task)`: clears **every** pawn whose `current_task_id` is the task. It stays connected to `task_completed` and `task_cancelled`, where that is right, and is disconnected from `task_released`. - `Pawns.clear_task(pawn_id)`: sets `current_task_id = -1`, and WORKING → IDLING. A MOVING pawn keeps walking, as #75 decided; it arrives, finds no task and idles. - `Pawns._on_view_arrived(id)`: a pawn standing on `task.target_cell` becomes WORKING. Every assignee targets the same cell, the task's `target_cell` (the doorstep), so several pawns stand on one tile. That looks odd, but nothing breaks; see "Out of scope". - `Task.transition_to(new)`: same status → returns true, a no-op. There is no IN_PROGRESS → CLAIMED edge. ### 2.4 Signals | Signal | Declared on | Custom / built-in | Emitted by (method) | Listened to by (method) | Connected where | |---|---|---|---|---|---| | `task_released(task: Task, pawn_id: int)` **changed** | `TaskManager` | custom | `TaskManager.release()` (and so, indirectly, `set_open_slots()`) | `Pawns._on_task_released()` | `Pawns.tasks` setter (disconnect the old manager, connect the new) | | `task_completed(task: Task)` | `TaskManager` | custom | `TaskManager.complete()` | `Pawns._release_pawn_from_task()` | `Pawns.tasks` setter (unchanged) | | `task_cancelled(task: Task)` | `TaskManager` | custom | `TaskManager.cancel()` | `Pawns._release_pawn_from_task()` | `Pawns.tasks` setter (unchanged) | | `task_blocked` / `task_unblocked` | `TaskManager` | custom | `next_for()` and `complete()`, **only for unassigned tasks** (rule 4) | `Buildings`, `ToastNotifier` | unchanged | | `tick(run_tick: int)` | `GameClock` | custom | `GameClock` | `Pawns.on_tick()` | `game.gd` (unchanged) | Nothing else listens to `task_released` today (checked with grep), so changing its arity breaks only the one connection in the `Pawns.tasks` setter. **That connection must change in both places, the `disconnect` and the `connect`.** A handler with the wrong number of parameters errors when the signal is emitted, not when it is connected. ### 2.5 Wiring pattern ``` TaskManager.release(id, pawn_id) # or set_open_slots(), which calls release() erase pawn_id; if none left: -> PENDING task_released.emit(task, pawn_id) -> Pawns._on_task_released(task, pawn_id) # connected in the Pawns.tasks setter if that pawn holds this task: clear_task(pawn_id) Pawns.remove(id) tasks.release(current_task_id, id) # BEFORE _pawns.erase(id), so the handler still finds the pawn ... erase, despawn view, GameState.pawn_removed.emit(pawn) # unchanged DebugLayer._unhandled_input(KEY_O) -> _open_worker_slots() tasks.set_open_slots(task.id, task.open_slots + 1) # a method call: the value comes back as the return ``` - **There are no builders here, so nothing to keep pure.** `set_open_slots()` is a command. Its return value is for the caller, the future #136 panel, to show what was actually applied. - **The manager emits; `Pawns` reacts.** The manager never reaches into `Pawns`, and `Pawns` never edits `assignees` directly. It goes through `claim()` / `release()`. - **Signals ignore return values.** `_on_task_released()` returns `void`. If the manager needed an answer from `Pawns`, that would be a method call, not a signal. ### 2.6 Scene structure None: no `.tscn` changes. The O command is added to `DebugLayer._debug_commands` in `_ready()`, next to R / T / X, as `KEY_O: {"desc": "Open worker slot", "action": _open_worker_slots}`. The tests reach no nodes by name. ### 2.7 Tests - `test_work_slots.gd` (new, manager level, using `StubTask` with `workers` set): - Defaults: `assignees` empty, `open_slots` 1, `max_workers()` 1, `has_free_slot()` true. - `set_open_slots`: raises; clamps to `max_workers()` and to 1; returns the applied value; an unknown id gives push_error and -1. - Lowering: below the headcount it releases the newest first, emitting `task_released(task, pawn)` for each; at or above the headcount it releases nobody. - `claim`: the first claim moves to CLAIMED; a second appends with the status unchanged, from both CLAIMED and IN_PROGRESS; a claim on a full task is refused and nobody is displaced; the same pawn twice is refused. - `release`: one of two leaves the other and keeps the status; the last goes to PENDING; a non-assignee is refused. - `next_for`: offers a free slot to a second pawn; never offers a task to its own assignee; a third pawn is refused while two slots are full; a task with assignees that becomes unstartable is skipped, stays IN_PROGRESS, and emits no `task_blocked`. - `cancel` clears every assignee. - `test_pawn_work_loop.gd` (additions): two pawns on one site both WORKING, neither displacing the other; effort accumulating from two pawns in one tick; one assignee released leaves the other working; completing with two pawns clears both and emits `task_completed` once; `remove()` of an assignee frees its slot without cancelling; `remove()` of the sole assignee returns the task to PENDING; `task_released` for one pawn does not clear another. - `test_construction_task.gd` / `test_deconstruction_task.gd`: `max_workers()` is 3. - Migrations: `assignee == -1` → `assignees.is_empty()`, `assignee == X` → `assignees == [X]`, and `release(id)` → `release(id, pawn)`. Covered by tests: every AC except the debug command and the debug task line, which are **checked by eye** (headed game: place a building, press O twice, watch three pawns build it, and watch `workers 3/3` in the debug panel). ### 2.8 Checked - Every signature is above, with types and return codes. - `task_released` has an emitter, a listener and a connection site; so do the unchanged signals. - There are no builders, and nothing relies on a signal's return value. - There are no nodes; the tests reach none. - The existing methods the change leans on (`on_tick`, `start`, `complete`, `_release_pawn_from_task`, `clear_task`, `_on_view_arrived`, `transition_to`) are in 2.3. - Design constraints: none of the CLAUDE.md schema constraints apply. The one this serves is "labour priority as a choice": extra slots are opened by the player, never automatically. ### Out of scope / follow-ups - The player-facing slot control: #136. - Pawns sharing one doorstep tile. Every assignee walks to `target_cell`, so two or three stand in the same spot. Spreading them over the free cells around the footprint is a visual follow-up, to be filed if it bothers in play. - Skill-weighted effort (#74) and specialisation (#69). They change what `on_tick` passes to `work()`, not who calls it. - Pulling `max_workers()` up into #126's `BuildingTask` once that lands, rather than overriding it in both subclasses.
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 Depends on
Reference
liamjd/UntitledColonyBuilder#117
No description provided.