From a4163ba222e30a066d7193ff7480d7ae2a7cfbd1 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 15 May 2026 14:53:50 +0100 Subject: [PATCH] Chop/mine designation gate + reachability gates on Doctor & Eat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player reported pawns ignoring chop designations. Root cause: ChopProvider/MineProvider iterated World.trees/World.rocks unconditionally — paint set a null sentinel and never touched the entity, so designation was cosmetic only. Pawns auto-chopped nearest unfelled tree. * Added chop_designated: bool to Tree, mine_designated: bool to Rock and BigRock (footprint-aware: paint on any of the 4 footprint cells flags the boulder). Save/load round-trips the flag. * world.gd._on_designation_added 'chop'/'mine' cases now find the entity at the painted tile and flip the flag. _on_designation_cleared inverts. * Boot seed auto-designates SAMPLE_TREES / SAMPLE_ROCKS / SAMPLE_BIG_ROCKS so the cabin demo still produces wood + stone end-to-end without requiring the player to paint first. Also from the same audit (researcher mapped all 11 WorkProviders): * DoctorProvider + EatProvider now pre-check reachability with pathfinder.find_path before issuing a job, mirroring HaulingProvider's pattern. Previously they handed out doomed walks that JobRunner had to cancel, busy-spinning at 20 Hz. Verified end-to-end via MCP runtime: undesignated tree/rock returns null from provider; paint flips the flag and provider returns a chop/mine job; un-paint clears the flag; BigRock footprint paint works on any of the 4 cells. Co-Authored-By: Claude Opus 4.7 (1M context) --- memory.md | 6 ++++ scenes/ai/chop_provider.gd | 4 +++ scenes/ai/doctor_provider.gd | 4 +++ scenes/ai/eat_provider.gd | 4 +++ scenes/ai/mine_provider.gd | 4 +++ scenes/entities/big_rock.gd | 5 ++++ scenes/entities/rock.gd | 5 ++++ scenes/entities/tree.gd | 5 ++++ scenes/world/world.gd | 53 +++++++++++++++++++++++++++++------- 9 files changed, 80 insertions(+), 10 deletions(-) diff --git a/memory.md b/memory.md index 089754d..ba8dace 100644 --- a/memory.md +++ b/memory.md @@ -266,6 +266,12 @@ Same scope as locked in `~/claude/ideas/rimlike/plan.md`. Realistic timeline 3 - **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. +- **Job system audit + designation-gate fix shipped same day** in the same bug-triage patch session. Player report: "I set some trees to be cut but no one is doing it". Root cause via `researcher` (Haiku, 1 dispatch, ~900 word digest mapping all 11 WorkProviders): ChopProvider and MineProvider iterated `World.trees` / `World.rocks` unconditionally — `is_choppable()` / `is_mineable()` only check progress, not designation. The `&"chop"` and `&"mine"` cases in `world.gd._on_designation_added` stashed a `null` sentinel and never touched the entity, so paint was purely cosmetic. Pawns were auto-chopping the nearest unfelled tree regardless of what the player painted. +- **Fix**: added `chop_designated: bool` to Tree, `mine_designated: bool` to Rock + BigRock (BigRock flag covers entire 2×2 footprint via `footprint_tiles()` membership check), gated both providers, wired designation paint/cancel to flip the flag, auto-designated SAMPLE_TREES / SAMPLE_ROCKS / SAMPLE_BIG_ROCKS at boot so the demo still runs end-to-end. Rimworld parity restored — pawns no longer auto-chop / auto-mine. +- **Also from same audit**: added reachability pre-checks (mirroring HaulingProvider) to DoctorProvider and EatProvider — they were issuing jobs to unreachable targets, then relying on JobRunner walk-cancel to clean up. The pre-check avoids the busy-spin between Decision and JobRunner. +- **Pattern recorded — "iteration-source bugs hide behind boot-seed shortcuts".** The Phase 4 seed `SAMPLE_TREES` / `SAMPLE_ROCKS` was always being chopped/mined because providers auto-picked. Player only noticed when they painted designations and the behavior didn't change. Boot seeds that mirror "default OK" state can mask gate logic; when adding any new gate (designation, skill, capability), playtest the FRESH state, not just the seeded state. +- **Other audit findings (deferred, not fixed this session)**: CraftingProvider has an ingredient re-scan race (ingredient may disappear between lines 73 and 91); PlantProvider only handles harvest (sow stubbed for Phase 17); RestProvider is a Phase 3 smoke-test leftover that could be deprecated. All low priority. + ## External references - **Forgejo repo:** https://git.rdx4.com/megaproxy/rimlike (private) diff --git a/scenes/ai/chop_provider.gd b/scenes/ai/chop_provider.gd index 003193a..9f9766c 100644 --- a/scenes/ai/chop_provider.gd +++ b/scenes/ai/chop_provider.gd @@ -34,6 +34,10 @@ func find_best_for(pawn) -> Job: for tree in World.trees: if not tree.is_choppable(): continue + # Gate on player designation — pawns don't auto-chop undesignated trees. + # Boot seed in world.gd auto-designates SAMPLE_TREES so the demo still runs. + if not tree.chop_designated: + continue if Job.is_target_taken_by_other(tree, pawn): continue var d: int = abs(tree.tile.x - pawn.tile.x) + abs(tree.tile.y - pawn.tile.y) diff --git a/scenes/ai/doctor_provider.gd b/scenes/ai/doctor_provider.gd index 63af166..f12f2d9 100644 --- a/scenes/ai/doctor_provider.gd +++ b/scenes/ai/doctor_provider.gd @@ -65,6 +65,10 @@ func find_best_for(pawn) -> Job: continue if Job.is_target_taken_by_other(p, pawn): continue + # Reachability — same pattern as HaulingProvider. Skip patients we + # can't path to instead of issuing a doomed walk that JobRunner cancels. + if pawn.tile != p.tile and World.pathfinder.find_path(pawn.tile, p.tile).is_empty(): + continue var d: int = abs(p.tile.x - pawn.tile.x) + abs(p.tile.y - pawn.tile.y) if d < best_dist: best_dist = d diff --git a/scenes/ai/eat_provider.gd b/scenes/ai/eat_provider.gd index 7bd809f..ef4d49d 100644 --- a/scenes/ai/eat_provider.gd +++ b/scenes/ai/eat_provider.gd @@ -57,6 +57,10 @@ func find_best_for(pawn) -> Job: var fp: int = _food_priority(it.item_type) if fp < 0: continue + # Reachability — same pattern as HaulingProvider. Skip food we can't + # path to instead of returning a doomed walk job. + if pawn.tile != it.tile and World.pathfinder.find_path(pawn.tile, it.tile).is_empty(): + continue var d: int = abs(it.tile.x - pawn.tile.x) + abs(it.tile.y - pawn.tile.y) # Higher food-priority tier beats distance; within same tier nearest wins. if fp > best_priority or (fp == best_priority and d < best_dist): diff --git a/scenes/ai/mine_provider.gd b/scenes/ai/mine_provider.gd index 6209c1e..f6c08ce 100644 --- a/scenes/ai/mine_provider.gd +++ b/scenes/ai/mine_provider.gd @@ -37,6 +37,10 @@ func find_best_for(pawn) -> Job: for rock in World.rocks: if not rock.is_mineable(): continue + # Gate on player designation — pawns don't auto-mine undesignated rocks. + # Boot seed in world.gd auto-designates SAMPLE_ROCKS so the demo still runs. + if not rock.mine_designated: + continue if Job.is_target_taken_by_other(rock, pawn): continue var d: int = abs(rock.tile.x - pawn.tile.x) + abs(rock.tile.y - pawn.tile.y) diff --git a/scenes/entities/big_rock.gd b/scenes/entities/big_rock.gd index ea0748b..55c1ce9 100644 --- a/scenes/entities/big_rock.gd +++ b/scenes/entities/big_rock.gd @@ -50,6 +50,9 @@ var origin_tile: Vector2i = Vector2i.ZERO ## 0..MINE_TICKS. Advanced by on_mine_tick(); rock is mined when equal to MINE_TICKS. var mine_progress: int = 0 +## True once a player has painted a mine designation on any footprint tile. +## MineProvider ignores undesignated boulders (Rimworld parity). +var mine_designated: bool = false # ── lifecycle ───────────────────────────────────────────────────────────────── @@ -165,6 +168,7 @@ func to_dict() -> Dictionary: "origin_x": origin_tile.x, "origin_y": origin_tile.y, "mine_progress": mine_progress, + "mine_designated": mine_designated, } @@ -173,6 +177,7 @@ static func from_dict(d: Dictionary) -> Dictionary: "origin_x": int(d.get("origin_x", 0)), "origin_y": int(d.get("origin_y", 0)), "mine_progress": int(d.get("mine_progress", 0)), + "mine_designated": bool(d.get("mine_designated", false)), } diff --git a/scenes/entities/rock.gd b/scenes/entities/rock.gd index 59b82d7..d01e13d 100644 --- a/scenes/entities/rock.gd +++ b/scenes/entities/rock.gd @@ -23,6 +23,9 @@ const STONE_DROPS_ON_MINE: int = 1 var tile: Vector2i = Vector2i.ZERO ## 0..MINE_TICKS. Advanced by on_mine_tick(); rock is mined when equal to MINE_TICKS. var mine_progress: int = 0 +## True once a player has painted a mine designation on this rock. MineProvider +## ignores undesignated rocks (Rimworld parity — pawns don't auto-mine). +var mine_designated: bool = false # Preloaded scene for spawned stone items. const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn") @@ -130,6 +133,7 @@ func to_dict() -> Dictionary: "tile_x": tile.x, "tile_y": tile.y, "mine_progress": mine_progress, + "mine_designated": mine_designated, } @@ -138,6 +142,7 @@ static func from_dict(d: Dictionary) -> Dictionary: "tile_x": int(d.get("tile_x", 0)), "tile_y": int(d.get("tile_y", 0)), "mine_progress": int(d.get("mine_progress", 0)), + "mine_designated": bool(d.get("mine_designated", false)), } diff --git a/scenes/entities/tree.gd b/scenes/entities/tree.gd index e9259d8..27f084d 100644 --- a/scenes/entities/tree.gd +++ b/scenes/entities/tree.gd @@ -27,6 +27,9 @@ const STACK_SIZE_PER_DROP: int = 1 var tile: Vector2i = Vector2i.ZERO ## 0..CHOP_TICKS. Advanced by on_chop_tick(); tree is felled when equal to CHOP_TICKS. var chop_progress: int = 0 +## True once a player has painted a chop designation on this tree. ChopProvider +## ignores undesignated trees (Rimworld parity — pawns don't auto-chop). +var chop_designated: bool = false # Preloaded scene for spawned wood items. const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn") @@ -125,6 +128,7 @@ func to_dict() -> Dictionary: "tile_x": tile.x, "tile_y": tile.y, "chop_progress": chop_progress, + "chop_designated": chop_designated, } @@ -133,6 +137,7 @@ static func from_dict(d: Dictionary) -> Dictionary: "tile_x": int(d.get("tile_x", 0)), "tile_y": int(d.get("tile_y", 0)), "chop_progress": int(d.get("chop_progress", 0)), + "chop_designated": bool(d.get("chop_designated", false)), } diff --git a/scenes/world/world.gd b/scenes/world/world.gd index c4fa98b..09a5f86 100644 --- a/scenes/world/world.gd +++ b/scenes/world/world.gd @@ -409,18 +409,24 @@ func _spawn_sample_harvestables() -> void: # Untyped vars — Godot's class-name cache for class_name'd classes is # scan-time and intermittently lags behind file changes. Duck typing is # safer here and the calls below are all spec'd on the entity types. + # Boot seed auto-designates so the production-chain demo runs end-to-end + # without requiring a player to paint chop/mine first. Real player-painted + # trees / rocks still gate on chop_designated / mine_designated (Rimworld parity). for t_tile in SAMPLE_TREES: var tree = TREE_SCENE.instantiate() add_child(tree) tree.setup(t_tile) + tree.chop_designated = true for r_tile in SAMPLE_ROCKS: var rock = ROCK_SCENE.instantiate() add_child(rock) rock.setup(r_tile) + rock.mine_designated = true for br_origin in SAMPLE_BIG_ROCKS: var big = BIG_ROCK_SCENE.instantiate() add_child(big) big.setup(br_origin) + big.mine_designated = true Audit.log("world", "spawned %d trees + %d rocks + %d big rocks" % [ SAMPLE_TREES.size(), SAMPLE_ROCKS.size(), SAMPLE_BIG_ROCKS.size() ]) @@ -674,16 +680,30 @@ func _on_designation_added(cell: Vector2i, tool: StringName) -> void: add_child(gs) gs.setup(cell) entity = gs - # Phase 17 — chop / mine: designation ghost only; providers auto-scan - # World.trees / World.rocks and will service the nearest entity. - # _build_sites_by_tile tracks the ghost so cancel works. - &"chop", &"mine": - # No entity to spawn — the ghost tile on the designation layer IS the - # marker. Register a sentinel (null body) so _build_sites_by_tile can - # clear the ghost on cancel without double-spawning. + # Chop / mine: flag the Tree / Rock entity at this tile so ChopProvider / + # MineProvider treat it as work. Designation ghost on the TileMap is the + # visual cue; _build_sites_by_tile holds a sentinel so cancel can clear. + &"chop": + for t in World.trees: + if t.tile == cell: + t.chop_designated = true + break _build_sites_by_tile[cell] = null - Audit.log("world", "designation ghost '%s' at %s (no entity)" % [tool, cell]) - return # skip the entity/site registration below + Audit.log("world", "chop designation at %s" % cell) + return + &"mine": + for r in World.rocks: + # BigRock occupies a 2×2 footprint; flag if cell is any of them. + if r.has_method("footprint_tiles"): + if cell in r.footprint_tiles(): + r.mine_designated = true + break + elif r.tile == cell: + r.mine_designated = true + break + _build_sites_by_tile[cell] = null + Audit.log("world", "mine designation at %s" % cell) + return # Phase 17 — crate. &"build_crate": entity = CRATE_SCENE.instantiate() @@ -743,8 +763,21 @@ func _on_designation_cleared(cell: Vector2i) -> void: return var entity = _build_sites_by_tile[cell] _build_sites_by_tile.erase(cell) - # Phase 17 — chop/mine designations store null as their sentinel; nothing to free. + # Phase 17 — chop/mine designations store null as their sentinel; nothing to + # free, but clear the corresponding flag on any Tree / Rock at that tile. if entity == null: + for t in World.trees: + if t.tile == cell: + t.chop_designated = false + break + for r in World.rocks: + if r.has_method("footprint_tiles"): + if cell in r.footprint_tiles(): + r.mine_designated = false + break + elif r.tile == cell: + r.mine_designated = false + break return if not is_instance_valid(entity): return