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>
98 lines
4.1 KiB
GDScript
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
|