ConstructionTask: placing a building enqueues construction #109

Merged
liamjd merged 1 commit from 70-construction-task into main 2026-09-18 13:22:22 +01:00
Owner

Closes #70.

Buildings.place() now creates pawn-raised buildings as PLANNED and enqueues a ConstructionTask targeting the building's entrance cell.

Decisions taken (the issue asked for two of these to be noted here)

  • Completion signal is GameState.building_status_changed(building, old_status), not building_built. #71 has to re-render on PLANNED -> UNDER_CONSTRUCTION as well as on completion, which a completion-only signal cannot carry. Buildings.set_status() is the only place it is emitted.
  • ctx is a typed TaskContext, not the Dictionary #66 left as a TODO. The registries a task may touch are few and run-scoped, and a Dictionary cost var b: Buildings = ctx["buildings"] in every hook of every task type — with key typos failing at runtime in on_cancel(), the branch least likely to be exercised by hand. It carries buildings today; #75 adds the map for reachability.
  • Cancel owns removal, not the other way round. on_cancel() calls buildings.remove(); remove() does not cancel the task back, so the two cannot recurse. #72's demolish UI enters through cancel().
  • work() does not complete itself when work_remaining reaches zero. The manager owns completion, because that is where the status transition, the parent re-check and the archiving live — a task calling on_complete() directly would apply its effects and still be sitting in the queue.
  • Buildings.tasks is public, matching map. Both are injected by game.gd, and an injected collaborator behind a leading underscore is one the injector has to reach through.

Entrances, finally wired

BuildingFootprint.doorstep() has existed since #55 but nothing ever called it, so Building.entrance was still Vector2i.ZERO. It is now anchor + doorstep(rotation_steps) — a grid cell like cells(), not an anchor-relative offset like offsets(). The doorstep is deliberately excluded from the footprint's cells, which is what makes it a legal cell for a builder to stand in; a test pins that it never enters _occupancy.

build_days == 0.0 means "not raised by pawns" — the Colony Centre lands already built — so that branch places BUILT and enqueues nothing.

Verification

481 passing, 0 failing, 1 pending across 482 tests. The one pending is the pre-existing #85 marker in test_task_tree.gd, untouched here.

test/unit/test_construction_task.gd adds 19 tests, written before the implementation and red for the right reason first:

  • entrance is a grid cell, rotates with the footprint, stays unoccupied
  • one ConstructionTask enqueued, PENDING, targeting the entrance, holding building_id
  • work_remaining from GameCalendar.days_to_ticks() — asserted against the calendar rather than build_work_ticks(), so a skipped conversion fails rather than agreeing with itself
  • build_days == 0.0 lands BUILT with an empty queue
  • a registry with no TaskManager still places, and stays quiet doing it
  • first work() breaks ground; effort decrements; never goes below zero
  • complete() raises the building and announces the transition
  • cancel removes the record, frees the cells, and does not recurse
  • PLANNED buildings count in count_of() and trip at_limit()

Also fixed the stale assertion message in test_buildings.gd that still referenced "#54's follow-up", and the required_roll typo on Task.

Not done here

  • Nothing drives the task yet. #75's pawn work loop is what will claim it, walk to the entrance and call work(). Until then the task is only exercised by tests; the tests use a _take_up() helper to move it through claim -> start because COMPLETE is unreachable from PENDING by design.
  • No visual verification. PLANNED and UNDER_CONSTRUCTION buildings still render identically to BUILT — that is #71. Nothing in this branch changes what is drawn, so the headed screenshot pass has nothing to show.
  • next_for() still calls task._can_start() directly and re-checks children inline, duplicating the can_start() wrapper on Task. Pre-existing, left alone to keep this branch to #70; worth a follow-up issue against #67.

🤖 Generated with Claude Code

Closes #70. `Buildings.place()` now creates pawn-raised buildings as `PLANNED` and enqueues a `ConstructionTask` targeting the building's entrance cell. ## Decisions taken (the issue asked for two of these to be noted here) - **Completion signal is `GameState.building_status_changed(building, old_status)`**, not `building_built`. #71 has to re-render on `PLANNED -> UNDER_CONSTRUCTION` as well as on completion, which a completion-only signal cannot carry. `Buildings.set_status()` is the only place it is emitted. - **`ctx` is a typed `TaskContext`**, not the `Dictionary` #66 left as a TODO. The registries a task may touch are few and run-scoped, and a Dictionary cost `var b: Buildings = ctx["buildings"]` in every hook of every task type — with key typos failing at runtime in `on_cancel()`, the branch least likely to be exercised by hand. It carries `buildings` today; #75 adds the map for reachability. - **Cancel owns removal, not the other way round.** `on_cancel()` calls `buildings.remove()`; `remove()` does not cancel the task back, so the two cannot recurse. #72's demolish UI enters through `cancel()`. - **`work()` does not complete itself** when `work_remaining` reaches zero. The manager owns completion, because that is where the status transition, the parent re-check and the archiving live — a task calling `on_complete()` directly would apply its effects and still be sitting in the queue. - **`Buildings.tasks` is public**, matching `map`. Both are injected by `game.gd`, and an injected collaborator behind a leading underscore is one the injector has to reach through. ## Entrances, finally wired `BuildingFootprint.doorstep()` has existed since #55 but nothing ever called it, so `Building.entrance` was still `Vector2i.ZERO`. It is now `anchor + doorstep(rotation_steps)` — a grid cell like `cells()`, not an anchor-relative offset like `offsets()`. The doorstep is deliberately excluded from the footprint's cells, which is what makes it a legal cell for a builder to stand in; a test pins that it never enters `_occupancy`. `build_days == 0.0` means "not raised by pawns" — the Colony Centre lands already built — so that branch places `BUILT` and enqueues nothing. ## Verification `481 passing, 0 failing, 1 pending` across 482 tests. The one pending is the pre-existing #85 marker in `test_task_tree.gd`, untouched here. `test/unit/test_construction_task.gd` adds 19 tests, written before the implementation and red for the right reason first: - entrance is a grid cell, rotates with the footprint, stays unoccupied - one `ConstructionTask` enqueued, PENDING, targeting the entrance, holding `building_id` - `work_remaining` from `GameCalendar.days_to_ticks()` — asserted against the calendar rather than `build_work_ticks()`, so a skipped conversion fails rather than agreeing with itself - `build_days == 0.0` lands BUILT with an empty queue - a registry with no `TaskManager` still places, and stays quiet doing it - first `work()` breaks ground; effort decrements; never goes below zero - `complete()` raises the building and announces the transition - cancel removes the record, frees the cells, and does not recurse - PLANNED buildings count in `count_of()` and trip `at_limit()` Also fixed the stale assertion message in `test_buildings.gd` that still referenced "#54's follow-up", and the `required_roll` typo on `Task`. ## Not done here - **Nothing drives the task yet.** #75's pawn work loop is what will claim it, walk to the entrance and call `work()`. Until then the task is only exercised by tests; the tests use a `_take_up()` helper to move it through `claim -> start` because `COMPLETE` is unreachable from `PENDING` by design. - **No visual verification.** PLANNED and UNDER_CONSTRUCTION buildings still render identically to BUILT — that is #71. Nothing in this branch changes what is drawn, so the headed screenshot pass has nothing to show. - `next_for()` still calls `task._can_start()` directly and re-checks children inline, duplicating the `can_start()` wrapper on `Task`. Pre-existing, left alone to keep this branch to #70; worth a follow-up issue against #67. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Buildings.place() now creates pawn-raised buildings as PLANNED and enqueues a
ConstructionTask targeting the building's entrance, closing #70.

Entrances are wired up at last. BuildingFootprint.doorstep() has existed since
#55 but nothing called it, so Building.entrance was still Vector2i.ZERO. It is
now anchor + doorstep(rotation_steps) - a grid cell, like cells(), not an
anchor-relative offset like offsets(). The doorstep is deliberately excluded
from the footprint's cells, which is what makes it a legal cell for a builder
to stand in.

build_days of 0.0 means "not raised by pawns" - the Colony Centre lands already
built - so that branch places BUILT and enqueues nothing.

Task context is a typed TaskContext rather than the Dictionary #66 left as a
TODO. The registries are few and run-scoped, and a Dictionary would have cost
`var b: Buildings = ctx["buildings"]` in every hook of every task type, with
key typos failing at runtime in the branches least likely to be exercised.

Buildings gains set_status(), the single place building_status_changed is
emitted. The signal carries the old status as well as the record: #71 has to
re-render on PLANNED -> UNDER_CONSTRUCTION as well as on completion, which a
completion-only signal cannot express.

ConstructionTask.work() flips the building to UNDER_CONSTRUCTION on its first
tick, reading the record's own status rather than keeping a separate bool - one
less thing a save file has to hold in step. It deliberately does not complete
itself at zero work: the manager owns completion, because that is where the
status transition, the parent re-check and the archiving live.

on_cancel() removes the building record. remove() does not cancel the task back,
so the two cannot recurse; #72's demolish UI enters through cancel().

TaskManager is injected into Buildings by game.gd, alongside map, and both are
optional - a registry with neither still places records, which is what keeps it
standalone in tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
liamjd merged commit e7553f4498 into main 2026-09-18 13:22:22 +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!109
No description provided.