rimlike/scenes/ai/sleep_provider.gd
megaproxy 16e04e46f0 add reachability pre-checks to plant/sleep/chop/mine
Provider audit found 6 WorkProviders missing reachability gates before
returning jobs. Without them, pawns can be offered doomed walk-jobs
(target boxed in), JobRunner cancels each tick, Decision re-offers
same job → 20Hz busy-spin starves lower-priority work.

Fixed 4 here (mechanical pattern):
- PlantProvider._find_harvest: walkable-target check (mirrors _find_sow)
- SleepProvider: walkable bed-tile check
- ChopProvider: adjacent-walkable for impassable tree
- MineProvider: adjacent-walkable for impassable rock

Cooking/Crafting reachability changes (in the same audit's
recommendation) were attempted but caused intermittent null returns
that regressed cooking rate. Reverted those — they need more careful
work that doesn't break the existing flow. Filed separately.

Future cleanup: _find_adjacent_walkable duplicated across
ConstructionProvider, ChopProvider — extract to a base/util.

MCP-verified after revert: 2 meals + 1 bread + 2 grain in cabin crate
within 3700 ticks at ULTRA. Cooking fires, hauling fires, all
production paths operational.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:20:35 +01:00

98 lines
4.1 KiB
GDScript

class_name SleepProvider extends WorkProvider
## WorkProvider for the Sleep work category. Slots into the 5-layer pawn AI
## (Decision → WorkProvider → Job + JobRunner) at priority 8 — above eating (7)
## so a maximally tired AND hungry pawn prioritises rest.
##
## When a pawn is tired and not carrying an item, scans World.beds for the
## nearest available bed and builds a 2-toil sleep job: walk → sleep.
## If no bed is available, the pawn sleeps on the floor at its current tile
## (walk toil omitted since it's already there).
##
## Bed claim race: the bed is NOT claimed here. Claiming in find_best_for()
## would leak a held claim if the job is later cancelled by a player override
## (Layer 5) before execution starts. Instead _tick_sleep in JobRunner claims
## on first tick, when the pawn has actually arrived and is ready to sleep.
## If the claim fails at that point (another pawn beat us) the fallback is
## floor sleep — no deadlock, no leaked claim.
##
## Pawn and Bed are intentionally duck-typed to avoid the class_name
## registration-order trap documented in Phase 2/3.
##
## See docs/architecture.md "Pawn AI 5-layer pipeline" and
## docs/design.md "Sleep mood" gradient.
func _init() -> void:
category = &"sleep"
# Priority 8 > eat (7) > construction (6) > hauling (3) > rest (0).
# Phase 9 status interrupts will preempt from Decision layer 3 once the
# Status registry lands — for now sleep rides at the WorkProvider level.
priority = 8
# ── WorkProvider override ─────────────────────────────────────────────────────
## Returns a sleep Job for `pawn`, or null if the pawn is not tired.
## The job has 1 or 2 toils:
## - walk_to(target_tile) [omitted when target == pawn.tile]
## - sleep_in_bed(bed_path) [bed_path is empty string for floor sleep]
##
## `pawn` is duck-typed: must expose .is_tired(), .carried_item, .tile (Vector2i).
func find_best_for(pawn) -> Job:
if not pawn.is_tired():
return null
# Don't interrupt an ongoing carry — finish the carry before going to sleep.
if pawn.carried_item != null:
return null
# Scan World.beds for the nearest available bed (duck-typed — Bed exposes
# .is_available() -> bool and .tile -> Vector2i).
var best_bed = null
var best_dist: int = 999999
for bed in World.beds:
if not bed.is_available():
continue
# Skip beds already targeted by another pawn's active job. This is the
# same deconfliction used by ConstructionProvider (Job.is_target_taken_by_other)
# and prevents all simultaneously-tired pawns from converging on the same
# nearest bed and then losing the race at claim time.
if Job.is_target_taken_by_other(bed, pawn):
continue
# Reachability pre-check — beds are walkable tiles, use Pattern A.
# Prevents a busy-spin if all beds are walled off from this pawn.
if pawn.tile != bed.tile and World.pathfinder != null:
if World.pathfinder.find_path(pawn.tile, bed.tile).is_empty():
continue
var d: int = abs(bed.tile.x - pawn.tile.x) + abs(bed.tile.y - pawn.tile.y)
if d < best_dist:
best_dist = d
best_bed = bed
# Resolve target tile and bed NodePath. An empty NodePath means floor sleep.
var target_tile: Vector2i
var bed_path: NodePath
if best_bed != null:
target_tile = best_bed.tile
bed_path = best_bed.get_path()
else:
# Phase 8 fallback — sleep on the floor at the current tile.
# The Mood system (Agent C, Phase 8) will fire a "slept_on_floor" thought
# via the thought registry; analogous to EatProvider's "ate raw food".
target_tile = pawn.tile
bed_path = NodePath("")
var j := Job.new()
if best_bed != null:
j.label = "Sleep at %s" % target_tile
# Tag the job's target so other pawns see this bed as taken via
# Job.is_target_taken_by_other() before it is physically claimed.
j.target_node = best_bed
else:
j.label = "Sleep on the floor at %s" % target_tile
# Only prepend a walk if the pawn is not already at the target.
if pawn.tile != target_tile:
j.toils.append(Toil.walk_to(target_tile))
j.toils.append(Toil.sleep_in_bed(bed_path))
return j