Render PLANNED, BLOCKED and UNDER_CONSTRUCTION buildings distinctly (#71) #121

Merged
liamjd merged 4 commits from 71-render-planned-building-states into main 2026-09-20 20:24:14 +01:00
Owner

Closes #71.

A placed-but-unbuilt building now looks like a plan rather than a finished structure, and a
stalled one says so.

What it does

BuildingView is a new class of pure static functions - no nodes, no scene, no map. It owns
the whole decision table:

View state Reached by Drawn as
PLANNED Building.Status.PLANNED translucent, pale blue tint
BLOCKED either unbuilt status + construction_blocked translucent, red tint
UNDER_CONSTRUCTION Building.Status.UNDER_CONSTRUCTION translucent, olive tint, opacity tracking progress
BUILT Building.Status.BUILT (and RUINED, for now) no override at all - the mesh's own baked material

Buildings refreshes the view from the record on every status, blocked or progress change,
touching only material_override and cast_shadow - never the mesh, which
BuildingMeshes shares between every building of a type.

Blocking arrives from the task queue: TaskManager gained task_blocked / task_unblocked,
emitted from next_for() only when a task's status actually crosses into or out of BLOCKED.
Buildings.tasks is now a property whose setter makes the connection, so a registry built
standalone in a test wires itself the same way game.gd does and there is no second place to
forget it.

Design decisions

BLOCKED is deliberately not a Building.Status. It is a Task.Status, and making it a
building one would deadlock: ConstructionTask._can_start() gates on
status in [PLANNED, UNDER_CONSTRUCTION], so a building parked in a building-level BLOCKED
could never start work again and therefore could never leave that state. Hence a separate
view-state enum derived from the record plus one transient flag.

construction_blocked is a cache, not truth, and is not part of the save format. It is
checked only for the two unbuilt states, so a stale true cannot paint a finished building
red; a reload recomputes it the moment a pawn next asks for work.

Opacity tracks progress rather than a flat scaffold colour - the ticket allowed either.
It costs Building.build_progress and one write per worked tick. Exactly one unit test
asserts the fade, so reverting to a flat colour is one deletion plus one line in
material_for().

The reason a task is blocked is out of scope - that is #119, which names this PR's view
path as its hook. It widens the bool to a reason enum and will need to teach state_for()
that a task waiting on #73's material children must not read as blocked. Building the hook
as a bool first means #119 changes one function rather than introducing the path.

Two bugs found on the way

ConstructionTask reported (build_days / work_remaining) * 100.0. Inverted, in the
wrong unit (build_days is pawn-days, work_remaining is ticks), scaled to a percentage
where an 0..1 fraction was wanted - and dividing by zero on the last tick of every
construction, since work_remaining reaches exactly 0.0 there. Now
1.0 - work_remaining / build_work_ticks(), which moves the zero case to the total, where it
means "not pawn-raised at all" and a building needing no work is already built.

PLANNED's tint was white, which made BLOCKED unreadable. White lifts the red channel as
much as Color.RED does, so the two states stopped being distinguishable by the exact channel
meant to separate them. It is now the placement ghost's pale blue, which also makes "not real
yet" read consistently between the ghost and the plan it becomes.

Verification

Unit tests: 599 passing, 0 failing. The one Risky/Pending is
test_what_a_cancelled_child_should_do_to_its_parent_is_undecided, which predates this branch
and is waiting on #85.

Written spec-first, per the usual split: the tests landed red in a5cd18d before any
implementation existed.

  • test/unit/test_building_view.gd (new, 22 tests) - the decision table, including that a
    stale blocked flag is ignored on a finished building, that each call returns its own
    material, and that apply() never writes to the shared mesh.
  • test/unit/test_buildings.gd (+14) - the wiring: a status change reaches the mesh without
    rebuilding it, two buildings of one type are tinted independently, and a blocked task marks
    and unmarks its building.
  • test/unit/test_construction_task.gd (+7) - progress reporting, including the
    divide-by-zero above.

Visual check: headed, tools/screenshot_building_states.gd. Draws five of one type in a
row - PLANNED, BLOCKED, UNDER_CONSTRUCTION at 25% and 75%, BUILT - driving each through the
registry's own setters, so a correct picture is evidence the whole path works rather than
just that BuildingView can produce a material.

"$GODOT" --path . -s tools/screenshot_building_states.gd

That check earned its keep: the alpha floor moved from 0.2 to 0.35 because a plan at 0.2
all but vanished against pale sand, while BLOCKED at the same alpha still read - red carries
against that background and pale blue does not. Not something the headless suite could ever
have reported. In the final image only BUILT casts a shadow, which is the other half of what
the shot is checking.

Caveats and things left undone

  • A blocked building only turns red when a pawn next asks for work. next_for() is the
    only thing that re-evaluates a task, so with every pawn busy the view can lag arbitrarily.
    This is pre-existing - Task.status and the debug panel have exactly the same lag - and is
    not made worse here. Fixing it means a re-evaluation sweep on a timer; worth its own issue
    if it ever bites.
  • RUINED renders as BUILT, flagged with a TODO. Nothing sets that status yet.
  • No wireframe or hologram shader. Translucency gets there; a real shader is a
    look-and-feel pass.
  • tools/screenshot_building_states.gd refers to every project class as Node/Object
    through load(), per the -s compile-order trap in CLAUDE.md - Buildings reaches
    GameState and BuildingType reaches GameCalendar, so one type annotation would break
    both silently and produce a plausible wrong picture. Reasoning is in the file header.
  • Noticed but not fixed here: tools/screenshot_buildings.gd names BuildingType
    directly and so is likely broken in exactly that way since build_work_ticks() landed in
    #88. Untouched, since it is unrelated to this change.
  • One commit on this branch reverts a stray two-backslash typo in GameDate's docstring that
    was sitting in the working tree and got swept in by a git add -A.

🤖 Generated with Claude Code

Closes #71. A placed-but-unbuilt building now looks like a plan rather than a finished structure, and a stalled one says so. ## What it does `BuildingView` is a new class of pure static functions - no nodes, no scene, no map. It owns the whole decision table: | View state | Reached by | Drawn as | | --- | --- | --- | | PLANNED | `Building.Status.PLANNED` | translucent, pale blue tint | | BLOCKED | either unbuilt status + `construction_blocked` | translucent, red tint | | UNDER_CONSTRUCTION | `Building.Status.UNDER_CONSTRUCTION` | translucent, olive tint, opacity tracking progress | | BUILT | `Building.Status.BUILT` (and RUINED, for now) | no override at all - the mesh's own baked material | `Buildings` refreshes the view from the record on every status, blocked or progress change, touching only `material_override` and `cast_shadow` - never the mesh, which `BuildingMeshes` shares between every building of a type. Blocking arrives from the task queue: `TaskManager` gained `task_blocked` / `task_unblocked`, emitted from `next_for()` only when a task's status actually crosses into or out of BLOCKED. `Buildings.tasks` is now a property whose setter makes the connection, so a registry built standalone in a test wires itself the same way `game.gd` does and there is no second place to forget it. ## Design decisions **BLOCKED is deliberately not a `Building.Status`.** It is a `Task.Status`, and making it a building one would deadlock: `ConstructionTask._can_start()` gates on `status in [PLANNED, UNDER_CONSTRUCTION]`, so a building parked in a building-level BLOCKED could never start work again and therefore could never leave that state. Hence a separate view-state enum derived from the record plus one transient flag. **`construction_blocked` is a cache, not truth, and is not part of the save format.** It is checked only for the two unbuilt states, so a stale `true` cannot paint a finished building red; a reload recomputes it the moment a pawn next asks for work. **Opacity tracks progress rather than a flat scaffold colour** - the ticket allowed either. It costs `Building.build_progress` and one write per worked tick. Exactly one unit test asserts the fade, so reverting to a flat colour is one deletion plus one line in `material_for()`. **The reason a task is blocked is out of scope** - that is #119, which names this PR's view path as its hook. It widens the bool to a reason enum and will need to teach `state_for()` that a task waiting on #73's material children must *not* read as blocked. Building the hook as a bool first means #119 changes one function rather than introducing the path. ## Two bugs found on the way **`ConstructionTask` reported `(build_days / work_remaining) * 100.0`.** Inverted, in the wrong unit (`build_days` is pawn-days, `work_remaining` is ticks), scaled to a percentage where an 0..1 fraction was wanted - and dividing by zero on the last tick of *every* construction, since `work_remaining` reaches exactly `0.0` there. Now `1.0 - work_remaining / build_work_ticks()`, which moves the zero case to the total, where it means "not pawn-raised at all" and a building needing no work is already built. **PLANNED's tint was white, which made BLOCKED unreadable.** White lifts the red channel as much as `Color.RED` does, so the two states stopped being distinguishable by the exact channel meant to separate them. It is now the placement ghost's pale blue, which also makes "not real yet" read consistently between the ghost and the plan it becomes. ## Verification **Unit tests: 599 passing, 0 failing.** The one Risky/Pending is `test_what_a_cancelled_child_should_do_to_its_parent_is_undecided`, which predates this branch and is waiting on #85. Written spec-first, per the usual split: the tests landed red in a5cd18d before any implementation existed. - `test/unit/test_building_view.gd` (new, 22 tests) - the decision table, including that a stale blocked flag is ignored on a finished building, that each call returns its own material, and that `apply()` never writes to the shared mesh. - `test/unit/test_buildings.gd` (+14) - the wiring: a status change reaches the mesh without rebuilding it, two buildings of one type are tinted independently, and a blocked task marks and unmarks its building. - `test/unit/test_construction_task.gd` (+7) - progress reporting, including the divide-by-zero above. **Visual check: headed, `tools/screenshot_building_states.gd`.** Draws five of one type in a row - PLANNED, BLOCKED, UNDER_CONSTRUCTION at 25% and 75%, BUILT - driving each through the registry's own setters, so a correct picture is evidence the whole path works rather than just that `BuildingView` can produce a material. ```sh "$GODOT" --path . -s tools/screenshot_building_states.gd ``` That check earned its keep: **the alpha floor moved from 0.2 to 0.35** because a plan at 0.2 all but vanished against pale sand, while BLOCKED at the same alpha still read - red carries against that background and pale blue does not. Not something the headless suite could ever have reported. In the final image only BUILT casts a shadow, which is the other half of what the shot is checking. ## Caveats and things left undone - **A blocked building only turns red when a pawn next asks for work.** `next_for()` is the only thing that re-evaluates a task, so with every pawn busy the view can lag arbitrarily. This is pre-existing - `Task.status` and the debug panel have exactly the same lag - and is not made worse here. Fixing it means a re-evaluation sweep on a timer; worth its own issue if it ever bites. - **RUINED renders as BUILT**, flagged with a TODO. Nothing sets that status yet. - **No wireframe or hologram shader.** Translucency gets there; a real shader is a look-and-feel pass. - `tools/screenshot_building_states.gd` refers to every project class as `Node`/`Object` through `load()`, per the `-s` compile-order trap in CLAUDE.md - `Buildings` reaches `GameState` and `BuildingType` reaches `GameCalendar`, so one type annotation would break both silently and produce a plausible wrong picture. Reasoning is in the file header. - Noticed but **not fixed here**: `tools/screenshot_buildings.gd` names `BuildingType` directly and so is likely broken in exactly that way since `build_work_ticks()` landed in #88. Untouched, since it is unrelated to this change. - One commit on this branch reverts a stray two-backslash typo in `GameDate`'s docstring that was sitting in the working tree and got swept in by a `git add -A`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Tests first, so they are red until the implementation lands.

test_building_view.gd is new: the decision table as pure static functions,
which is the only part of this the headless suite can see at all - under
gl_compatibility it renders nothing, so the look itself stays a headed
screenshot.

test_buildings.gd gains the registry wiring - that a status change reaches
the mesh without rebuilding it, and that two buildings sharing one Mesh do
not share one material_override.

test_construction_task.gd gains build_progress reporting, including the
divide-by-zero a type with build_days of 0.0 would otherwise produce.

21 new failures, 557 passing; no pre-existing test changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add build progress to Buildings. Use this to drive the alpha for the construction progress.
Stub/no-op for blocked construction - unit tests still fail.
BuildingView.state_for() now consults construction_blocked, checked after
the status match and only for PLANNED and UNDER_CONSTRUCTION - the flag is
a cache of a task's status, and the task is gone by the time the building
is BUILT, so a stale true must not paint a finished building red.

Buildings.set_construction_blocked() writes the record and redraws rather
than redrawing an unchanged one; next_for() re-evaluates every task on
every request, so the unchanged-value guard is the common path, not an
edge case. The tasks setter now tolerates null and disconnects a previous
manager before connecting a new one.

ConstructionTask reported (build_days / work_remaining) * 100, which was
inverted, in the wrong unit, and divided by zero on the last tick of every
construction - work_remaining reaches exactly 0.0 there. Now
1.0 - work_remaining / build_work_ticks(), with the zero case moved to the
total, where it means "not pawn-raised at all".

PLANNED's tint moves from white to the placement ghost's pale blue: white
lifts red as much as the blocked tint does, so the two states stopped
being distinguishable by the channel meant to tell them apart.

Verified headed - tools/screenshot_building_states.gd draws all five
states in a row. That check moved the alpha floor from 0.2 to 0.35: a plan
at 0.2 all but vanished against pale sand, while BLOCKED still read,
because red carries against that background and pale blue does not.

Full suite: 599 passing, 0 failing, 1 pre-existing pending (#85).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two backslashes that had landed in "a date is the picture you take of the
result". Unrelated to #71; it was sitting in the working tree and got
swept into the previous commit by a git add -A.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
liamjd merged commit b300629c19 into main 2026-09-20 20:24:14 +01:00
Sign in to join this conversation.
No reviewers
No milestone
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.

Dependencies

No dependencies set.

Reference
liamjd/UntitledColonyBuilder!121
No description provided.