Worker slot control on a selected construction site #136

Closed
opened 2026-09-28 06:04:27 +01:00 by liamjd · 1 comment
Owner

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's max_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)

  • Selecting a building: done through 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.
  • Where the control lives: in the inspector panel from #142, shown when the selected building has a construction or deconstruction task. Originally assignees / open_slots with + 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.
  • How it is built (2026-10-10): as an inspector section from #152, the first interactive one, rather than as rows. Its nodes persist and are updated in place on each refresh, because the rows are rebuilt every tick and a button cannot survive that. The + and - buttons change the game only through TaskManager.set_open_slots().
  • Mode: the inspector ignores clicks outside NONE mode and never leaves PLACE or DEMOLISH, so the + and - buttons follow the same rule.

Acceptance criteria

  • A worker-slot section is added to the inspector (#152) by instancing it under %Sections in inspector_panel.tscn, after the details section, without changing inspector_panel.gd.
  • With a site selected that has a construction or deconstruction task, the section shows a header, Workers: <assignees> / <open slots> with - and +, and below it a two-column grid with one slot for each of the task type's max_workers().
  • Each slot shows one of three states: occupied (names the pawn), open (no pawn has claimed it yet), or closed. The three are visibly different. Slots fill in order: occupied in claim order, then open, then closed.
  • The player can raise the slots up to the type maximum and lower them to 1. + is disabled at the maximum and - at 1. Lowering below the current headcount releases the newest pawns first (set_open_slots() from #117), and the grid shows that slot as closed.
  • The section is hidden for a pawn (even one holding a construction task), a building with no task, or a task type whose max_workers() is 1.
  • The values stay current while the panel is open (for example, a pawn claiming the task). The slot and button nodes are reused across refreshes, not rebuilt, so the per-tick refresh does not disturb a press.
  • Pressing + or - after the task has ended, but before the next tick hides the section, does nothing and does not error.
  • The look is checked by eye, headed, with a harness in tools/.
  • The #117 debug command stays; it is still useful for testing.

Depends on #117 (closed), #78 (closed), #142 (closed) and #152 (closed).

Decisions (2026-10-11)

  • Slots are visible, not just counted. This is modelled on Timberborn's workplace panel. A label alone is not enough, and neither is a checkbox, which has only two states for three.
  • Slots display only. Clicking a slot does nothing; - and + are the only controls. Clicking an occupied slot to select its pawn could come later without changing the states.
  • Keep the header row: Workers: a / b with - and +, as in Timberborn.
  • The slots are laid out in a two-column grid, so a larger crew later grows the panel downwards, not sideways.
  • The placeholder look stays inside WorkerSlot, so #21's art can restyle the slots without touching the section.

Out of scope

  • Clicking a slot (to select the pawn, or to open or close it).
  • Portraits and final art (#21).
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's `max_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) - **Selecting a building:** done through `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. - **Where the control lives:** in the inspector panel from #142, shown when the selected building has a construction or deconstruction task. Originally `assignees / open_slots` with + 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. - **How it is built (2026-10-10):** as an inspector **section** from #152, the first interactive one, rather than as rows. Its nodes persist and are updated in place on each refresh, because the rows are rebuilt every tick and a button cannot survive that. The + and - buttons change the game only through `TaskManager.set_open_slots()`. - **Mode:** the inspector ignores clicks outside NONE mode and never leaves PLACE or DEMOLISH, so the + and - buttons follow the same rule. ## Acceptance criteria - [ ] A worker-slot section is added to the inspector (#152) by instancing it under `%Sections` in `inspector_panel.tscn`, after the details section, without changing `inspector_panel.gd`. - [ ] With a site selected that has a construction or deconstruction task, the section shows a header, `Workers: <assignees> / <open slots>` with - and +, and below it a two-column grid with one slot for each of the task type's `max_workers()`. - [ ] Each slot shows one of three states: **occupied** (names the pawn), **open** (no pawn has claimed it yet), or **closed**. The three are visibly different. Slots fill in order: occupied in claim order, then open, then closed. - [ ] The player can raise the slots up to the type maximum and lower them to 1. + is disabled at the maximum and - at 1. Lowering below the current headcount releases the newest pawns first (`set_open_slots()` from #117), and the grid shows that slot as closed. - [ ] The section is hidden for a pawn (even one holding a construction task), a building with no task, or a task type whose `max_workers()` is 1. - [ ] The values stay current while the panel is open (for example, a pawn claiming the task). The slot and button nodes are reused across refreshes, not rebuilt, so the per-tick refresh does not disturb a press. - [ ] Pressing + or - after the task has ended, but before the next tick hides the section, does nothing and does not error. - [ ] The look is checked by eye, headed, with a harness in `tools/`. - [ ] The #117 debug command stays; it is still useful for testing. Depends on #117 (closed), #78 (closed), #142 (closed) and #152 (closed). ## Decisions (2026-10-11) - **Slots are visible, not just counted.** This is modelled on Timberborn's workplace panel. A label alone is not enough, and neither is a checkbox, which has only two states for three. - **Slots display only.** Clicking a slot does nothing; - and + are the only controls. Clicking an occupied slot to select its pawn could come later without changing the states. - **Keep the header row:** `Workers: a / b` with - and +, as in Timberborn. - **The slots are laid out in a two-column grid**, so a larger crew later grows the panel downwards, not sideways. - **The placeholder look stays inside `WorkerSlot`**, so #21's art can restyle the slots without touching the section. ## Out of scope - Clicking a slot (to select the pawn, or to open or close it). - Portraits and final art (#21).
Author
Owner

Spec (drafted with Claude)

This follows the Decisions (2026-10-11) in the body: visible slots in three states, display only, a Workers: a / b header with - and +, and a two-column grid. The tests are test/unit/test_worker_slot_section.gd on 136-worker-slot-control; they and this outline are one spec.

2.1 Files

File New / changed Change
ui/inspector/sections/worker_slot.gd new class_name WorkerSlot extends PanelContainer. One slot: state, pawn id, and its look.
ui/inspector/sections/worker_slot.tscn new Root WorkerSlot plus %SlotLbl. See 2.6.
ui/inspector/sections/worker_slot_section.gd new class_name WorkerSlotSection extends InspectorSection.
ui/inspector/sections/worker_slot_section.tscn new Header row plus %SlotsGrid. See 2.6.
ui/inspector/inspector_panel.tscn changed %WorkerSlotSection instanced under %Sections, after %DetailsSection.
test/unit/test_inspector_sections.gd changed Three #152 tests assumed the panel held exactly one section. They now count relative to the scene. That was a gap in #152's spec.
tools/screenshot_worker_slots.gd new Headed harness for the look, written by Claude alongside the tests. See 2.7.
inspector_panel.gd, InspectorContext, TaskManager unchanged

2.2 API: full signatures

# ui/inspector/sections/worker_slot.gd - class_name WorkerSlot extends PanelContainer
enum State {CLOSED, OPEN, OCCUPIED}
const NO_PAWN := -1
var state: State = State.CLOSED     # read by tests; written only through set_state()
var pawn_id: int = NO_PAWN          # the pawn in an OCCUPIED slot; NO_PAWN otherwise
@onready var slot_lbl: Label = %SlotLbl
func _ready() -> void                                            # calls _apply()
func set_state(new_state: State, new_pawn_id: int = NO_PAWN) -> void
func _apply() -> void                                            # the placeholder look; the only place it lives

# ui/inspector/sections/worker_slot_section.gd - class_name WorkerSlotSection extends InspectorSection
const SLOT_SCENE: PackedScene = preload("res://ui/inspector/sections/worker_slot.tscn")
var _entity: Entity = null                  # set only in refresh()
var _ctx: InspectorContext = null           # set only in refresh()
@onready var workers_lbl: Label = %WorkersLbl
@onready var remove_btn: Button = %RemoveWorkerSlotBtn
@onready var add_btn: Button = %AddWorkerSlotBtn
@onready var slots_grid: GridContainer = %SlotsGrid
static func slot_states(task: Task) -> Array[WorkerSlot.State]  # pure; [] for a null task
func applies_to(entity: Entity, ctx: InspectorContext) -> bool
func refresh(entity: Entity, ctx: InspectorContext) -> void
func slots() -> Array[WorkerSlot]                                 # the grid's children, in order
func _ready() -> void
func _on_add_pressed() -> void
func _on_remove_pressed() -> void
func _change_slots(delta: int) -> void
func _ensure_slots(count: int) -> void

Both classes are Nodes, so tests autofree them. WorkerSlot.State is 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.

Case Result
slot_states(null) []
set_state(OPEN or CLOSED, some_id) the id is ignored; pawn_id = NO_PAWN
refresh() when ctx.task_for() is null returns early. It cannot happen through the panel, because applies_to() has just said yes, but it keeps refresh() safe on its own.
A press when _ctx is null, _ctx.tasks is null, or the task has gone returns, changes nothing, no error

TaskManager.set_open_slots() does push_error and returns -1 for 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): stores state, and stores pawn_id only for OCCUPIED (NO_PAWN otherwise). Then calls _apply() only if is_node_ready(), because before then slot_lbl is still null.
  • WorkerSlot._ready(): calls _apply(), so a state set before the slot entered the tree shows once it does.
  • WorkerSlot._apply(): sets slot_lbl.text and the placeholder look (for example modulate) 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 per task.max_workers(). Index i is OCCUPIED while i < task.assignees.size(), OPEN while i < task.open_slots, and CLOSED after that.
  • WorkerSlotSection.applies_to(entity, ctx): false unless entity is Building. Then true only if ctx.task_for(entity) is non-null and its max_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 as WorkerSlots, 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 has count children, does nothing. Otherwise: for each child, remove_child() it then queue_free() it (a child that is only queued still shows in get_children() this frame; EntityList._remove_entity() hit the same thing); then adds count new SLOT_SCENE instances.

Existing methods it relies on:

  • InspectorContext.task_for(entity): for a Building, buildings.task_for(id) (a ConstructionTask or DeconstructionTask, or null). For a Pawn, the pawn's own task, which is why applies_to() checks entity is Building first. Null if the registry it needs is null.
  • TaskManager.set_open_slots(id, count) -> int: clamps count to 1..max_workers(), writes task.open_slots, then releases assignees from the back (newest claim first) until they fit. Returns the new count. Unknown id: push_error and -1.
  • TaskManager.claim(id, pawn_id) -> Error: appends to task.assignees, so assignees are in claim order, oldest first.
  • Task.max_workers(): 1 on the base, 3 on ConstructionTask and DeconstructionTask.
  • InspectorPanel.refresh() (#152): for each section, sets visible from applies_to() and calls refresh() only if it applies. Runs on selection_changed and on every GameClock.tick.
  • Tick order: the panel's tick connection is made in its own _ready(), which runs before game.gd connects pawns.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.

Signal Declared on Custom / built-in Emitted by (method) Listened to by (method) Connected where
pressed() Button (%AddWorkerSlotBtn) built-in engine, on click WorkerSlotSection._on_add_pressed() WorkerSlotSection._ready(), in code
pressed() Button (%RemoveWorkerSlotBtn) built-in engine, on click WorkerSlotSection._on_remove_pressed() WorkerSlotSection._ready(), in code
selection_changed(new, previous) GameState custom GameState.selected setter InspectorPanel._on_selection_changed() InspectorPanel._ready() (unchanged)
tick(run_tick) GameClock custom GameClock InspectorPanel._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

GameState.selection_changed / GameClock.tick -> InspectorPanel.refresh()        # #152, unchanged
    for each section: visible = applies_to(entity, context)
                      if visible: section.refresh(entity, context)

WorkerSlotSection.refresh(entity, ctx):
    1. _entity = entity; _ctx = ctx                                              # always
    2. task := ctx.task_for(entity); if task == null: return                     # conditional
    3. _ensure_slots(task.max_workers())                                         # always; creates nodes only if the count differs
    4. states := slot_states(task)                                               # pure
       for i in states.size():
           slots()[i].set_state(states[i], task.assignees[i] if i < task.assignees.size() else WorkerSlot.NO_PAWN)
    5. workers_lbl.text = "Workers: %d / %d" % [task.assignees.size(), task.open_slots]
    6. remove_btn.disabled = task.open_slots <= 1                                 # always
    7. add_btn.disabled = task.open_slots >= task.max_workers()                   # always

%AddWorkerSlotBtn.pressed    -> _on_add_pressed()    -> _change_slots(1)          # connected in _ready()
%RemoveWorkerSlotBtn.pressed -> _on_remove_pressed() -> _change_slots(-1)         # connected in _ready()

_change_slots(delta):
    1. if _ctx == null or _ctx.tasks == null: return                              # conditional
    2. task := _ctx.task_for(_entity)                                             # always fresh: never a cached Task
    3. if task == null: return                                                    # conditional: the task ended this tick
    4. _ctx.tasks.set_open_slots(task.id, task.open_slots + delta)                # always, from here; the return value is not needed
    5. refresh(_entity, _ctx)                                                     # always: shows the change now, not next tick
  • Builders are pure and never emit. slot_states() only returns a value, so tests call it with no nodes.
  • The game changes only through the registry. _change_slots() calls set_open_slots(); nothing writes open_slots or assignees itself (#152's rule).
  • Signals ignore return values. pressed carries nothing back; the handler calls refresh() itself.
  • Mode: no handling needed. A mode change clears the selection (#78), which hides the panel and with it the buttons.

2.6 Scene structure

inspector_panel.tscn (changed)
└── %Sections (VBoxContainer)
    ├── %DetailsSection (instance)
    └── %WorkerSlotSection (instance of worker_slot_section.tscn)   # after the details: under the Workers row; "Access as Unique Name" set here

worker_slot_section.tscn
WorkerSlotSection (VBoxContainer, worker_slot_section.gd)
├── Header (HBoxContainer)
│   ├── %WorkersLbl (Label)               # size_flags_horizontal = EXPAND_FILL, so the buttons sit right
│   ├── %RemoveWorkerSlotBtn (Button)     # text "-"
│   └── %AddWorkerSlotBtn (Button)        # text "+"
└── %SlotsGrid (GridContainer)            # columns = 2, set in the scene; children made by _ensure_slots()

worker_slot.tscn
WorkerSlot (PanelContainer, worker_slot.gd)   # custom_minimum_size gives the grid even cells
└── %SlotLbl (Label)
  • The slots are direct children of %SlotsGrid: the grid's columns lay them out, and slots() reads them back.
  • The current %SlotsContainer (HBoxContainer) and the title label become %SlotsGrid and %WorkersLbl. Delete them and add the new nodes rather than retyping them, so the editor gives fresh unique_ids.
  • Text and look are set in code (_apply(), refresh()); layout (columns, size flags, minimum size) is set in the scenes.

Names the tests depend on: %WorkerSlotSection and %DetailsSection (in the panel), %WorkersLbl, %RemoveWorkerSlotBtn, %AddWorkerSlotBtn, %SlotsGrid (through workers_lbl, remove_btn, add_btn, slots_grid), and %SlotLbl (through slot_lbl).

2.7 Tests

test/unit/test_worker_slot_section.gd (new). Most tests call refresh() directly with a FixedContext, a test-local InspectorContext whose task_for() returns a chosen StubTask, so max_workers() can be any value.

  • WorkerSlot: an occupied slot names its pawn; open and closed hold NO_PAWN; the three states read differently; a state set before _ready() shows once in the tree; a new slot is closed.
  • slot_states: occupied, then open, then closed; one per max_workers(); [] for null.
  • applies_to: true for a real construction site and a real demolition site; false for a building with no task, a one-worker task, and a pawn holding a three-worker task.
  • refresh: slots match slot_states() with pawn ids; the slots are the grid's children and it has 2 columns; the header reads a / b; the same slot nodes are reused across refreshes; a different max_workers() resizes the grid; - disabled at 1, + at the maximum.
  • Buttons (through 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.
  • In the panel: stacked after the details; a claim shows on the next tick; hidden for a pawn.

test/unit/test_inspector_sections.gd: the three count-based #152 tests now count relative to the scene.

AC Covered by
Added without changing inspector_panel.gd test test_the_panel_stacks_it_after_the_details; review the diff
Header and grid, one slot per max_workers() refresh tests
Three states, visibly different, filled in order slot and slot_states tests; by eye for "visibly"
Raise to max, lower to 1, buttons disable, newest released button tests
Hidden for a pawn, no task, one-worker task applies_to tests, test_hidden_for_a_pawn_in_the_panel
Stays current; nodes reused test_a_claim_shows_on_the_next_tick, test_a_refresh_reuses_the_slot_nodes
A press after the task ended does nothing test_a_press_after_the_task_has_gone_does_nothing
Look checked by eye headed: tools/screenshot_worker_slots.gd, modelled on screenshot_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.
#117 debug command stays review: no change to debug_layer.gd

2.8 Before posting, check that

  • Liam could write every signature without opening another file
  • every signal has an emitter, a listener and a connection site
  • no builder emits, and no flow relies on a signal's return value
  • every node the tests reach is in 2.6 with its exact name
  • every existing method the code leans on is in 2.3
  • every error path has its return value, and any text a test matches on ("1 / 2"-style counts and the pawn id only)
  • every changed signal has its exact emit(...) call written out (none changed)
  • design constraints named. The CLAUDE.md gameplay constraints don't bear on this. #152's rule does: the game changes only through 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.

## Spec (drafted with Claude) This follows the Decisions (2026-10-11) in the body: visible slots in three states, display only, a `Workers: a / b` header with - and +, and a two-column grid. The tests are `test/unit/test_worker_slot_section.gd` on `136-worker-slot-control`; they and this outline are one spec. ### 2.1 Files | File | New / changed | Change | |---|---|---| | `ui/inspector/sections/worker_slot.gd` | new | `class_name WorkerSlot extends PanelContainer`. One slot: state, pawn id, and its look. | | `ui/inspector/sections/worker_slot.tscn` | new | Root `WorkerSlot` plus `%SlotLbl`. See 2.6. | | `ui/inspector/sections/worker_slot_section.gd` | new | `class_name WorkerSlotSection extends InspectorSection`. | | `ui/inspector/sections/worker_slot_section.tscn` | new | Header row plus `%SlotsGrid`. See 2.6. | | `ui/inspector/inspector_panel.tscn` | changed | `%WorkerSlotSection` instanced under `%Sections`, after `%DetailsSection`. | | `test/unit/test_inspector_sections.gd` | changed | Three #152 tests assumed the panel held exactly one section. They now count relative to the scene. That was a gap in #152's spec. | | `tools/screenshot_worker_slots.gd` | new | Headed harness for the look, written by Claude alongside the tests. See 2.7. | | `inspector_panel.gd`, `InspectorContext`, `TaskManager` | unchanged | | ### 2.2 API: full signatures ```gdscript # ui/inspector/sections/worker_slot.gd - class_name WorkerSlot extends PanelContainer enum State {CLOSED, OPEN, OCCUPIED} const NO_PAWN := -1 var state: State = State.CLOSED # read by tests; written only through set_state() var pawn_id: int = NO_PAWN # the pawn in an OCCUPIED slot; NO_PAWN otherwise @onready var slot_lbl: Label = %SlotLbl func _ready() -> void # calls _apply() func set_state(new_state: State, new_pawn_id: int = NO_PAWN) -> void func _apply() -> void # the placeholder look; the only place it lives # ui/inspector/sections/worker_slot_section.gd - class_name WorkerSlotSection extends InspectorSection const SLOT_SCENE: PackedScene = preload("res://ui/inspector/sections/worker_slot.tscn") var _entity: Entity = null # set only in refresh() var _ctx: InspectorContext = null # set only in refresh() @onready var workers_lbl: Label = %WorkersLbl @onready var remove_btn: Button = %RemoveWorkerSlotBtn @onready var add_btn: Button = %AddWorkerSlotBtn @onready var slots_grid: GridContainer = %SlotsGrid static func slot_states(task: Task) -> Array[WorkerSlot.State] # pure; [] for a null task func applies_to(entity: Entity, ctx: InspectorContext) -> bool func refresh(entity: Entity, ctx: InspectorContext) -> void func slots() -> Array[WorkerSlot] # the grid's children, in order func _ready() -> void func _on_add_pressed() -> void func _on_remove_pressed() -> void func _change_slots(delta: int) -> void func _ensure_slots(count: int) -> void ``` Both classes are `Node`s, so tests `autofree` them. `WorkerSlot.State` is 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. | Case | Result | |---|---| | `slot_states(null)` | `[]` | | `set_state(OPEN or CLOSED, some_id)` | the id is ignored; `pawn_id = NO_PAWN` | | `refresh()` when `ctx.task_for()` is null | returns early. It cannot happen through the panel, because `applies_to()` has just said yes, but it keeps `refresh()` safe on its own. | | A press when `_ctx` is null, `_ctx.tasks` is null, or the task has gone | returns, changes nothing, no error | `TaskManager.set_open_slots()` does `push_error` and returns `-1` for 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)`: stores `state`, and stores `pawn_id` only for `OCCUPIED` (`NO_PAWN` otherwise). Then calls `_apply()` **only if** `is_node_ready()`, because before then `slot_lbl` is still null. - `WorkerSlot._ready()`: calls `_apply()`, so a state set before the slot entered the tree shows once it does. - `WorkerSlot._apply()`: sets `slot_lbl.text` and the placeholder look (for example `modulate`) 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 per `task.max_workers()`. Index `i` is OCCUPIED while `i < task.assignees.size()`, OPEN while `i < task.open_slots`, and CLOSED after that. - `WorkerSlotSection.applies_to(entity, ctx)`: false unless `entity is Building`. Then true only if `ctx.task_for(entity)` is non-null and its `max_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 as `WorkerSlot`s, 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 has `count` children, does nothing. Otherwise: for each child, `remove_child()` it **then** `queue_free()` it (a child that is only queued still shows in `get_children()` this frame; `EntityList._remove_entity()` hit the same thing); then adds `count` new `SLOT_SCENE` instances. Existing methods it relies on: - `InspectorContext.task_for(entity)`: for a `Building`, `buildings.task_for(id)` (a `ConstructionTask` or `DeconstructionTask`, or null). For a `Pawn`, **the pawn's own task**, which is why `applies_to()` checks `entity is Building` first. Null if the registry it needs is null. - `TaskManager.set_open_slots(id, count) -> int`: clamps `count` to `1..max_workers()`, writes `task.open_slots`, then releases assignees from the **back** (newest claim first) until they fit. Returns the new count. Unknown id: `push_error` and `-1`. - `TaskManager.claim(id, pawn_id) -> Error`: appends to `task.assignees`, so assignees are in claim order, oldest first. - `Task.max_workers()`: 1 on the base, 3 on `ConstructionTask` and `DeconstructionTask`. - `InspectorPanel.refresh()` (#152): for each section, sets `visible` from `applies_to()` and calls `refresh()` only if it applies. Runs on `selection_changed` and on every `GameClock.tick`. - **Tick order:** the panel's `tick` connection is made in its own `_ready()`, which runs before `game.gd` connects `pawns.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. | Signal | Declared on | Custom / built-in | Emitted by (method) | Listened to by (method) | Connected where | |---|---|---|---|---|---| | `pressed()` | `Button` (`%AddWorkerSlotBtn`) | built-in | engine, on click | `WorkerSlotSection._on_add_pressed()` | `WorkerSlotSection._ready()`, in code | | `pressed()` | `Button` (`%RemoveWorkerSlotBtn`) | built-in | engine, on click | `WorkerSlotSection._on_remove_pressed()` | `WorkerSlotSection._ready()`, in code | | `selection_changed(new, previous)` | `GameState` | custom | `GameState.selected` setter | `InspectorPanel._on_selection_changed()` | `InspectorPanel._ready()` (unchanged) | | `tick(run_tick)` | `GameClock` | custom | `GameClock` | `InspectorPanel._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 ``` GameState.selection_changed / GameClock.tick -> InspectorPanel.refresh() # #152, unchanged for each section: visible = applies_to(entity, context) if visible: section.refresh(entity, context) WorkerSlotSection.refresh(entity, ctx): 1. _entity = entity; _ctx = ctx # always 2. task := ctx.task_for(entity); if task == null: return # conditional 3. _ensure_slots(task.max_workers()) # always; creates nodes only if the count differs 4. states := slot_states(task) # pure for i in states.size(): slots()[i].set_state(states[i], task.assignees[i] if i < task.assignees.size() else WorkerSlot.NO_PAWN) 5. workers_lbl.text = "Workers: %d / %d" % [task.assignees.size(), task.open_slots] 6. remove_btn.disabled = task.open_slots <= 1 # always 7. add_btn.disabled = task.open_slots >= task.max_workers() # always %AddWorkerSlotBtn.pressed -> _on_add_pressed() -> _change_slots(1) # connected in _ready() %RemoveWorkerSlotBtn.pressed -> _on_remove_pressed() -> _change_slots(-1) # connected in _ready() _change_slots(delta): 1. if _ctx == null or _ctx.tasks == null: return # conditional 2. task := _ctx.task_for(_entity) # always fresh: never a cached Task 3. if task == null: return # conditional: the task ended this tick 4. _ctx.tasks.set_open_slots(task.id, task.open_slots + delta) # always, from here; the return value is not needed 5. refresh(_entity, _ctx) # always: shows the change now, not next tick ``` - **Builders are pure and never emit.** `slot_states()` only returns a value, so tests call it with no nodes. - **The game changes only through the registry.** `_change_slots()` calls `set_open_slots()`; nothing writes `open_slots` or `assignees` itself (#152's rule). - **Signals ignore return values.** `pressed` carries nothing back; the handler calls `refresh()` itself. - **Mode:** no handling needed. A mode change clears the selection (#78), which hides the panel and with it the buttons. ### 2.6 Scene structure ``` inspector_panel.tscn (changed) └── %Sections (VBoxContainer) ├── %DetailsSection (instance) └── %WorkerSlotSection (instance of worker_slot_section.tscn) # after the details: under the Workers row; "Access as Unique Name" set here worker_slot_section.tscn WorkerSlotSection (VBoxContainer, worker_slot_section.gd) ├── Header (HBoxContainer) │ ├── %WorkersLbl (Label) # size_flags_horizontal = EXPAND_FILL, so the buttons sit right │ ├── %RemoveWorkerSlotBtn (Button) # text "-" │ └── %AddWorkerSlotBtn (Button) # text "+" └── %SlotsGrid (GridContainer) # columns = 2, set in the scene; children made by _ensure_slots() worker_slot.tscn WorkerSlot (PanelContainer, worker_slot.gd) # custom_minimum_size gives the grid even cells └── %SlotLbl (Label) ``` - The slots are **direct children of `%SlotsGrid`**: the grid's columns lay them out, and `slots()` reads them back. - The current `%SlotsContainer` (HBoxContainer) and the title label become `%SlotsGrid` and `%WorkersLbl`. Delete them and add the new nodes rather than retyping them, so the editor gives fresh `unique_id`s. - Text and look are set in code (`_apply()`, `refresh()`); layout (`columns`, size flags, minimum size) is set in the scenes. Names the tests depend on: `%WorkerSlotSection` and `%DetailsSection` (in the panel), `%WorkersLbl`, `%RemoveWorkerSlotBtn`, `%AddWorkerSlotBtn`, `%SlotsGrid` (through `workers_lbl`, `remove_btn`, `add_btn`, `slots_grid`), and `%SlotLbl` (through `slot_lbl`). ### 2.7 Tests `test/unit/test_worker_slot_section.gd` (new). Most tests call `refresh()` directly with a `FixedContext`, a test-local `InspectorContext` whose `task_for()` returns a chosen `StubTask`, so `max_workers()` can be any value. - **WorkerSlot**: an occupied slot names its pawn; open and closed hold `NO_PAWN`; the three states read differently; a state set before `_ready()` shows once in the tree; a new slot is closed. - **slot_states**: occupied, then open, then closed; one per `max_workers()`; `[]` for null. - **applies_to**: true for a real construction site and a real demolition site; false for a building with no task, a one-worker task, and a pawn holding a three-worker task. - **refresh**: slots match `slot_states()` with pawn ids; the slots are the grid's children and it has 2 columns; the header reads `a / b`; the same slot nodes are reused across refreshes; a different `max_workers()` resizes the grid; - disabled at 1, + at the maximum. - **Buttons** (through `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. - **In the panel**: stacked after the details; a claim shows on the next tick; hidden for a pawn. `test/unit/test_inspector_sections.gd`: the three count-based #152 tests now count relative to the scene. | AC | Covered by | |---|---| | Added without changing `inspector_panel.gd` | test `test_the_panel_stacks_it_after_the_details`; review the diff | | Header and grid, one slot per `max_workers()` | refresh tests | | Three states, visibly different, filled in order | slot and `slot_states` tests; **by eye** for "visibly" | | Raise to max, lower to 1, buttons disable, newest released | button tests | | Hidden for a pawn, no task, one-worker task | `applies_to` tests, `test_hidden_for_a_pawn_in_the_panel` | | Stays current; nodes reused | `test_a_claim_shows_on_the_next_tick`, `test_a_refresh_reuses_the_slot_nodes` | | A press after the task ended does nothing | `test_a_press_after_the_task_has_gone_does_nothing` | | Look checked by eye | **headed**: `tools/screenshot_worker_slots.gd`, modelled on `screenshot_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. | | #117 debug command stays | review: no change to `debug_layer.gd` | ### 2.8 Before posting, check that - [x] Liam could write every signature without opening another file - [x] every signal has an emitter, a listener and a connection site - [x] no builder emits, and no flow relies on a signal's return value - [x] every node the tests reach is in 2.6 with its exact name - [x] every existing method the code leans on is in 2.3 - [x] every error path has its return value, and any text a test matches on (`"1 / 2"`-style counts and the pawn id only) - [x] every changed signal has its exact `emit(...)` call written out (none changed) - [x] design constraints named. The CLAUDE.md gameplay constraints don't bear on this. #152's rule does: the game changes only through `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.
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.

Reference
liamjd/UntitledColonyBuilder#136
No description provided.