Inspector panel for the selected pawn or building #142

Closed
opened 2026-09-30 21:50:56 +01:00 by liamjd · 2 comments
Owner

Part of milestone #6 (Selection and inspection). Depends on #78 (closed).

A panel that shows detailed information about whatever GameState.selected holds, whether it was selected on the map (#78), from the pawn list (#140) or from the building list (#141). It is the player-facing counterpart to the debug panel, and the place #136's worker-slot control will go.

Acceptance criteria

  • With nothing selected, the panel is hidden.
  • For a pawn it shows its name, state, cell, and current task (the task's label, or "idle").
  • For a building it shows its name (type and id) and status. While PLANNED or UNDER_CONSTRUCTION it also shows construction progress and whether construction is blocked. It always shows whether the building is marked for demolition. When the building has a task, it shows the pawns on that task.
  • It refreshes on GameState.selection_changed and on every GameClock.tick, so values stay current while it is open. No _process.
  • Removing the selected entity, or changing mode, hides the panel. The clearing itself comes from #78.
  • It catches the mouse: a click on the panel never reaches the map's selection.
  • Unit tests on the rows built for each entity kind, and on the panel's show/hide and refresh. A headed screenshot of each kind, checked by eye (tools/screenshot_inspector.gd).

Decisions (2026-10-10)

  1. One panel, switching its contents by the selected type. The rows come from pure static builders, so the "text built for each kind" tests need no scene.
  2. It refreshes on the tick, not on per-field signals. The only state signal that exists is building_status_changed. Nothing fires when a pawn's state, cell or task changes, when a building's progress, blocked flag or demolition mark changes, or when a task's assignees change. Nearly all of these change inside the tick, so redrawing on GameClock.tick shows them at the right moment, at negligible cost for one panel. This is the same approach as the debug panel. A pawn's cell follows its view between ticks, so it can lag by up to one tick.
  3. No role row yet. #74 has not landed, so pawns have no role. #74 gains the criterion "the inspector shows the pawn's role".
  4. The current task is shown by its label, which already names the kind of work and its target cell. "idle" when the pawn holds no live task.
  5. Buildings._task_for() becomes public task_for(). The panel needs a building's task to list its workers. #126 is not a blocker: it only simplifies that method's insides.
  6. TaskManager and Buildings are injected by game.gd, as toast_notifier.tasks is.
  7. Position: the left edge for now. The lists may move left and the inspector right later, which is a scene-only change.
  8. "Does not leave PLACE or DEMOLISH" is now "catches the mouse". Changing mode already clears the selection, so the panel is never shown outside NONE mode. What matters is that a click on it is consumed rather than reaching SelectionController._unhandled_input().

Out of scope

  • Worker slot + / − controls, which are #136. They go in this panel, so #136 depends on this issue.
  • A pawn's role, which is #74.
  • Styling, until art and theming land (#21).
Part of milestone #6 (Selection and inspection). Depends on #78 (closed). A panel that shows detailed information about whatever `GameState.selected` holds, whether it was selected on the map (#78), from the pawn list (#140) or from the building list (#141). It is the player-facing counterpart to the debug panel, and the place #136's worker-slot control will go. ## Acceptance criteria - [ ] With nothing selected, the panel is hidden. - [ ] For a pawn it shows its name, `state`, `cell`, and current task (the task's label, or "idle"). - [ ] For a building it shows its name (type and id) and `status`. While PLANNED or UNDER_CONSTRUCTION it also shows construction progress and whether construction is blocked. It always shows whether the building is marked for demolition. When the building has a task, it shows the pawns on that task. - [ ] It refreshes on `GameState.selection_changed` and on every `GameClock.tick`, so values stay current while it is open. No `_process`. - [ ] Removing the selected entity, or changing mode, hides the panel. The clearing itself comes from #78. - [ ] It catches the mouse: a click on the panel never reaches the map's selection. - [ ] Unit tests on the rows built for each entity kind, and on the panel's show/hide and refresh. A headed screenshot of each kind, checked by eye (`tools/screenshot_inspector.gd`). ## Decisions (2026-10-10) 1. **One panel**, switching its contents by the selected type. The rows come from pure static builders, so the "text built for each kind" tests need no scene. 2. **It refreshes on the tick, not on per-field signals.** The only state signal that exists is `building_status_changed`. Nothing fires when a pawn's state, cell or task changes, when a building's progress, blocked flag or demolition mark changes, or when a task's assignees change. Nearly all of these change inside the tick, so redrawing on `GameClock.tick` shows them at the right moment, at negligible cost for one panel. This is the same approach as the debug panel. A pawn's `cell` follows its view between ticks, so it can lag by up to one tick. 3. **No role row yet.** #74 has not landed, so pawns have no role. #74 gains the criterion "the inspector shows the pawn's role". 4. **The current task is shown by its `label`**, which already names the kind of work and its target cell. "idle" when the pawn holds no live task. 5. **`Buildings._task_for()` becomes public `task_for()`.** The panel needs a building's task to list its workers. #126 is not a blocker: it only simplifies that method's insides. 6. **`TaskManager` and `Buildings` are injected by `game.gd`**, as `toast_notifier.tasks` is. 7. **Position: the left edge for now.** The lists may move left and the inspector right later, which is a scene-only change. 8. **"Does not leave PLACE or DEMOLISH" is now "catches the mouse".** Changing mode already clears the selection, so the panel is never shown outside NONE mode. What matters is that a click on it is consumed rather than reaching `SelectionController._unhandled_input()`. ## Out of scope - Worker slot + / − controls, which are #136. They go in this panel, so #136 depends on this issue. - A pawn's role, which is #74. - Styling, until art and theming land (#21).
Author
Owner

Spec (drafted with Claude)

Decisions are in the issue body (2026-10-10). This outline and test/unit/test_inspector_panel.gd are one spec. Where they disagree with the code, they are the spec.

2.1 Files

File New / changed Change
ui/inspector/inspector_panel.gd new class_name InspectorPanel: three static row builders, and a panel that renders them for GameState.selected
ui/inspector/inspector_panel.tscn new The panel's layout (2.6). Root mouse_filter = STOP, visible = false
entities/buildings/buildings.gd changed _task_for() renamed to public task_for(). Update its two callers, mark_for_demolition() and unmark_for_demolition(). No behaviour change
common/game.gd changed @onready var inspector_panel, plus two injections inside the not Engine.is_editor_hint() guard
common/game.tscn changed Instance inspector_panel.tscn under UIHUDLayer as InspectorPanel
test/unit/test_inspector_panel.gd new The spec tests (2.7)
tools/screenshot_inspector.gd new Headed check, one shot per kind (Claude writes it at review)

2.2 API: full signatures

# ui/inspector/inspector_panel.gd - class_name InspectorPanel extends PanelContainer   (a Node: tests autofree it)

const IDLE_TEXT := "idle"     # the "Task" value when the pawn holds no live task
const NONE_TEXT := "none"     # the "Workers" value when the task has no assignees
const YES_TEXT := "yes"
const NO_TEXT := "no"

var tasks: TaskManager = null     # injected by game.gd; null is supported (no task rows resolve)
var buildings: Buildings = null   # injected by game.gd; null is supported

@onready var title_lbl: Label = %TitleLbl
@onready var rows_grid: GridContainer = %Rows

static func title_for(entity: Entity) -> String
static func pawn_rows(pawn: Pawn, task: Task) -> Dictionary[String, String]
static func building_rows(building: Building, task: Task) -> Dictionary[String, String]

func refresh() -> void
func _on_selection_changed(_new: Entity, _previous: Entity) -> void
func _on_tick(_run_tick: int) -> void
func _task_for(entity: Entity) -> Task          # null when there is no task, or nothing injected
func _render(rows: Dictionary[String, String]) -> void
# entities/buildings/buildings.gd - class_name Buildings extends Node3D
func task_for(building_id: int) -> Task         # was _task_for(); null when no manager or no matching task

Exact row contract. The tests pin the keys, their order and the values. Dictionary keeps insertion order, so insert in this order:

Builder Key Value Present when
title_for(pawn) n/a "Pawn %d" % pawn.id, e.g. "Pawn 3" n/a
title_for(building) n/a "%s %d" % [building.type.display_name, building.id], e.g. "Warehouse 4", the same as BuildingCard n/a
title_for(null) or another Entity n/a "" n/a
pawn_rows "State" Pawn.PawnState.keys()[pawn.state], e.g. "IDLING" always
"Cell" str(pawn.cell), e.g. "(3, 4)" always
"Task" task.label, or IDLE_TEXT when task == null always
building_rows "Status" Building.Status.keys()[building.status], e.g. "UNDER_CONSTRUCTION" always
"Progress" "%d%%" % roundi(building.build_progress * 100.0), e.g. "40%" status is PLANNED or UNDER_CONSTRUCTION
"Blocked" YES_TEXT / NO_TEXT from construction_blocked status is PLANNED or UNDER_CONSTRUCTION
"Marked for demolition" YES_TEXT / NO_TEXT from marked_for_demolition always
"Workers" assignees as "Pawn 1, Pawn 3" in task.assignees order, or NONE_TEXT when empty task != null

Error paths. None push an error, and no test matches error text.

Call Case Result
title_for null, or an Entity that is neither Pawn nor Building ""
pawn_rows / building_rows task == null Ordinary: the "idle" value, or no "Workers" row
_task_for tasks / buildings not injected, or no task null
refresh GameState.selected == null Hides the panel; leaves the rows as they were

The builders do not accept a null pawn or building. refresh() never passes one.

2.3 Each method's job, in one line

New

  • title_for(entity): the panel heading, built from the entity's kind and id. Pure.
  • pawn_rows(pawn, task): the pawn's rows per the table above. Pure; takes the task rather than a manager, so tests need no TaskManager.
  • building_rows(building, task): the building's rows per the table above. Pure, for the same reason.
  • refresh(): reads GameState.selected. If null, sets visible = false and returns. Otherwise sets visible = true, sets title_lbl.text, builds the rows for its kind with _task_for(), and calls _render().
  • _on_selection_changed(...): calls refresh(). It ignores the arguments, because refresh() reads GameState.selected itself.
  • _on_tick(...): calls refresh() only when GameState.selected != null, so a hidden panel does no work on each tick.
  • _task_for(entity): for a Pawn, tasks.get_task(pawn.current_task_id) when tasks != null. For a Building, buildings.task_for(building.id) when buildings != null. Otherwise null.
  • _render(rows): free()s every child of rows_grid, then adds two Labels per row: the key, then the value. Use free() rather than queue_free() so get_child_count() is correct the moment it returns. They are scriptless leaf Labels with no callbacks in flight, the same argument as Buildings._despawn_view().
  • _ready(): connects the two signals (2.4), then calls refresh() once, so a panel created while something is already selected starts correct.
  • Buildings.task_for(id): the existing _task_for() body, unchanged and made public.

Existing code the new code relies on

  • GameState.selected setter: emits selection_changed(new, previous) itself, and only on a real change. It is set to null when the selected pawn or building is removed (_clear_if_selected) and when interaction_mode changes. That is what makes "removing or a mode change hides the panel" work with no extra code here.
  • TaskManager.get_task(id): returns null for an unknown id, without an error, so get_task(-1) for a pawn with no task is safe.
  • Buildings._task_for(id) (soon task_for): scans tasks.all() for a ConstructionTask or DeconstructionTask with a matching building_id. Returns null when tasks == null. all() excludes completed and cancelled tasks.
  • Task.label: set by Buildings.place() / mark_for_demolition() to text like "Construct ore_mine at (12, 5) for 3.0 days". It already names the target, which is why decision 4 shows it as is.
  • Task.assignees: Array[int]: pawn ids, oldest claim first.
  • GameClock.tick: emitted from GameClock._process() via advance(), at ticks_per_second (10) × speed. Not emitted while paused.
  • SelectionController._unhandled_input(): selects on select in NONE mode. It only sees a click that no Control consumed, so the panel must be MOUSE_FILTER_STOP. PanelContainer already defaults to STOP, and MarginContainer / VBoxContainer default to PASS, which passes up to the root. Do not copy the list panels here: their PanelContainer sets mouse_filter = 1 (PASS) explicitly. The test pins STOP for that reason.

2.4 Signals

No new signals, and nothing in this issue emits one.

Signal Declared on Custom / built-in Emitted by (method) Listened to by (method) Connected where
selection_changed(new: Entity, previous: Entity) GameState custom GameState.selected setter InspectorPanel._on_selection_changed() InspectorPanel._ready()
tick(run_tick: int) GameClock custom GameClock.advance() InspectorPanel._on_tick() InspectorPanel._ready()

The panel subscribes to GameClock.tick itself, unlike Pawns.on_tick, which game.gd connects. Pawns avoids self-subscription because the live clock would drive work during unrelated tests. The panel only reads state, so a stray tick during a test is harmless.

2.5 Wiring pattern

GameState.selection_changed -> InspectorPanel._on_selection_changed(_new, _previous)   # connected in _ready()
                               calls refresh()

GameClock.tick              -> InspectorPanel._on_tick(_run_tick)                      # connected in _ready()
                               if GameState.selected != null: refresh()

refresh():
  1. entity := GameState.selected
  2. if entity == null: visible = false; return             # always stops here when nothing is selected
  3. visible = true
  4. title_lbl.text = title_for(entity)                      # pure
  5. rows := pawn_rows(entity, _task_for(entity))            # if entity is Pawn; pure
     rows := building_rows(entity, _task_for(entity))        # if entity is Building; pure
  6. _render(rows)                                           # the only step that touches nodes besides 3-4
  • The builders are pure and never emit. They return a Dictionary, so tests call them without a scene.
  • Nothing here emits. The panel only listens.
  • Signals ignore return values. No flow here needs one back.

2.6 Scene structure

inspector_panel.tscn
InspectorPanel (PanelContainer, inspector_panel.gd)   # visible = false; mouse_filter = STOP, the PanelContainer default; leave it
└── MarginContainer                                  # margins 8; mouse_filter left at default
    └── VBoxContainer
        ├── %TitleLbl (Label)
        └── %Rows (GridContainer)                    # columns = 2, set in scene; children made by _render()

game.tscn
Game (Node3D, game.gd)
└── UIHUDLayer (CanvasLayer)
    └── InspectorPanel (inspector_panel.tscn)   # anchored centre-left (anchors_preset = 4), offset_left = 20,
                                                # custom_minimum_size.x = 240. Left edge per decision 7
  • Not to be confused with BuildingPanel, which is the build bar along the bottom.
  • Names the tests depend on: the scene path res://ui/inspector/inspector_panel.tscn, %TitleLbl and %Rows. The tests read title_lbl, rows_grid, visible and mouse_filter. _render order matters: the tests read %Rows' children as key/value pairs, child 2i = key and 2i + 1 = value.
  • Set in the scene: visible, columns, anchors. mouse_filter stays at the PanelContainer default (STOP). Set in code: label texts, and the %Rows children.

game.gd additions:

@onready var inspector_panel: InspectorPanel = $UIHUDLayer/InspectorPanel
# inside `if not Engine.is_editor_hint():`, alongside toast_notifier.tasks:
inspector_panel.tasks = task_manager
inspector_panel.buildings = buildings

2.7 Tests

test/unit/test_inspector_panel.gd:

  • title_for: a pawn, a building, and the "" fallback.
  • pawn_rows: the key order, each value, the task's label, and "idle" for no task.
  • building_rows: the BUILT key set (no Progress or Blocked), the PLANNED / UNDER_CONSTRUCTION key set, progress rounding, the blocked and marked flags, and Workers listed, "none" and absent.
  • Panel: hidden with nothing selected; shows a selected pawn's title and rows; hides again on deselect; hides on a mode change; a tick redraws a changed value; MOUSE_FILTER_STOP.
  • Panel with a real Buildings and TaskManager: a PLANNED building's Workers row comes through task_for().
  • Buildings.task_for: returns the construction task of a planned building; null for a BUILT one with no task; null with no manager.

Covered by tests: every criterion except the screenshots and the position. Headed only: how the panel looks and where it sits, via tools/screenshot_inspector.gd.

2.8 Checks

  • Every signature is above, with types, statics and the class each extends.
  • Both signals have an emitter, a listener and a connection site.
  • No builder emits, and nothing relies on a signal's return value.
  • Every node the tests reach (%TitleLbl, %Rows) is in 2.6.
  • Existing methods relied on are in 2.3.
  • Every error path has its return value; no test matches error text.
  • No signal changes, so there are no emit(...) calls to write out.
  • CLAUDE.md constraints: none of the design constraints apply. Static typing throughout; Resources in tests are not freed.
## Spec (drafted with Claude) Decisions are in the issue body (2026-10-10). This outline and `test/unit/test_inspector_panel.gd` are one spec. Where they disagree with the code, they are the spec. ### 2.1 Files | File | New / changed | Change | |---|---|---| | `ui/inspector/inspector_panel.gd` | new | `class_name InspectorPanel`: three static row builders, and a panel that renders them for `GameState.selected` | | `ui/inspector/inspector_panel.tscn` | new | The panel's layout (2.6). Root `mouse_filter = STOP`, `visible = false` | | `entities/buildings/buildings.gd` | changed | `_task_for()` renamed to public `task_for()`. Update its two callers, `mark_for_demolition()` and `unmark_for_demolition()`. No behaviour change | | `common/game.gd` | changed | `@onready var inspector_panel`, plus two injections inside the `not Engine.is_editor_hint()` guard | | `common/game.tscn` | changed | Instance `inspector_panel.tscn` under `UIHUDLayer` as `InspectorPanel` | | `test/unit/test_inspector_panel.gd` | new | The spec tests (2.7) | | `tools/screenshot_inspector.gd` | new | Headed check, one shot per kind (Claude writes it at review) | ### 2.2 API: full signatures ```gdscript # ui/inspector/inspector_panel.gd - class_name InspectorPanel extends PanelContainer (a Node: tests autofree it) const IDLE_TEXT := "idle" # the "Task" value when the pawn holds no live task const NONE_TEXT := "none" # the "Workers" value when the task has no assignees const YES_TEXT := "yes" const NO_TEXT := "no" var tasks: TaskManager = null # injected by game.gd; null is supported (no task rows resolve) var buildings: Buildings = null # injected by game.gd; null is supported @onready var title_lbl: Label = %TitleLbl @onready var rows_grid: GridContainer = %Rows static func title_for(entity: Entity) -> String static func pawn_rows(pawn: Pawn, task: Task) -> Dictionary[String, String] static func building_rows(building: Building, task: Task) -> Dictionary[String, String] func refresh() -> void func _on_selection_changed(_new: Entity, _previous: Entity) -> void func _on_tick(_run_tick: int) -> void func _task_for(entity: Entity) -> Task # null when there is no task, or nothing injected func _render(rows: Dictionary[String, String]) -> void ``` ```gdscript # entities/buildings/buildings.gd - class_name Buildings extends Node3D func task_for(building_id: int) -> Task # was _task_for(); null when no manager or no matching task ``` **Exact row contract.** The tests pin the keys, their order and the values. `Dictionary` keeps insertion order, so insert in this order: | Builder | Key | Value | Present when | |---|---|---|---| | `title_for(pawn)` | n/a | `"Pawn %d" % pawn.id`, e.g. `"Pawn 3"` | n/a | | `title_for(building)` | n/a | `"%s %d" % [building.type.display_name, building.id]`, e.g. `"Warehouse 4"`, the same as `BuildingCard` | n/a | | `title_for(null)` or another Entity | n/a | `""` | n/a | | `pawn_rows` | `"State"` | `Pawn.PawnState.keys()[pawn.state]`, e.g. `"IDLING"` | always | | | `"Cell"` | `str(pawn.cell)`, e.g. `"(3, 4)"` | always | | | `"Task"` | `task.label`, or `IDLE_TEXT` when `task == null` | always | | `building_rows` | `"Status"` | `Building.Status.keys()[building.status]`, e.g. `"UNDER_CONSTRUCTION"` | always | | | `"Progress"` | `"%d%%" % roundi(building.build_progress * 100.0)`, e.g. `"40%"` | status is PLANNED or UNDER_CONSTRUCTION | | | `"Blocked"` | `YES_TEXT` / `NO_TEXT` from `construction_blocked` | status is PLANNED or UNDER_CONSTRUCTION | | | `"Marked for demolition"` | `YES_TEXT` / `NO_TEXT` from `marked_for_demolition` | always | | | `"Workers"` | assignees as `"Pawn 1, Pawn 3"` in `task.assignees` order, or `NONE_TEXT` when empty | `task != null` | **Error paths.** None push an error, and no test matches error text. | Call | Case | Result | |---|---|---| | `title_for` | `null`, or an `Entity` that is neither Pawn nor Building | `""` | | `pawn_rows` / `building_rows` | `task == null` | Ordinary: the "idle" value, or no "Workers" row | | `_task_for` | `tasks` / `buildings` not injected, or no task | `null` | | `refresh` | `GameState.selected == null` | Hides the panel; leaves the rows as they were | The builders do not accept a null pawn or building. `refresh()` never passes one. ### 2.3 Each method's job, in one line **New** - `title_for(entity)`: the panel heading, built from the entity's kind and id. Pure. - `pawn_rows(pawn, task)`: the pawn's rows per the table above. Pure; takes the task rather than a manager, so tests need no `TaskManager`. - `building_rows(building, task)`: the building's rows per the table above. Pure, for the same reason. - `refresh()`: reads `GameState.selected`. If null, sets `visible = false` and returns. Otherwise sets `visible = true`, sets `title_lbl.text`, builds the rows for its kind with `_task_for()`, and calls `_render()`. - `_on_selection_changed(...)`: calls `refresh()`. It ignores the arguments, because `refresh()` reads `GameState.selected` itself. - `_on_tick(...)`: calls `refresh()` only when `GameState.selected != null`, so a hidden panel does no work on each tick. - `_task_for(entity)`: for a Pawn, `tasks.get_task(pawn.current_task_id)` when `tasks != null`. For a Building, `buildings.task_for(building.id)` when `buildings != null`. Otherwise null. - `_render(rows)`: `free()`s every child of `rows_grid`, then adds two `Label`s per row: the key, then the value. Use `free()` rather than `queue_free()` so `get_child_count()` is correct the moment it returns. They are scriptless leaf Labels with no callbacks in flight, the same argument as `Buildings._despawn_view()`. - `_ready()`: connects the two signals (2.4), then calls `refresh()` once, so a panel created while something is already selected starts correct. - `Buildings.task_for(id)`: the existing `_task_for()` body, unchanged and made public. **Existing code the new code relies on** - `GameState.selected` setter: emits `selection_changed(new, previous)` itself, and only on a real change. It is set to null when the selected pawn or building is removed (`_clear_if_selected`) and when `interaction_mode` changes. That is what makes "removing or a mode change hides the panel" work with no extra code here. - `TaskManager.get_task(id)`: returns null for an unknown id, **without an error**, so `get_task(-1)` for a pawn with no task is safe. - `Buildings._task_for(id)` (soon `task_for`): scans `tasks.all()` for a `ConstructionTask` or `DeconstructionTask` with a matching `building_id`. Returns null when `tasks == null`. `all()` excludes completed and cancelled tasks. - `Task.label`: set by `Buildings.place()` / `mark_for_demolition()` to text like `"Construct ore_mine at (12, 5) for 3.0 days"`. It already names the target, which is why decision 4 shows it as is. - `Task.assignees: Array[int]`: pawn ids, oldest claim first. - `GameClock.tick`: emitted from `GameClock._process()` via `advance()`, at `ticks_per_second` (10) × speed. Not emitted while paused. - `SelectionController._unhandled_input()`: selects on `select` in NONE mode. It only sees a click that no Control consumed, so the panel must be `MOUSE_FILTER_STOP`. `PanelContainer` already defaults to STOP, and `MarginContainer` / `VBoxContainer` default to PASS, which passes up to the root. **Do not copy the list panels here**: their `PanelContainer` sets `mouse_filter = 1` (PASS) explicitly. The test pins STOP for that reason. ### 2.4 Signals No new signals, and nothing in this issue emits one. | Signal | Declared on | Custom / built-in | Emitted by (method) | Listened to by (method) | Connected where | |---|---|---|---|---|---| | `selection_changed(new: Entity, previous: Entity)` | `GameState` | custom | `GameState.selected` setter | `InspectorPanel._on_selection_changed()` | `InspectorPanel._ready()` | | `tick(run_tick: int)` | `GameClock` | custom | `GameClock.advance()` | `InspectorPanel._on_tick()` | `InspectorPanel._ready()` | The panel subscribes to `GameClock.tick` itself, unlike `Pawns.on_tick`, which `game.gd` connects. Pawns avoids self-subscription because the live clock would drive work during unrelated tests. The panel only reads state, so a stray tick during a test is harmless. ### 2.5 Wiring pattern ``` GameState.selection_changed -> InspectorPanel._on_selection_changed(_new, _previous) # connected in _ready() calls refresh() GameClock.tick -> InspectorPanel._on_tick(_run_tick) # connected in _ready() if GameState.selected != null: refresh() refresh(): 1. entity := GameState.selected 2. if entity == null: visible = false; return # always stops here when nothing is selected 3. visible = true 4. title_lbl.text = title_for(entity) # pure 5. rows := pawn_rows(entity, _task_for(entity)) # if entity is Pawn; pure rows := building_rows(entity, _task_for(entity)) # if entity is Building; pure 6. _render(rows) # the only step that touches nodes besides 3-4 ``` - **The builders are pure and never emit.** They return a `Dictionary`, so tests call them without a scene. - **Nothing here emits.** The panel only listens. - **Signals ignore return values.** No flow here needs one back. ### 2.6 Scene structure ``` inspector_panel.tscn InspectorPanel (PanelContainer, inspector_panel.gd) # visible = false; mouse_filter = STOP, the PanelContainer default; leave it └── MarginContainer # margins 8; mouse_filter left at default └── VBoxContainer ├── %TitleLbl (Label) └── %Rows (GridContainer) # columns = 2, set in scene; children made by _render() game.tscn Game (Node3D, game.gd) └── UIHUDLayer (CanvasLayer) └── InspectorPanel (inspector_panel.tscn) # anchored centre-left (anchors_preset = 4), offset_left = 20, # custom_minimum_size.x = 240. Left edge per decision 7 ``` - Not to be confused with `BuildingPanel`, which is the build bar along the bottom. - **Names the tests depend on:** the scene path `res://ui/inspector/inspector_panel.tscn`, `%TitleLbl` and `%Rows`. The tests read `title_lbl`, `rows_grid`, `visible` and `mouse_filter`. `_render` order matters: the tests read `%Rows`' children as key/value pairs, child `2i` = key and `2i + 1` = value. - Set in the scene: `visible`, `columns`, anchors. `mouse_filter` stays at the PanelContainer default (STOP). Set in code: label texts, and the `%Rows` children. `game.gd` additions: ```gdscript @onready var inspector_panel: InspectorPanel = $UIHUDLayer/InspectorPanel # inside `if not Engine.is_editor_hint():`, alongside toast_notifier.tasks: inspector_panel.tasks = task_manager inspector_panel.buildings = buildings ``` ### 2.7 Tests `test/unit/test_inspector_panel.gd`: - **`title_for`**: a pawn, a building, and the `""` fallback. - **`pawn_rows`**: the key order, each value, the task's label, and "idle" for no task. - **`building_rows`**: the BUILT key set (no Progress or Blocked), the PLANNED / UNDER_CONSTRUCTION key set, progress rounding, the blocked and marked flags, and Workers listed, "none" and absent. - **Panel**: hidden with nothing selected; shows a selected pawn's title and rows; hides again on deselect; hides on a mode change; a tick redraws a changed value; `MOUSE_FILTER_STOP`. - **Panel with a real `Buildings` and `TaskManager`**: a PLANNED building's Workers row comes through `task_for()`. - **`Buildings.task_for`**: returns the construction task of a planned building; null for a BUILT one with no task; null with no manager. Covered by **tests**: every criterion except the screenshots and the position. **Headed only**: how the panel looks and where it sits, via `tools/screenshot_inspector.gd`. ### 2.8 Checks - [x] Every signature is above, with types, statics and the class each extends. - [x] Both signals have an emitter, a listener and a connection site. - [x] No builder emits, and nothing relies on a signal's return value. - [x] Every node the tests reach (`%TitleLbl`, `%Rows`) is in 2.6. - [x] Existing methods relied on are in 2.3. - [x] Every error path has its return value; no test matches error text. - [x] No signal changes, so there are no `emit(...)` calls to write out. - [x] CLAUDE.md constraints: none of the design constraints apply. Static typing throughout; `Resource`s in tests are not freed.
Author
Owner

Spec amendment (2026-10-10): what the panel shows, not how it says it

This replaces the Exact row contract table in the spec comment above. The panel will change a lot as the game grows, so the tests no longer fix the row labels, their order or the exact values. The builders' signatures, the Dictionary[String, String] return type and everything else in the outline are unchanged.

What the tests now pin

Builder Must be in the text (any key or value) Shape rule
title_for(pawn) the pawn's id n/a
title_for(building) the type's display_name and the building's id n/a
title_for(null) or another Entity n/a exactly ""
pawn_rows the state name (e.g. MOVING), str(pawn.cell), and the task's label, or IDLE_TEXT with no task none
building_rows the status name Progress (a whole percentage) only while PLANNED or UNDER_CONSTRUCTION
Blocked adds one row only when construction_blocked is true
Marked for demolition adds one row only when marked_for_demolition is true
A BUILT building with no flags and no task has exactly one row (its status)
each assignee's id, when task != null NONE_TEXT when the task has no assignees. With no task there is no workers row at all

The flag rule is the one real change. YES_TEXT and NO_TEXT go away: an unset flag is noise, so it gets no row.

IDLE_TEXT and NONE_TEXT stay as constants. The tests read them by name, so their wording can change freely.

Panel tests

These are unchanged in intent. They compare the rendered rows with the builder's own output, or check that a fact appears, so none of them fixes any wording. There is one new test: test_a_building_with_only_the_manager_injected_still_renders, which guards the _task_for null case found in review.

## Spec amendment (2026-10-10): what the panel shows, not how it says it This replaces the **Exact row contract** table in the spec comment above. The panel will change a lot as the game grows, so the tests no longer fix the row labels, their order or the exact values. The builders' signatures, the `Dictionary[String, String]` return type and everything else in the outline are unchanged. ### What the tests now pin | Builder | Must be in the text (any key or value) | Shape rule | |---|---|---| | `title_for(pawn)` | the pawn's id | n/a | | `title_for(building)` | the type's `display_name` and the building's id | n/a | | `title_for(null)` or another Entity | n/a | exactly `""` | | `pawn_rows` | the state name (e.g. `MOVING`), `str(pawn.cell)`, and the task's `label`, or `IDLE_TEXT` with no task | none | | `building_rows` | the status name | Progress (a **whole** percentage) only while PLANNED or UNDER_CONSTRUCTION | | | | **Blocked adds one row only when `construction_blocked` is true** | | | | **Marked for demolition adds one row only when `marked_for_demolition` is true** | | | | A BUILT building with no flags and no task has exactly **one** row (its status) | | | each assignee's id, when `task != null` | `NONE_TEXT` when the task has no assignees. With no task there is no workers row at all | The flag rule is the one real change. **`YES_TEXT` and `NO_TEXT` go away**: an unset flag is noise, so it gets no row. `IDLE_TEXT` and `NONE_TEXT` stay as constants. The tests read them by name, so their wording can change freely. ### Panel tests These are unchanged in intent. They compare the rendered rows with the builder's own output, or check that a fact appears, so none of them fixes any wording. There is one new test: `test_a_building_with_only_the_manager_injected_still_renders`, which guards the `_task_for` null case found in review.
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
#78 Select a pawn or building on the map
liamjd/UntitledColonyBuilder
Reference
liamjd/UntitledColonyBuilder#142
No description provided.