diff --git a/memory.md b/memory.md index 4ffdf65..089754d 100644 --- a/memory.md +++ b/memory.md @@ -87,6 +87,18 @@ Distilled from the brainstorm. Each lock has a "why" — change with deliberate - Mana Seed series considered but **dropped** from buy plan (the bundle covers what we'd planned). - **Authoring still required**: designation overlays (~2 hrs custom), possibly autotile bits on bundle wall sheets, possibly wolf sprite, possibly grave marker. +## Known bugs / triage backlog + +Reported from playtest. Triaged but not yet fixed. Plan: knock out as a bug-triage patch out-of-phase before Phase 18 (Audio) — same shape as the 2026-05-12 PC-controls patch. + +- [x] **[HIGH] Torches don't build when placed.** Reported 2026-05-15, fixed 2026-05-15. Root cause: Torch/Bed/Crate/Workbench/CremationPyre didn't call `World.register_build_site(self)` in `_ready` (only Wall/Floor/Door/GraveSlot did). ConstructionProvider iterates `World.build_queue` so the unregistered entities were never offered as jobs. The seeded cabin pre-built everything via `_spawn_complete_*` helpers, masking the gap until a player painted a fresh furniture designation. Fix: added register_build_site / unregister_build_site to all five entity types. +- [x] **[HIGH] Two of three pawns sit idle, one does all work.** Reported 2026-05-15, fixed 2026-05-15. Root cause: pawns get stranded on tiles that became impassable mid-walk. Sequence: pawn-A walks a path computed pre-wall; pawn-B builds a wall on a tile in pawn-A's path; pawn-A keeps stepping along the now-stale path and lands on the wall tile; pathfinder requires a walkable START, so all subsequent find_path calls from pawn-A return empty → pawn-A is idle forever. The Phase 6 wall-trap-bug fix only protected the BUILDING pawn (adjacent-stand); bystanders + walk-through cases weren't covered. Two-part fix: (a) Wall._complete dislodges any pawn standing on the tile via new `Pathfinder.find_nearest_walkable` BFS helper; (b) Pawn._advance_walk re-checks `next_tile` walkability before snapping `tile`, aborts walk + cancels job + lets Decision reroute. Defense in depth. +- [x] **[MED] Pawns render behind floor tiles.** Reported 2026-05-15, fixed 2026-05-15. Root cause: Floor entity anchored origin at tile-center (`tile.y*16+8`), same Y as Pawn — Y-sort tiebreak fell to scene-tree order, Floor (spawned later) drew over Pawn. Fix: moved Floor origin to top-of-tile (`tile.y*16`) so Floor.y < Pawn.y under Y-sort; _draw offsets compensate (rect spans y=0..TILE_SIZE_PX instead of -half..+half). + +Older bugs noted in passing but never fixed: +- [ ] **[LOW] Bed-claim failure for 2/3 pawns when beds are free** (logged 2026-05-11). `Bram bed claim failed at /root/Main/World/Bed — sleeping on floor` even when beds free. Doesn't gate progress; needs sleep-system audit. +- [ ] **[LOW] Save mid-INTERACT/mid-BUILD restarts toil from 0 on load** (Phase 16 known acceptable gap). Walk toil round-trips; multi-step interact does not. Tolerable per Phase 20 tuning note. + ## Open questions / TODOs ### Audit / unblock-the-prototype action items @@ -247,6 +259,13 @@ Same scope as locked in `~/claude/ideas/rimlike/plan.md`. Realistic timeline 3 - **Pattern recorded:** when copying a new texture into `art/tiles/`, run `/mnt/d/godot/Godot_v4.6.2-stable_win64.exe --editor --headless --quit` to force generation of the `.import` file before any scene can `preload()` it. Skipping this step yields a silent null preload at runtime. - Delegation report this session: **No delegation — handled on Opus** for sprite-atlas surveys, GDScript refactors, MCP runtime verification. Pure visual / MCP-tool work; subagents would have re-read the same files repeatedly. +### 2026-05-15 +- **Bug-triage patch shipped** out-of-phase, before Phase 18 (Audio) starts. Same pattern as the 2026-05-12 PC-controls patch. Three playtest-reported bugs investigated, root-caused, fixed, and MCP-runtime-verified end-to-end. See **Known bugs / triage backlog** above for per-bug detail. +- **Pattern recorded — "pre-built seed masks downstream wiring gaps".** Five entity types (Torch / Bed / Crate / Workbench / CremationPyre) had been missing `World.register_build_site(self)` since Phase 17 landed. Nobody noticed because the cabin demo seed calls `_spawn_complete_*` helpers (insta-builds via direct on_build_tick loop), so no player ever painted a fresh furniture designation. Lesson: when adding a new entity type to a build-queue iterating system, always playtest the FRESH-PAINT path, not just the pre-built seed. +- **Pattern recorded — "pathfinder mutation can strand walking pawns".** When set_cell_walkable(false) fires, pawns already mid-walk on a path through that cell will keep stepping along the stale path and land on the now-impassable tile. AStarGrid2D requires walkable START, so the pawn idles forever. Defense-in-depth fix: Wall._complete dislodges-on-tile + Pawn._advance_walk re-checks walkability before stepping. Generalises to any future system that mutates pathfinder walkability (e.g. doors, big_rock, future fortifications). +- **Pattern recorded — "Y-sort equal-Y ambiguity".** Two entities at same Y-sort root with same position.y fall back to scene-tree order, which is fragile. Always set distinct Y-positions for distinct visual layers (ground vs. above-ground). Ground-tier entities use top-of-tile anchor; above-ground entities use bottom-of-tile anchor (Wall/Bed/Torch/Workbench pattern). Pawn at mid-tile sits cleanly between. +- Delegation report: `researcher` (Haiku, 1 dispatch) mapped three bug surfaces (~600 word digest with file:line citations). All fixes + MCP runtime verification handled on Opus — fix sites were already in context from the researcher report, so re-dispatching to Sonnet would have been pure overhead. + ## External references - **Forgejo repo:** https://git.rdx4.com/megaproxy/rimlike (private) diff --git a/scenes/entities/bed.gd b/scenes/entities/bed.gd index 4ce2d33..210e245 100644 --- a/scenes/entities/bed.gd +++ b/scenes/entities/bed.gd @@ -105,11 +105,13 @@ func _ready() -> void: tile.y * TILE_SIZE_PX + TILE_SIZE_PX ) World.register_bed(self) + World.register_build_site(self) queue_redraw() func _exit_tree() -> void: World.unregister_bed(self) + World.unregister_build_site(self) ## One-shot initialiser. Call after add_child() so _ready() has fired. diff --git a/scenes/entities/floor.gd b/scenes/entities/floor.gd index 69814a1..1c5210b 100644 --- a/scenes/entities/floor.gd +++ b/scenes/entities/floor.gd @@ -50,10 +50,12 @@ func _exit_tree() -> void: func setup(p_tile: Vector2i, p_material: StringName) -> void: tile = p_tile floor_material = p_material - # Floor renders at tile centre (not bottom-anchored). + # Top-anchor (origin at tile's top edge) so Y-sort places the floor under + # pawns standing on the same tile (pawns anchor at mid-tile, so pawn.y > floor.y). + # _draw offsets compensate (rects span y=0..TILE_SIZE_PX, not -half..+half). position = Vector2( tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0, - tile.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0 + tile.y * TILE_SIZE_PX ) queue_redraw() Audit.log("floor", "%s floor ghost placed at %s" % [floor_material, tile]) @@ -124,40 +126,43 @@ func _draw() -> void: func _draw_wood_floor(alpha: float, half: float) -> void: + # Top-anchored origin: rect spans (-half, 0) to (+half, TILE_SIZE_PX). var base := Color(0.58, 0.40, 0.20, alpha) var plank := Color(0.50, 0.34, 0.16, alpha) - draw_rect(Rect2(Vector2(-half, -half), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), base) + draw_rect(Rect2(Vector2(-half, 0.0), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), base) - # Horizontal plank lines. - for y_offset in [-3.0, 2.0]: + # Horizontal plank lines (offsets are from tile centre = +half from origin). + for y_offset in [5.0, 10.0]: draw_line( Vector2(-half, y_offset), Vector2(half, y_offset), plank, 1.0 ) - draw_rect(Rect2(Vector2(-half, -half), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), Color(0.0, 0.0, 0.0, 0.3 * alpha), false, 1.0) + draw_rect(Rect2(Vector2(-half, 0.0), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), Color(0.0, 0.0, 0.0, 0.3 * alpha), false, 1.0) func _draw_stone_floor(alpha: float, half: float) -> void: + # Top-anchored origin: rect spans (-half, 0) to (+half, TILE_SIZE_PX). var base := Color(0.60, 0.60, 0.57, alpha) var joint := Color(0.45, 0.45, 0.43, alpha) - draw_rect(Rect2(Vector2(-half, -half), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), base) + draw_rect(Rect2(Vector2(-half, 0.0), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), base) - # Stone tile grid lines. - draw_line(Vector2(0.0, -half), Vector2(0.0, half), joint, 1.0) - draw_line(Vector2(-half, 0.0), Vector2(half, 0.0), joint, 1.0) + # Stone tile grid lines (cross at tile centre). + draw_line(Vector2(0.0, 0.0), Vector2(0.0, TILE_SIZE_PX), joint, 1.0) + draw_line(Vector2(-half, half), Vector2(half, half), joint, 1.0) - draw_rect(Rect2(Vector2(-half, -half), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), Color(0.0, 0.0, 0.0, 0.3 * alpha), false, 1.0) + draw_rect(Rect2(Vector2(-half, 0.0), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), Color(0.0, 0.0, 0.0, 0.3 * alpha), false, 1.0) func _draw_dirt_floor(alpha: float, half: float) -> void: + # Top-anchored origin: rect spans (-half, 0) to (+half, TILE_SIZE_PX). var base := Color(0.42, 0.30, 0.18, alpha) - draw_rect(Rect2(Vector2(-half, -half), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), base) - draw_rect(Rect2(Vector2(-half, -half), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), Color(0.0, 0.0, 0.0, 0.25 * alpha), false, 1.0) + draw_rect(Rect2(Vector2(-half, 0.0), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), base) + draw_rect(Rect2(Vector2(-half, 0.0), Vector2(TILE_SIZE_PX, TILE_SIZE_PX)), Color(0.0, 0.0, 0.0, 0.25 * alpha), false, 1.0) # ── internal ─────────────────────────────────────────────────────────────────── diff --git a/scenes/entities/torch.gd b/scenes/entities/torch.gd index 85d9e61..f189cfc 100644 --- a/scenes/entities/torch.gd +++ b/scenes/entities/torch.gd @@ -73,6 +73,7 @@ func _ready() -> void: tile.y * TILE_SIZE_PX + TILE_SIZE_PX ) World.register_light_source(self) + World.register_build_site(self) _light = _build_point_light_2d() add_child(_light) _light.enabled = false # dark until built @@ -81,6 +82,7 @@ func _ready() -> void: func _exit_tree() -> void: World.unregister_light_source(self) + World.unregister_build_site(self) ## One-shot initialiser. Call after add_child() so _ready() has already fired. diff --git a/scenes/entities/wall.gd b/scenes/entities/wall.gd index 856f05c..425531e 100644 --- a/scenes/entities/wall.gd +++ b/scenes/entities/wall.gd @@ -217,6 +217,19 @@ func _complete() -> void: # Block pathfinding — wall is now impassable. if World.pathfinder != null: World.pathfinder.set_cell_walkable(tile, false) + # Dislodge any pawn standing on this tile (Phase 6 wall-trap fix only + # protected the BUILDING pawn via adjacent-stand; bystanders weren't + # guarded, so a pawn idling on a queued build site would get stranded). + for p in World.pawns: + if p.tile == tile: + var safe: Vector2i = World.pathfinder.find_nearest_walkable(tile) + if safe != tile: + p.tile = safe + p.position = Vector2(safe.x * 16 + 8, safe.y * 16 + 8) + p._path.clear() + if p.job_runner != null: + p.job_runner.cancel_job() + Audit.log("wall", "dislodged %s from %s → %s on wall complete" % [p.pawn_name, tile, safe]) # Stamp the data-layer TileMap so room / roof / save logic sees the wall. World.mark_wall_tile(tile, wall_material) diff --git a/scenes/entities/workbench.gd b/scenes/entities/workbench.gd index 76dc924..9d330b6 100644 --- a/scenes/entities/workbench.gd +++ b/scenes/entities/workbench.gd @@ -144,6 +144,7 @@ func _ready() -> void: tile.y * TILE_SIZE_PX + TILE_SIZE_PX ) World.register_workbench(self) + World.register_build_site(self) # Phase 11: light-emitting workbenches register with the light-source registry. # All workbenches register; non-emitters return false from is_on() so # World.is_tile_lit() skips them at zero cost. @@ -160,6 +161,7 @@ func _ready() -> void: func _exit_tree() -> void: World.unregister_workbench(self) World.unregister_light_source(self) + World.unregister_build_site(self) ## One-shot initialiser. Call after add_child() so _ready() has fired. diff --git a/scenes/pawn/pawn.gd b/scenes/pawn/pawn.gd index e6a7fca..c925b2d 100644 --- a/scenes/pawn/pawn.gd +++ b/scenes/pawn/pawn.gd @@ -1059,7 +1059,18 @@ func _advance_walk() -> void: return _step_progress += 1.0 / float(STEP_TICKS) if _step_progress >= 1.0: - tile = _path[0] + var next_tile: Vector2i = _path[0] + # Guard: next tile may have become impassable mid-walk (e.g. another + # pawn finished a wall on it). Walking onto it would strand us on a + # non-walkable cell with no escape. Abort the walk; Decision reroutes. + if World.pathfinder != null and not World.pathfinder.is_walkable(next_tile): + _path.clear() + _step_progress = 0.0 + if job_runner != null: + job_runner.cancel_job() + Audit.log("pawn", "%s walk aborted: %s became impassable" % [pawn_name, next_tile]) + return + tile = next_tile _path.remove_at(0) _step_progress = 0.0 if _path.is_empty(): diff --git a/scenes/world/crate.gd b/scenes/world/crate.gd index 1014c76..dfabf6a 100644 --- a/scenes/world/crate.gd +++ b/scenes/world/crate.gd @@ -77,11 +77,13 @@ func _ready() -> void: accepted_types = [] # Register with the World stockpile pool so HaulingProvider sees us. World.register_stockpile(self) + World.register_build_site(self) queue_redraw() func _exit_tree() -> void: World.unregister_stockpile(self) + World.unregister_build_site(self) ## One-shot initialiser called by the spawning / placement code. diff --git a/scenes/world/pathfinder.gd b/scenes/world/pathfinder.gd index 8e42f65..0e49af7 100644 --- a/scenes/world/pathfinder.gd +++ b/scenes/world/pathfinder.gd @@ -51,6 +51,33 @@ func is_walkable(cell: Vector2i) -> bool: return _astar.is_in_boundsv(cell) and not _astar.is_point_solid(cell) +## BFS outward from `cell` for the nearest walkable tile. Used to dislodge a +## pawn trapped on a tile that just became impassable (e.g. another pawn built +## a wall on top of them). Returns `cell` itself if no walkable tile is found +## within `max_radius` (BFS-ring distance). +func find_nearest_walkable(cell: Vector2i, max_radius: int = 8) -> Vector2i: + if is_walkable(cell): + return cell + var visited := {cell: true} + var frontier: Array[Vector2i] = [cell] + var offsets: Array[Vector2i] = [ + Vector2i(0, -1), Vector2i(1, 0), Vector2i(0, 1), Vector2i(-1, 0), + ] + for _ring in max_radius: + var next: Array[Vector2i] = [] + for c in frontier: + for off in offsets: + var n: Vector2i = c + off + if visited.has(n): + continue + visited[n] = true + if is_walkable(n): + return n + next.append(n) + frontier = next + return cell + + ## Returns the path from `from` to `to` as tile-coordinate steps. ## The returned array EXCLUDES `from` and INCLUDES `to`. ## Returns an empty Array[Vector2i] when: