Worker slot control on a selected construction site #136
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.
Depends on
#78 Select a pawn or building on the map
liamjd/UntitledColonyBuilder
#117 Multi-pawn work slots, opted into per site
liamjd/UntitledColonyBuilder
#142 Inspector panel for the selected pawn or building
liamjd/UntitledColonyBuilder
#152 Split InspectorPanel into sections so it can host controls
liamjd/UntitledColonyBuilder
Reference
liamjd/UntitledColonyBuilder#136
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. This was split out of #117 on 2026-09-28, so that #117 can deliver the model and work loop without waiting on selection.
#117 lets a task hold several pawns and adds
TaskManager.set_open_slots(id, count) -> int. For now the only thing that opens slots is a debug command. This issue is the player-facing control: select a construction or demolition site, then open or close worker slots on it, up to its task type'smax_workers().The player opens extra slots deliberately. Throwing the whole crew at one building has an opportunity cost elsewhere in the colony, and that cost is the interesting part (#117).
Settled by milestone #6 (2026-09-30)
GameState.selected, from #78 (a click on the map in NONE mode) or #141 (the building list). This issue adds no picking or mode handling of its own.assignees / open_slotswith + and -; superseded by the visible slots under Decisions (2026-10-11). The inspector already shows the workers on a building's task, so the slot control goes next to them.TaskManager.set_open_slots().Acceptance criteria
%Sectionsininspector_panel.tscn, after the details section, without changinginspector_panel.gd.Workers: <assignees> / <open slots>with - and +, and below it a two-column grid with one slot for each of the task type'smax_workers().set_open_slots()from #117), and the grid shows that slot as closed.max_workers()is 1.tools/.Depends on #117 (closed), #78 (closed), #142 (closed) and #152 (closed).
Decisions (2026-10-11)
Workers: a / bwith - and +, as in Timberborn.WorkerSlot, so #21's art can restyle the slots without touching the section.Out of scope
Spec (drafted with Claude)
This follows the Decisions (2026-10-11) in the body: visible slots in three states, display only, a
Workers: a / bheader with - and +, and a two-column grid. The tests aretest/unit/test_worker_slot_section.gdon136-worker-slot-control; they and this outline are one spec.2.1 Files
ui/inspector/sections/worker_slot.gdclass_name WorkerSlot extends PanelContainer. One slot: state, pawn id, and its look.ui/inspector/sections/worker_slot.tscnWorkerSlotplus%SlotLbl. See 2.6.ui/inspector/sections/worker_slot_section.gdclass_name WorkerSlotSection extends InspectorSection.ui/inspector/sections/worker_slot_section.tscn%SlotsGrid. See 2.6.ui/inspector/inspector_panel.tscn%WorkerSlotSectioninstanced under%Sections, after%DetailsSection.test/unit/test_inspector_sections.gdtools/screenshot_worker_slots.gdinspector_panel.gd,InspectorContext,TaskManager2.2 API: full signatures
Both classes are
Nodes, so testsautofreethem.WorkerSlot.Stateis used as a typed-array element type (Array[WorkerSlot.State]), which GDScript allows for enums.Error paths: none raise. No
push_error, and no test matches on error text.slot_states(null)[]set_state(OPEN or CLOSED, some_id)pawn_id = NO_PAWNrefresh()whenctx.task_for()is nullapplies_to()has just said yes, but it keepsrefresh()safe on its own._ctxis null,_ctx.tasksis null, or the task has goneTaskManager.set_open_slots()doespush_errorand returns-1for an unknown id. The section never reaches that path, because it looks the task up fresh and returns when the lookup is null.2.3 Each method's job, in one line
New:
WorkerSlot.set_state(new_state, new_pawn_id): storesstate, and storespawn_idonly forOCCUPIED(NO_PAWNotherwise). Then calls_apply()only ifis_node_ready(), because before thenslot_lblis still null.WorkerSlot._ready(): calls_apply(), so a state set before the slot entered the tree shows once it does.WorkerSlot._apply(): setsslot_lbl.textand the placeholder look (for examplemodulate) for the current state. OCCUPIED names the pawn ("Pawn %d"), and the three texts must differ. #21 restyles only this method and the scene.WorkerSlotSection.slot_states(task): one entry pertask.max_workers(). Indexiis OCCUPIED whilei < task.assignees.size(), OPEN whilei < task.open_slots, and CLOSED after that.WorkerSlotSection.applies_to(entity, ctx): false unlessentity is Building. Then true only ifctx.task_for(entity)is non-null and itsmax_workers() > 1. No type check on the task, so construction and demolition both qualify. Stores nothing.WorkerSlotSection.refresh(entity, ctx): see 2.5. Runs in full on every call, ticks included.WorkerSlotSection.slots():slots_grid's children asWorkerSlots, in tree order.WorkerSlotSection._ready(): connects both buttons'pressed(2.4)._on_add_pressed()/_on_remove_pressed():_change_slots(1)/_change_slots(-1)._change_slots(delta): see 2.5._ensure_slots(count): if the grid already hascountchildren, does nothing. Otherwise: for each child,remove_child()it thenqueue_free()it (a child that is only queued still shows inget_children()this frame;EntityList._remove_entity()hit the same thing); then addscountnewSLOT_SCENEinstances.Existing methods it relies on:
InspectorContext.task_for(entity): for aBuilding,buildings.task_for(id)(aConstructionTaskorDeconstructionTask, or null). For aPawn, the pawn's own task, which is whyapplies_to()checksentity is Buildingfirst. Null if the registry it needs is null.TaskManager.set_open_slots(id, count) -> int: clampscountto1..max_workers(), writestask.open_slots, then releases assignees from the back (newest claim first) until they fit. Returns the new count. Unknown id:push_errorand-1.TaskManager.claim(id, pawn_id) -> Error: appends totask.assignees, so assignees are in claim order, oldest first.Task.max_workers(): 1 on the base, 3 onConstructionTaskandDeconstructionTask.InspectorPanel.refresh()(#152): for each section, setsvisiblefromapplies_to()and callsrefresh()only if it applies. Runs onselection_changedand on everyGameClock.tick.tickconnection is made in its own_ready(), which runs beforegame.gdconnectspawns.on_tick. So within one tick the panel refreshes before pawns work. A task completed during a tick stays on screen until the next one, and that is the window the null-task guard covers.2.4 Signals
No new custom signals.
pressed()Button(%AddWorkerSlotBtn)WorkerSlotSection._on_add_pressed()WorkerSlotSection._ready(), in codepressed()Button(%RemoveWorkerSlotBtn)WorkerSlotSection._on_remove_pressed()WorkerSlotSection._ready(), in codeselection_changed(new, previous)GameStateGameState.selectedsetterInspectorPanel._on_selection_changed()InspectorPanel._ready()(unchanged)tick(run_tick)GameClockGameClockInspectorPanel._on_tick()InspectorPanel._ready()(unchanged)Connect the buttons in code, not in the scene, so the tests (which call
pressed.emit()) and the code reader see the same wiring. The section connects to nothing else: the panel holds the timing (#152).2.5 Wiring pattern
slot_states()only returns a value, so tests call it with no nodes._change_slots()callsset_open_slots(); nothing writesopen_slotsorassigneesitself (#152's rule).pressedcarries nothing back; the handler callsrefresh()itself.2.6 Scene structure
%SlotsGrid: the grid's columns lay them out, andslots()reads them back.%SlotsContainer(HBoxContainer) and the title label become%SlotsGridand%WorkersLbl. Delete them and add the new nodes rather than retyping them, so the editor gives freshunique_ids._apply(),refresh()); layout (columns, size flags, minimum size) is set in the scenes.Names the tests depend on:
%WorkerSlotSectionand%DetailsSection(in the panel),%WorkersLbl,%RemoveWorkerSlotBtn,%AddWorkerSlotBtn,%SlotsGrid(throughworkers_lbl,remove_btn,add_btn,slots_grid), and%SlotLbl(throughslot_lbl).2.7 Tests
test/unit/test_worker_slot_section.gd(new). Most tests callrefresh()directly with aFixedContext, a test-localInspectorContextwhosetask_for()returns a chosenStubTask, somax_workers()can be any value.NO_PAWN; the three states read differently; a state set before_ready()shows once in the tree; a new slot is closed.max_workers();[]for null.slot_states()with pawn ids; the slots are the grid's children and it has 2 columns; the header readsa / b; the same slot nodes are reused across refreshes; a differentmax_workers()resizes the grid; - disabled at 1, + at the maximum.pressed.emit()): + opens a slot and shows it at once; + opens every slot up to the maximum; - closes one; - below the headcount releases the newest pawn and shows its slot closed; a press after the task has gone changes nothing.test/unit/test_inspector_sections.gd: the three count-based #152 tests now count relative to the scene.inspector_panel.gdtest_the_panel_stacks_it_after_the_details; review the diffmax_workers()slot_statestests; by eye for "visibly"applies_totests,test_hidden_for_a_pawn_in_the_paneltest_a_claim_shows_on_the_next_tick,test_a_refresh_reuses_the_slot_nodestest_a_press_after_the_task_has_gone_does_nothingtools/screenshot_worker_slots.gd, modelled onscreenshot_inspector.gd. It places a building and shoots it with 1 slot open, then presses + once and waits for a pawn to claim the site, so that occupied, open and closed show side by side.debug_layer.gd2.8 Before posting, check that
"1 / 2"-style counts and the pawn id only)emit(...)call written out (none changed)TaskManager.set_open_slots().The spec was checked against a throwaway implementation that followed this outline, in a separate worktree. The full suite went green (44 scripts, 977 tests), and the implementation was then discarded.