rimlike/scenes/ai/sleep_provider.gd
megaproxy 2f76ae1639 sleep_provider deconflicts beds against other pawns' targets
L: SleepProvider.find_best_for filters bed candidates via the existing
Job.is_target_taken_by_other mechanism that ConstructionProvider
already uses. Sets j.target_node = best_bed on the proposed job so
other pawns see the claim.

Fixes the 2/3-pawns-floor-sleep symptom (memory.md 2026-05-11) caused
by greedy nearest-neighbor convergence. The bed.claim() mechanism was
already race-free; this just prevents simultaneous proposals on the
same bed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:29:19 +01:00

93 lines
3.8 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
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