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