Left-clicking a valid site places the building #54

Closed
opened 2026-08-30 19:24:52 +01:00 by liamjd · 0 comments
Owner

Left-clicking a valid site commits the building: a solid mesh is spawned on the terrain in the type's colour, the cells it occupies are recorded so nothing else can be built on them, and placement mode ends (ghost gone, highlight gone, panel card un-toggled).

This is the last open acceptance criterion of the #1 milestone's core flow — #33 → #39 → #37 → #44 all end at a ghost that never becomes a building. It is also the issue that introduces the first piece of persistent colony state, so the data structure it picks is load-bearing for construction, jobs, haulage and saves later on. That, not the click handling, is the interesting part.

Scope

In:

  • A record type for a placed building, and a registry that owns them and the cell→building occupancy map.
  • An OCCUPIED placement rule.
  • Left-click to commit, gated on the placement being valid.
  • A solid mesh spawned at the placed site, in the type's colour, correctly rotated.
  • Placement mode ends after a successful placement.

Out (each wants its own issue if it isn't already one):

  • Demolition / removal. The registry should be capable of it (see below) but no UI or input for it here.
  • Construction time and the PLANNED → UNDER_CONSTRUCTION → BUILT progression. Buildings land as BUILT.
  • Enforcing BuildingType.unique — a second colony centre is currently placeable. The field exists and the milestone lists Uniqueness, but it is a separate rule with its own tests; file it as a follow-up.
  • Save/load. The record just has to be serialisable (see "Store the inputs, not the outputs").
  • Fixing the water/height bug — that is #46, and it affects the placed mesh in exactly the same way it already affects the ghost. Do not fix it here; adopt drawn_level_at() when #46 lands.

Data model

Split the record from the view. A Building is data; the MeshInstance3D is one way of drawing it. Anything that later asks "what is on this tile", "which buildings need power", "what is in this warehouse" wants the record and not a node, and the node should be reconstructible from the record alone.

class_name Building
extends RefCounted

enum Status { PLANNED, UNDER_CONSTRUCTION, BUILT, RUINED }

var id: int                     # registry-assigned, stable, non-reused
var type: BuildingType          # shared resource, never mutated
var anchor: Vector2i            # the cell under the cursor at placement
var rotation_steps: int         # 0..3, as PlacementController counts them
var status: Status = Status.BUILT

RefCounted rather than Resource: it is not authored in the editor, not shared, and does not want a uid://. It is also not a Node — nothing about a building record needs the tree, and RefCounted keeps the registry tests free of autofree() bookkeeping (CLAUDE.md: Nodes in tests need autofree(), RefCounted must never be freed by hand).

Store the inputs, not the outputs

anchor + rotation_steps + type fully determine the occupied cells, because BuildingFootprint.rotated() is pure. So derive the cells rather than storing them as the source of truth:

func cells() -> Array[Vector2i]:
    var out: Array[Vector2i] = []
    for offset: Vector2i in type.footprint.rotated(rotation_steps):
        out.append(anchor + offset)
    return out

The registry's occupancy dictionary is then a cache of that derivation, which is fine, rather than a second copy of the truth that can disagree with it. This is also what makes the record trivially serialisable later: three scalars and a type.id string, no cell arrays to keep in sync.

The registry

class_name Buildings
extends Node3D

var _buildings: Dictionary = {}   # id -> Building
var _occupancy: Dictionary = {}   # Vector2i -> id
var _next_id: int = 1

func place(type: BuildingType, anchor: Vector2i, rotation_steps: int) -> Building
func remove(id: int) -> void
func building_at(cell: Vector2i) -> Building     # null when empty
func is_occupied(cell: Vector2i) -> bool
func occupied_cells() -> Dictionary              # for PlacementRules
func all() -> Array[Building]

A Node3D sibling of Map in game.tscn, so it can parent the spawned meshes and inherit the map's transform, and so PlacementController reaches it the same way it already reaches the map: @onready var buildings: Buildings = $"../Buildings". Do not parent placed buildings to PlacementController — that node owns the transient ghost and gets cleared on cancel.

remove() is in the API even with no caller, because it is the thing that proves the occupancy map is maintained in one place rather than smeared across place(). A registry that can only add is one where the second developer to touch it invents their own removal path.

Dictionary-as-set for _occupancy follows the existing house pattern (BuildingFootprint._has_disjoined_cells()); GDScript still has no Set.

The OCCUPIED rule

PlacementRules.evaluate() gains a reason and a parameter:

enum Reason { VALID, OUT_OF_BOUNDS, WATER, TOO_STEEP, MISSING_RESOURCE, OCCUPIED, ERROR }

static func evaluate(map: TerrainGrid, anchor: Vector2i, cells: Array[Vector2i],
        max_level_spread: int, occupied: Dictionary = {}) -> Reason

Pass the dictionary, not the registry. PlacementRules is a static, dependency-free pure function today and test_placement_rules.gd leans on that hard; taking a Buildings node would drag a Node3D into every one of those tests for no gain. A default of {} also keeps every existing call site and test compiling unchanged.

Append OCCUPIED to the end of the enum rather than inserting it in the middle — the values are ordinals and anything serialised or compared numerically shifts under you otherwise.

Check order: after OUT_OF_BOUNDS, before WATER. Bounds first because an out-of-range cell is not a meaningful lookup; occupancy before terrain because "there's already a mine there" is the more useful message than "that tile is steep", and a tile with a building on it is by definition one you already found flat enough.

In PlacementController, add PlacementRules.Reason.OCCUPIED to the INVALID_TINT arm of the match.

Committing the placement

Use an input action, not a raw button

#50 moved keybindings into the Input Map (debug panel excepted) and the right-click cancel in PlacementController._unhandled_input() is a leftover raw MOUSE_BUTTON_RIGHT test. Add a place_building action bound to the left mouse button, use it here, and add the row to the keybindings panel. Converting the existing right-click cancel to a cancel_placement action at the same time is welcome but optional — note that it deliberately implements click-with-no-drag (a 4px threshold so a camera drag doesn't cancel), which an action alone does not give you.

Don't re-raycast on the click

_physics_process() already raycasts, computes the anchor, rotates the footprint and evaluates the rule, every frame. The click handler must use that answer, not compute a second one — two independent evaluations one frame apart is how you get a building placed on a site the player saw rejected.

The existing _anchor_cache / _reason_cache fields are not safe to reuse for this: they exist to skip _rebuild_highlight() and are only written when something changed, so after any of the three early returns (no type, GUI hovered, ray missed) they hold whatever was true before. Add explicit per-frame state that every path writes:

var _hover_anchor: Vector2i = Vector2i.ZERO
var _hover_valid: bool = false     # set false on EVERY early return

Then the handler is just: if _hover_valid, call buildings.place(selected_type, _hover_anchor, _rotation_steps), then cancel_placement(), then get_viewport().set_input_as_handled().

Cancel through the public path

Call cancel_placement(), not _cancel_placement(). The public one emits building_placement_canceled, which is what un-toggles the card in BuildingPanel._cancel_placement(). Using the private one leaves the panel button stuck pressed with no ghost — the same class of desync as bug #43.

(Ending placement mode after one building is what this issue asks for, and it matches the milestone's "the cursor will be cleared". It does make placing a row of ten mines ten round trips to the panel; if that grates in play, file a follow-up for a sticky / shift-to-repeat mode rather than widening this one.)

Announce it

Add to GameState, alongside the existing placement signals:

signal building_placed(building: Building)

Nothing consumes it yet. It is the hook for a building count in the debug overlay, and for resource deduction and job creation later, and adding it now costs a line.

Rendering the placed building

Reuse the ghost's geometry and positioning wholesale — extract the position calculation out of _physics_process() into a shared helper and have the ghost call it too, with mesh_hover_height as a parameter that placement passes 0.0. If the two compute the site independently they will drift, and the bug shows up as a building that doesn't land where the ghost promised.

The maths as it stands:

  • Centre in X/Z from footprint.calculate_bounds(cells): (anchor + bounds.position + bounds.size * 0.5) * tile_size.
  • Y from the highest cell under the footprint: max(surface_y(level_at(cell))), minus aabb.position.y to lift the mesh so its base sits on that surface rather than its centre.
  • rotation.y = -deg_to_rad(90.0) * rotation_steps — Godot's +Y rotation is counter-clockwise viewed from above, hence the negation.

Differences from the ghost, all of them deliberate:

  • No material_override. BuildingMeshes.build() already bakes placeholder_color onto PrimitiveMesh.material via _placeholder_material(), so letting the mesh's own material through is how the building gets "its colour as defined in the building type". Setting an override is how you accidentally place a translucent ghost-tinted building.
  • cast_shadow left on. The ghost explicitly disables it; a real building should cast.
  • Call BuildingMeshes.build(type, map.tile_size, map.level_height) with the same arguments the ghost uses, so #41's cache (keyed on exactly those three) can return one shared mesh for the ghost and every instance of that type.

Things that will bite

  • The spawned mesh has no collider, and that is correct. TerrainGrid._build_mesh_library() gives the terrain box colliders; a bare MeshInstance3D has none. So the placement raycast passes straight through a placed building and still hits the ground underneath, and the ghost keeps drawing over it. Rejecting that site is entirely the OCCUPIED rule's job — there is no physics fallback quietly covering for you. (When buildings later need to be clickable for selection, that is a collider added on purpose, not a fix for this.)
  • TerrainGrid.world_to_cell() clamps to the grid, so dragging the cursor off the map still yields an in-bounds anchor and a plausible-looking placement near the edge. The footprint's own in_bounds() check catches the actual overhang; don't add a second guard on the anchor and assume it did.
  • Rotation is only reset via the cancel path — _rotation_steps = 0 lives in _cancel_placement(). Another reason the click handler ends in cancel_placement().
  • level_at() is the sampled level, not the drawn one (#46). On a shoreline the placed mesh sinks exactly as the ghost does. Expected for now; switching both to drawn_level_at() is #46's job.
  • footprint.rotated() returns a copy — cheap, but if Building.cells() ends up called per frame by anything, cache at the call site rather than making the record stateful.
  • Regenerating the terrain does not clear the registry. TerrainGrid.reroll() / next_shape() would leave every placed building floating over a new map. Either clear on the regenerated signal or record it as a known debug-only wart — but decide, don't discover.

Tests

test/unit/test_placement_rules.gd — extend, following the file's existing real-TerrainGrid-with-flat-amplitude pattern:

  • A footprint over an occupied cell returns OCCUPIED.
  • A footprint overlapping the occupied set in exactly one cell returns OCCUPIED (partial overlap is the case naive implementations miss).
  • Adjacent-but-not-overlapping returns VALID.
  • Omitting the occupied argument entirely behaves as before.
  • An occupied cell that is also out of bounds returns OUT_OF_BOUNDS, pinning the check order.

test/unit/test_buildings.gd — new, one script per subject:

  • place() returns a Building with a fresh id and marks every rotated cell occupied.
  • occupied_cells() for a rotated non-rectangular footprint (five_thick_c or one_by_three_L) covers exactly the shape's cells and not its bounding box — the same bounds-vs-cells trap L_SHAPE pins in the rules tests.
  • building_at() returns the same instance for every cell of a multi-tile building, and null elsewhere.
  • remove() frees the cells and a subsequent place() on them succeeds.
  • Ids are not reused after a remove().
  • The registry Node3D goes in with add_child_autofree(); Building and BuildingType are RefCounted / Resource and must not be freed.

Rendering stays unverified by the suite (gl_compatibility draws nothing headless) — the mesh colour, rotation and seating need a headed screenshot check, and that check is worth doing on the five_thick_c warehouse at all four rotations, since an asymmetric footprint is where the centring maths shows its errors.

Acceptance criteria

  • Left-clicking a site the ghost shows as green places a building there; clicking a site shown in any other colour does nothing.
  • The placed building is opaque, sits on the ground (not floating at mesh_hover_height), is in placeholder_color, and is rotated to match the ghost at the moment of the click.
  • After placing, the ghost and the tile highlight are gone, selected_type is null, rotation is reset, and the building panel's card is un-toggled.
  • A second building cannot be placed on any cell of the first; hovering there shows the invalid tint.
  • Placement is driven by a place_building input action and listed in the keybindings panel.
  • Clicking on the building panel itself never places a building.
  • GameState.building_placed fires once per placement.
  • The GUT suite passes, including the new test_buildings.gd and the extended test_placement_rules.gd.
Left-clicking a valid site commits the building: a solid mesh is spawned on the terrain in the type's colour, the cells it occupies are recorded so nothing else can be built on them, and placement mode ends (ghost gone, highlight gone, panel card un-toggled). This is the last open acceptance criterion of the #1 milestone's core flow — #33 → #39 → #37 → #44 all end at a ghost that never becomes a building. It is also the issue that introduces the **first piece of persistent colony state**, so the data structure it picks is load-bearing for construction, jobs, haulage and saves later on. That, not the click handling, is the interesting part. ## Scope In: - A record type for a placed building, and a registry that owns them and the cell→building occupancy map. - An `OCCUPIED` placement rule. - Left-click to commit, gated on the placement being valid. - A solid mesh spawned at the placed site, in the type's colour, correctly rotated. - Placement mode ends after a successful placement. Out (each wants its own issue if it isn't already one): - Demolition / removal. The registry should be *capable* of it (see below) but no UI or input for it here. - Construction time and the `PLANNED` → `UNDER_CONSTRUCTION` → `BUILT` progression. Buildings land as `BUILT`. - Enforcing `BuildingType.unique` — a second colony centre is currently placeable. The field exists and the milestone lists Uniqueness, but it is a separate rule with its own tests; file it as a follow-up. - Save/load. The record just has to *be* serialisable (see "Store the inputs, not the outputs"). - Fixing the water/height bug — that is #46, and it affects the placed mesh in exactly the same way it already affects the ghost. Do not fix it here; adopt `drawn_level_at()` when #46 lands. ## Data model Split the **record** from the **view**. A `Building` is data; the `MeshInstance3D` is one way of drawing it. Anything that later asks "what is on this tile", "which buildings need power", "what is in this warehouse" wants the record and not a node, and the node should be reconstructible from the record alone. ```gdscript class_name Building extends RefCounted enum Status { PLANNED, UNDER_CONSTRUCTION, BUILT, RUINED } var id: int # registry-assigned, stable, non-reused var type: BuildingType # shared resource, never mutated var anchor: Vector2i # the cell under the cursor at placement var rotation_steps: int # 0..3, as PlacementController counts them var status: Status = Status.BUILT ``` `RefCounted` rather than `Resource`: it is not authored in the editor, not shared, and does not want a `uid://`. It is also not a `Node` — nothing about a building record needs the tree, and `RefCounted` keeps the registry tests free of `autofree()` bookkeeping (CLAUDE.md: `Node`s in tests need `autofree()`, `RefCounted` must never be freed by hand). ### Store the inputs, not the outputs `anchor` + `rotation_steps` + `type` fully determine the occupied cells, because `BuildingFootprint.rotated()` is pure. So **derive** the cells rather than storing them as the source of truth: ```gdscript func cells() -> Array[Vector2i]: var out: Array[Vector2i] = [] for offset: Vector2i in type.footprint.rotated(rotation_steps): out.append(anchor + offset) return out ``` The registry's occupancy dictionary is then a *cache* of that derivation, which is fine, rather than a second copy of the truth that can disagree with it. This is also what makes the record trivially serialisable later: three scalars and a `type.id` string, no cell arrays to keep in sync. ### The registry ```gdscript class_name Buildings extends Node3D var _buildings: Dictionary = {} # id -> Building var _occupancy: Dictionary = {} # Vector2i -> id var _next_id: int = 1 func place(type: BuildingType, anchor: Vector2i, rotation_steps: int) -> Building func remove(id: int) -> void func building_at(cell: Vector2i) -> Building # null when empty func is_occupied(cell: Vector2i) -> bool func occupied_cells() -> Dictionary # for PlacementRules func all() -> Array[Building] ``` A `Node3D` sibling of `Map` in `game.tscn`, so it can parent the spawned meshes and inherit the map's transform, and so `PlacementController` reaches it the same way it already reaches the map: `@onready var buildings: Buildings = $"../Buildings"`. Do **not** parent placed buildings to `PlacementController` — that node owns the transient ghost and gets cleared on cancel. `remove()` is in the API even with no caller, because it is the thing that proves the occupancy map is maintained in one place rather than smeared across `place()`. A registry that can only add is one where the second developer to touch it invents their own removal path. Dictionary-as-set for `_occupancy` follows the existing house pattern (`BuildingFootprint._has_disjoined_cells()`); GDScript still has no `Set`. ## The OCCUPIED rule `PlacementRules.evaluate()` gains a reason and a parameter: ```gdscript enum Reason { VALID, OUT_OF_BOUNDS, WATER, TOO_STEEP, MISSING_RESOURCE, OCCUPIED, ERROR } static func evaluate(map: TerrainGrid, anchor: Vector2i, cells: Array[Vector2i], max_level_spread: int, occupied: Dictionary = {}) -> Reason ``` Pass the **dictionary**, not the registry. `PlacementRules` is a static, dependency-free pure function today and `test_placement_rules.gd` leans on that hard; taking a `Buildings` node would drag a `Node3D` into every one of those tests for no gain. A default of `{}` also keeps every existing call site and test compiling unchanged. Append `OCCUPIED` to the end of the enum rather than inserting it in the middle — the values are ordinals and anything serialised or compared numerically shifts under you otherwise. Check order: after `OUT_OF_BOUNDS`, before `WATER`. Bounds first because an out-of-range cell is not a meaningful lookup; occupancy before terrain because "there's already a mine there" is the more useful message than "that tile is steep", and a tile with a building on it is by definition one you already found flat enough. In `PlacementController`, add `PlacementRules.Reason.OCCUPIED` to the `INVALID_TINT` arm of the match. ## Committing the placement ### Use an input action, not a raw button #50 moved keybindings into the Input Map (debug panel excepted) and the right-click cancel in `PlacementController._unhandled_input()` is a leftover raw `MOUSE_BUTTON_RIGHT` test. Add a `place_building` action bound to the left mouse button, use it here, and add the row to the keybindings panel. Converting the existing right-click cancel to a `cancel_placement` action at the same time is welcome but optional — note that it deliberately implements click-with-no-drag (a 4px threshold so a camera drag doesn't cancel), which an action alone does not give you. ### Don't re-raycast on the click `_physics_process()` already raycasts, computes the anchor, rotates the footprint and evaluates the rule, every frame. The click handler must use *that* answer, not compute a second one — two independent evaluations one frame apart is how you get a building placed on a site the player saw rejected. The existing `_anchor_cache` / `_reason_cache` fields are **not** safe to reuse for this: they exist to skip `_rebuild_highlight()` and are only written when something changed, so after any of the three early returns (no type, GUI hovered, ray missed) they hold whatever was true before. Add explicit per-frame state that every path writes: ```gdscript var _hover_anchor: Vector2i = Vector2i.ZERO var _hover_valid: bool = false # set false on EVERY early return ``` Then the handler is just: if `_hover_valid`, call `buildings.place(selected_type, _hover_anchor, _rotation_steps)`, then `cancel_placement()`, then `get_viewport().set_input_as_handled()`. ### Cancel through the public path Call `cancel_placement()`, not `_cancel_placement()`. The public one emits `building_placement_canceled`, which is what un-toggles the card in `BuildingPanel._cancel_placement()`. Using the private one leaves the panel button stuck pressed with no ghost — the same class of desync as bug #43. (Ending placement mode after one building is what this issue asks for, and it matches the milestone's "the cursor will be cleared". It does make placing a row of ten mines ten round trips to the panel; if that grates in play, file a follow-up for a sticky / shift-to-repeat mode rather than widening this one.) ### Announce it Add to `GameState`, alongside the existing placement signals: ```gdscript signal building_placed(building: Building) ``` Nothing consumes it yet. It is the hook for a building count in the debug overlay, and for resource deduction and job creation later, and adding it now costs a line. ## Rendering the placed building Reuse the ghost's geometry and positioning wholesale — extract the position calculation out of `_physics_process()` into a shared helper and have the ghost call it too, with `mesh_hover_height` as a parameter that placement passes `0.0`. If the two compute the site independently they will drift, and the bug shows up as a building that doesn't land where the ghost promised. The maths as it stands: - Centre in X/Z from `footprint.calculate_bounds(cells)`: `(anchor + bounds.position + bounds.size * 0.5) * tile_size`. - Y from the **highest** cell under the footprint: `max(surface_y(level_at(cell)))`, minus `aabb.position.y` to lift the mesh so its base sits on that surface rather than its centre. - `rotation.y = -deg_to_rad(90.0) * rotation_steps` — Godot's +Y rotation is counter-clockwise viewed from above, hence the negation. Differences from the ghost, all of them deliberate: - **No `material_override`.** `BuildingMeshes.build()` already bakes `placeholder_color` onto `PrimitiveMesh.material` via `_placeholder_material()`, so letting the mesh's own material through *is* how the building gets "its colour as defined in the building type". Setting an override is how you accidentally place a translucent ghost-tinted building. - **`cast_shadow` left on.** The ghost explicitly disables it; a real building should cast. - Call `BuildingMeshes.build(type, map.tile_size, map.level_height)` with the same arguments the ghost uses, so #41's cache (keyed on exactly those three) can return one shared mesh for the ghost and every instance of that type. ## Things that will bite - **The spawned mesh has no collider, and that is correct.** `TerrainGrid._build_mesh_library()` gives the terrain box colliders; a bare `MeshInstance3D` has none. So the placement raycast passes straight through a placed building and still hits the ground underneath, and the ghost keeps drawing over it. Rejecting that site is entirely the `OCCUPIED` rule's job — there is no physics fallback quietly covering for you. (When buildings later need to be clickable for selection, that is a collider added on purpose, not a fix for this.) - **`TerrainGrid.world_to_cell()` clamps to the grid**, so dragging the cursor off the map still yields an in-bounds anchor and a plausible-looking placement near the edge. The footprint's own `in_bounds()` check catches the actual overhang; don't add a second guard on the anchor and assume it did. - **Rotation is only reset via the cancel path** — `_rotation_steps = 0` lives in `_cancel_placement()`. Another reason the click handler ends in `cancel_placement()`. - **`level_at()` is the sampled level, not the drawn one** (#46). On a shoreline the placed mesh sinks exactly as the ghost does. Expected for now; switching both to `drawn_level_at()` is #46's job. - **`footprint.rotated()` returns a copy** — cheap, but if `Building.cells()` ends up called per frame by anything, cache at the call site rather than making the record stateful. - **Regenerating the terrain does not clear the registry.** `TerrainGrid.reroll()` / `next_shape()` would leave every placed building floating over a new map. Either clear on the `regenerated` signal or record it as a known debug-only wart — but decide, don't discover. ## Tests `test/unit/test_placement_rules.gd` — extend, following the file's existing real-`TerrainGrid`-with-flat-amplitude pattern: - A footprint over an occupied cell returns `OCCUPIED`. - A footprint overlapping the occupied set in exactly one cell returns `OCCUPIED` (partial overlap is the case naive implementations miss). - Adjacent-but-not-overlapping returns `VALID`. - Omitting the `occupied` argument entirely behaves as before. - An occupied cell that is *also* out of bounds returns `OUT_OF_BOUNDS`, pinning the check order. `test/unit/test_buildings.gd` — new, one script per subject: - `place()` returns a `Building` with a fresh id and marks every rotated cell occupied. - `occupied_cells()` for a rotated non-rectangular footprint (`five_thick_c` or `one_by_three_L`) covers exactly the shape's cells and not its bounding box — the same bounds-vs-cells trap `L_SHAPE` pins in the rules tests. - `building_at()` returns the same instance for every cell of a multi-tile building, and `null` elsewhere. - `remove()` frees the cells and a subsequent `place()` on them succeeds. - Ids are not reused after a `remove()`. - The registry `Node3D` goes in with `add_child_autofree()`; `Building` and `BuildingType` are `RefCounted` / `Resource` and must not be freed. Rendering stays unverified by the suite (`gl_compatibility` draws nothing headless) — the mesh colour, rotation and seating need a headed screenshot check, and that check is worth doing on the `five_thick_c` warehouse at all four rotations, since an asymmetric footprint is where the centring maths shows its errors. ## Acceptance criteria - Left-clicking a site the ghost shows as green places a building there; clicking a site shown in any other colour does nothing. - The placed building is opaque, sits on the ground (not floating at `mesh_hover_height`), is in `placeholder_color`, and is rotated to match the ghost at the moment of the click. - After placing, the ghost and the tile highlight are gone, `selected_type` is null, rotation is reset, and the building panel's card is un-toggled. - A second building cannot be placed on any cell of the first; hovering there shows the invalid tint. - Placement is driven by a `place_building` input action and listed in the keybindings panel. - Clicking on the building panel itself never places a building. - `GameState.building_placed` fires once per placement. - The GUT suite passes, including the new `test_buildings.gd` and the extended `test_placement_rules.gd`.
liamjd changed title from Placing a building on the terrace should place it there to Left-clicking a valid site places the building 2026-08-30 19:49:33 +01:00
liamjd added reference 54-building-placement-and-model 2026-08-31 21:40:18 +01:00
Sign in to join this conversation.
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#54
No description provided.