Chop/mine designation gate + reachability gates on Doctor & Eat

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) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-15 14:53:50 +01:00
parent 922f269a6c
commit a4163ba222
9 changed files with 80 additions and 10 deletions

View file

@ -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. - **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. - 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 ## External references
- **Forgejo repo:** https://git.rdx4.com/megaproxy/rimlike (private) - **Forgejo repo:** https://git.rdx4.com/megaproxy/rimlike (private)

View file

@ -34,6 +34,10 @@ func find_best_for(pawn) -> Job:
for tree in World.trees: for tree in World.trees:
if not tree.is_choppable(): if not tree.is_choppable():
continue 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): if Job.is_target_taken_by_other(tree, pawn):
continue continue
var d: int = abs(tree.tile.x - pawn.tile.x) + abs(tree.tile.y - pawn.tile.y) var d: int = abs(tree.tile.x - pawn.tile.x) + abs(tree.tile.y - pawn.tile.y)

View file

@ -65,6 +65,10 @@ func find_best_for(pawn) -> Job:
continue continue
if Job.is_target_taken_by_other(p, pawn): if Job.is_target_taken_by_other(p, pawn):
continue 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) var d: int = abs(p.tile.x - pawn.tile.x) + abs(p.tile.y - pawn.tile.y)
if d < best_dist: if d < best_dist:
best_dist = d best_dist = d

View file

@ -57,6 +57,10 @@ func find_best_for(pawn) -> Job:
var fp: int = _food_priority(it.item_type) var fp: int = _food_priority(it.item_type)
if fp < 0: if fp < 0:
continue 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) 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. # Higher food-priority tier beats distance; within same tier nearest wins.
if fp > best_priority or (fp == best_priority and d < best_dist): if fp > best_priority or (fp == best_priority and d < best_dist):

View file

@ -37,6 +37,10 @@ func find_best_for(pawn) -> Job:
for rock in World.rocks: for rock in World.rocks:
if not rock.is_mineable(): if not rock.is_mineable():
continue 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): if Job.is_target_taken_by_other(rock, pawn):
continue continue
var d: int = abs(rock.tile.x - pawn.tile.x) + abs(rock.tile.y - pawn.tile.y) var d: int = abs(rock.tile.x - pawn.tile.x) + abs(rock.tile.y - pawn.tile.y)

View file

@ -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. ## 0..MINE_TICKS. Advanced by on_mine_tick(); rock is mined when equal to MINE_TICKS.
var mine_progress: int = 0 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 ───────────────────────────────────────────────────────────────── # ── lifecycle ─────────────────────────────────────────────────────────────────
@ -165,6 +168,7 @@ func to_dict() -> Dictionary:
"origin_x": origin_tile.x, "origin_x": origin_tile.x,
"origin_y": origin_tile.y, "origin_y": origin_tile.y,
"mine_progress": mine_progress, "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_x": int(d.get("origin_x", 0)),
"origin_y": int(d.get("origin_y", 0)), "origin_y": int(d.get("origin_y", 0)),
"mine_progress": int(d.get("mine_progress", 0)), "mine_progress": int(d.get("mine_progress", 0)),
"mine_designated": bool(d.get("mine_designated", false)),
} }

View file

@ -23,6 +23,9 @@ const STONE_DROPS_ON_MINE: int = 1
var tile: Vector2i = Vector2i.ZERO var tile: Vector2i = Vector2i.ZERO
## 0..MINE_TICKS. Advanced by on_mine_tick(); rock is mined when equal to MINE_TICKS. ## 0..MINE_TICKS. Advanced by on_mine_tick(); rock is mined when equal to MINE_TICKS.
var mine_progress: int = 0 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. # Preloaded scene for spawned stone items.
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn") const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
@ -130,6 +133,7 @@ func to_dict() -> Dictionary:
"tile_x": tile.x, "tile_x": tile.x,
"tile_y": tile.y, "tile_y": tile.y,
"mine_progress": mine_progress, "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_x": int(d.get("tile_x", 0)),
"tile_y": int(d.get("tile_y", 0)), "tile_y": int(d.get("tile_y", 0)),
"mine_progress": int(d.get("mine_progress", 0)), "mine_progress": int(d.get("mine_progress", 0)),
"mine_designated": bool(d.get("mine_designated", false)),
} }

View file

@ -27,6 +27,9 @@ const STACK_SIZE_PER_DROP: int = 1
var tile: Vector2i = Vector2i.ZERO var tile: Vector2i = Vector2i.ZERO
## 0..CHOP_TICKS. Advanced by on_chop_tick(); tree is felled when equal to CHOP_TICKS. ## 0..CHOP_TICKS. Advanced by on_chop_tick(); tree is felled when equal to CHOP_TICKS.
var chop_progress: int = 0 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. # Preloaded scene for spawned wood items.
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn") const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
@ -125,6 +128,7 @@ func to_dict() -> Dictionary:
"tile_x": tile.x, "tile_x": tile.x,
"tile_y": tile.y, "tile_y": tile.y,
"chop_progress": chop_progress, "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_x": int(d.get("tile_x", 0)),
"tile_y": int(d.get("tile_y", 0)), "tile_y": int(d.get("tile_y", 0)),
"chop_progress": int(d.get("chop_progress", 0)), "chop_progress": int(d.get("chop_progress", 0)),
"chop_designated": bool(d.get("chop_designated", false)),
} }

View file

@ -409,18 +409,24 @@ func _spawn_sample_harvestables() -> void:
# Untyped vars — Godot's class-name cache for class_name'd classes is # Untyped vars — Godot's class-name cache for class_name'd classes is
# scan-time and intermittently lags behind file changes. Duck typing 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. # 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: for t_tile in SAMPLE_TREES:
var tree = TREE_SCENE.instantiate() var tree = TREE_SCENE.instantiate()
add_child(tree) add_child(tree)
tree.setup(t_tile) tree.setup(t_tile)
tree.chop_designated = true
for r_tile in SAMPLE_ROCKS: for r_tile in SAMPLE_ROCKS:
var rock = ROCK_SCENE.instantiate() var rock = ROCK_SCENE.instantiate()
add_child(rock) add_child(rock)
rock.setup(r_tile) rock.setup(r_tile)
rock.mine_designated = true
for br_origin in SAMPLE_BIG_ROCKS: for br_origin in SAMPLE_BIG_ROCKS:
var big = BIG_ROCK_SCENE.instantiate() var big = BIG_ROCK_SCENE.instantiate()
add_child(big) add_child(big)
big.setup(br_origin) big.setup(br_origin)
big.mine_designated = true
Audit.log("world", "spawned %d trees + %d rocks + %d big rocks" % [ Audit.log("world", "spawned %d trees + %d rocks + %d big rocks" % [
SAMPLE_TREES.size(), SAMPLE_ROCKS.size(), SAMPLE_BIG_ROCKS.size() 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) add_child(gs)
gs.setup(cell) gs.setup(cell)
entity = gs entity = gs
# Phase 17 — chop / mine: designation ghost only; providers auto-scan # Chop / mine: flag the Tree / Rock entity at this tile so ChopProvider /
# World.trees / World.rocks and will service the nearest entity. # MineProvider treat it as work. Designation ghost on the TileMap is the
# _build_sites_by_tile tracks the ghost so cancel works. # visual cue; _build_sites_by_tile holds a sentinel so cancel can clear.
&"chop", &"mine": &"chop":
# No entity to spawn — the ghost tile on the designation layer IS the for t in World.trees:
# marker. Register a sentinel (null body) so _build_sites_by_tile can if t.tile == cell:
# clear the ghost on cancel without double-spawning. t.chop_designated = true
break
_build_sites_by_tile[cell] = null _build_sites_by_tile[cell] = null
Audit.log("world", "designation ghost '%s' at %s (no entity)" % [tool, cell]) Audit.log("world", "chop designation at %s" % cell)
return # skip the entity/site registration below 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. # Phase 17 — crate.
&"build_crate": &"build_crate":
entity = CRATE_SCENE.instantiate() entity = CRATE_SCENE.instantiate()
@ -743,8 +763,21 @@ func _on_designation_cleared(cell: Vector2i) -> void:
return return
var entity = _build_sites_by_tile[cell] var entity = _build_sites_by_tile[cell]
_build_sites_by_tile.erase(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: 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 return
if not is_instance_valid(entity): if not is_instance_valid(entity):
return return