Multi-pawn work slots, opted into per site #117
Labels
No labels
assets
bug
duplicate
enhancement
help wanted
invalid
question
testing
ui
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
Depends on
#136 Worker slot control on a selected construction site
liamjd/UntitledColonyBuilder
#75 Pawn work loop: claim, navigate, work, complete
liamjd/UntitledColonyBuilder
Reference
liamjd/UntitledColonyBuilder#117
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of milestone #5. Deferred out of #111, which kept
Task.assigneesingle-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:
This is what makes
BuildingType.build_dayspawn-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_workerson the task type, defaulting to 1;ConstructionTasksets 3open_slots(or equivalent) on the task instance, starting at 1 and capped atmax_workersTask.assignee: intbecomesassignees: Array[int];claim()appends,release(id, pawn_id)removes one pawn without disturbing the othersnext_for()skips a task whose assignees have filled its open slots, rather than displacing a pawn already on itwork_remainingfalls proportionally to the headcountSpec (drafted with Claude)
Decisions made on 2026-09-28
DeconstructionTask.max_workers()is 3, the same as construction. Hauling (#73) will be 1, which is the default.Also in scope, found while outlining
task_releasedgains the pawn id.Pawnscurrently 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() -> intdefaults to 1.ConstructionTaskandDeconstructionTaskreturn 3.Task.open_slotsstarts at 1.TaskManager.set_open_slots()clamps it to1..max_workers()and releases the newest pawns when the headcount exceeds the new value.Task.assignee: intbecomesassignees: Array[int], in claim order.claim()appends, andrelease(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.work_remainingfalls by headcount ×EFFORT_PER_TICK.task_released(task, pawn_id)clears only that pawn.workers n/m(checked by eye).BuildingType.build_daysdocstring updated. Closes #111.test_work_slots.gd(new), plus additions totest_pawn_work_loop.gd,test_construction_task.gdandtest_deconstruction_task.gd, and the existingassigneetests migrated.2.1 Files
entities/tasks/task.gdassignee→assignees; addopen_slots,max_workers(),has_free_slot()entities/tasks/construction_task.gdmax_workers()→ 3entities/tasks/deconstruction_task.gdmax_workers()→ 3entities/tasks/task_manager.gdtask_releasedgainspawn_id;claim,release,cancel,next_forfor many assignees; newset_open_slots()entities/pawns/pawns.gd_on_task_released()handler;_start_task()passes the pawn torelease();remove()releases the slotui/debug_layer.gd_open_worker_slots();_task_text()shows workersentities/buildings/BuildingType.gdbuild_daysdocstring onlytest/support/stub_task.gdvar workers: int = 1and amax_workers()override, so tests can script a multi-slot tasktest/unit/test_work_slots.gdnext_for,set_open_slotstest/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.gdassignee/release(id); add the multi-pawn loop testsNo scenes change.
2.2 API: full signatures
Why
max_workers()is a method and not aconst. It is a property of the type, which in Kotlin would be anopen valoverridden per subclass. GDScript has no overridable constant. A subclass that redeclares a parent'sconstis 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, andtask.max_workers()on aTask-typed variable dispatches to the subclass.The return codes
claim()andrelease()use (Godot'sErrorenum, as today):claimFAILEDclaimpawn_idalready inassigneesERR_ALREADY_IN_USEclaimERR_CANT_ACQUIRE_RESOURCEclaimOK: → CLAIMED, appendclaimOK: append, status unchangedclaimERR_BUG, viatransition_to()'s own push_errorreleaseFAILEDreleasepawn_idnot inassigneesERR_INVALID_PARAMETERreleaseOK: erase, status unchanged, emitreleaseOK: erase, → PENDING, emitError text the tests match on (as a substring, via
assert_push_error): a refusedclaimorreleasenames the pawn asPawn <id>. The existing "full" message already does this. An unknown id inset_open_slotsnames 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 toassigneesif 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 emittask_released(task, pawn_id). When it was the last one, the task goes back to PENDING.TaskManager.set_open_slots(id, count): setopen_slotstoclampi(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, butassignees.clear()where it setassignee = -1.TaskManager.next_for(pawn_id): build the candidate list with these rules, in order, then sort as today.pawn_idinassignees.open_slotsis 1.evaluate_block(context). If it is notNONE, 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.Pawns._on_task_released(task, pawn_id): if that pawn exists and itscurrent_task_id == task.id, callclear_task(pawn_id). Only that pawn.Pawns.remove(id): before erasing, iftasks != nulland the pawn holds a task thattasks.get_task()still finds, calltasks.release(task_id, id). The rest is unchanged.Pawns._start_task(pawn, task): the one change istasks.release(task.id)→tasks.release(task.id, pawn.id).DebugLayer._open_worker_slots(): iftasksis null, return. Otherwise, for every task intasks.all()whosemax_workers() > 1, calltasks.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 lineworkers %d/%dwithassignees.size()andopen_slots.Existing code this leans on (unchanged):
Pawns.on_tick(run_tick): for each pawn, an IDLING pawn asksnext_for()and calls_start_task(). A WORKING pawn callstasks.start(id), thentask.work(ctx, EFFORT_PER_TICK), thentasks.complete(id)oncework_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, becausetransition_to()returns true for the same status. So the second pawn to start is harmless.TaskManager.complete(id): → COMPLETE, thenon_complete, archive, emittask_completed, and the parent re-check. It does not touchassignees; the archived task keeps a record of who finished it.Pawns._release_pawn_from_task(task): clears every pawn whosecurrent_task_idis the task. It stays connected totask_completedandtask_cancelled, where that is right, and is disconnected fromtask_released.Pawns.clear_task(pawn_id): setscurrent_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 ontask.target_cellbecomes WORKING. Every assignee targets the same cell, the task'starget_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
task_released(task: Task, pawn_id: int)changedTaskManagerTaskManager.release()(and so, indirectly,set_open_slots())Pawns._on_task_released()Pawns.taskssetter (disconnect the old manager, connect the new)task_completed(task: Task)TaskManagerTaskManager.complete()Pawns._release_pawn_from_task()Pawns.taskssetter (unchanged)task_cancelled(task: Task)TaskManagerTaskManager.cancel()Pawns._release_pawn_from_task()Pawns.taskssetter (unchanged)task_blocked/task_unblockedTaskManagernext_for()andcomplete(), only for unassigned tasks (rule 4)Buildings,ToastNotifiertick(run_tick: int)GameClockGameClockPawns.on_tick()game.gd(unchanged)Nothing else listens to
task_releasedtoday (checked with grep), so changing its arity breaks only the one connection in thePawns.taskssetter. That connection must change in both places, thedisconnectand theconnect. A handler with the wrong number of parameters errors when the signal is emitted, not when it is connected.2.5 Wiring pattern
set_open_slots()is a command. Its return value is for the caller, the future #136 panel, to show what was actually applied.Pawnsreacts. The manager never reaches intoPawns, andPawnsnever editsassigneesdirectly. It goes throughclaim()/release()._on_task_released()returnsvoid. If the manager needed an answer fromPawns, that would be a method call, not a signal.2.6 Scene structure
None: no
.tscnchanges. The O command is added toDebugLayer._debug_commandsin_ready(), next to R / T / X, asKEY_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, usingStubTaskwithworkersset):assigneesempty,open_slots1,max_workers()1,has_free_slot()true.set_open_slots: raises; clamps tomax_workers()and to 1; returns the applied value; an unknown id gives push_error and -1.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 notask_blocked.cancelclears 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 emitstask_completedonce;remove()of an assignee frees its slot without cancelling;remove()of the sole assignee returns the task to PENDING;task_releasedfor one pawn does not clear another.test_construction_task.gd/test_deconstruction_task.gd:max_workers()is 3.assignee == -1→assignees.is_empty(),assignee == X→assignees == [X], andrelease(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/3in the debug panel).2.8 Checked
task_releasedhas an emitter, a listener and a connection site; so do the unchanged signals.on_tick,start,complete,_release_pawn_from_task,clear_task,_on_view_arrived,transition_to) are in 2.3.Out of scope / follow-ups
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.on_tickpasses towork(), not who calls it.max_workers()up into #126'sBuildingTaskonce that lands, rather than overriding it in both subclasses.