rimlike/scenes/ai/job_runner.gd
megaproxy 43e52ffe75 Phase 8 — Beds, sleep need, thoughts, mood, Sulking soft-break
Three gdscript-refactor agents in parallel; Opus integrated and verified
the sleep+wake cycle via MCP runtime.

Bed entity (Agent A, scenes/entities/bed.{gd,tscn} + world.gd, ~280 lines):
- class Bed extends Node2D — bottom-anchored 3/4 perspective like Wall/Workbench
- BuildJob interface (is_buildable / on_build_tick / _complete) — same pattern
  as Wall / Crate / Workbench. blocks_pathing_when_complete=false (walkable).
- Quality-tinted sheet colours by Item.Quality tier (drab grey → blue →
  gold-brown → regal pink); white pillow + dark frame constant across tiers.
- claim(pawn) / release() / is_available() — atomic occupancy; claim re-checks
  is_available() inside to avoid race conditions during pawn walk-to-bed.
- World.beds registry + register_bed / unregister_bed (mirrors workbench pattern)

Sleep need + SleepProvider + KIND_SLEEP toil (Agent B, ~220 lines):
- Pawn.sleep: float 0..100. SLEEP_DECAY_PER_TICK=0.015 (~6667 ticks / 5.5 min
  at 1× / 1 min at Ultra to fully tire). Slower than hunger.
- is_tired() at <30; is_exhausted() at <5 (Phase 9 status interrupt hook)
- SleepProvider priority=8 (highest — sleep beats eat=7 when both urgent)
- Toil.KIND_SLEEP + Toil.sleep_in_bed(NodePath) factory
- JobRunner._tick_sleep: first-tick bed claim (with race-loss → floor fallback),
  per-tick recovery (bed=0.5/tick, floor=0.25/tick), wake-when-full at ≥99,
  emergency ceiling SLEEP_TICKS_MAX=2000 prevents stuck-asleep loops

Thoughts + mood + Sulking (Agent C, ~290 lines):
- scenes/ai/thought.gd: class Thought (RefCounted) with id, modifier, lifetime
  (PERSISTENT/EVENT), stacks, ticks_remaining; MAX_STACKS_PER_THOUGHT=5 locked
- scenes/ai/thought_catalog.gd: ThoughtCatalog with 5 Phase 8 thoughts —
  hungry(-6, PERSISTENT) / tired(-4, PERSISTENT) / well_rested(+5, EVENT 1200t)
  / slept_on_floor(-5, EVENT 1200t) / ate_meal(+3, EVENT 800t, stacks up to 3)
- Pawn extended: thoughts: Array, mood: float (base 50), sulking: bool,
  _sulk_low_ticks. add_thought (stack-merge by id), remove_thought_by_id,
  has_thought, is_sulking. _process_thoughts in sim_tick decays EVENT thoughts,
  syncs PERSISTENT thoughts to state (hungry/tired), recomputes mood, checks
  sulking transition: mood < 25 for MOOD_SULK_SUSTAIN_TICKS=600 ticks → SULKING;
  mood >= 35 → recover.
- Decision Layer 1 extended: pawn.is_sulking() → return null (sulking pawns
  refuse all work; Phase 17 may add Wandering variant)
- EventBus.pawn_mood_changed signal
- JobRunner._tick_eat: fires ate_meal thought when consuming MEAL/BREAD
- JobRunner._tick_sleep: fires well_rested or slept_on_floor on wake

Opus integration:
- world.tscn: SleepProvider node added (9 providers total)
- world.gd registers in priority order:
  sleep=8 > eat=7 > construction=6 > chop=5 ≈ plant=5 > mine=4 ≈ crafting=4 > haul=3 > rest=0
- Demo seed: 3 beds along cabin's north row at (45/47/49, 24), pre-built
  so pawns can sleep immediately when tired

Acceptance — MCP-verified end-to-end:
- Pre-tired Bram at sleep=25 → SleepProvider issued 'Sleep at (45, 24)' job
- Bram walked to bed, claimed, slept 200 ticks, woke at sleep≥99
- Bed released back to available; well_rested thought fired (+5 mood)
- After ~12000 ticks total: all 3 pawns slept (sleep recovered to 67/86/51),
  thoughts active (1-2 per pawn — well_rested + ate_meal from Phase 7 cooked
  bread consumption), beds all back to available, no claim leaks
- Mood compute working (base 50 + thought modifiers); sulking transition
  ready but didn't fire — would need misery accumulation (Phase 9 Cold +
  Bleeding statuses) to drive mood < 25 sustained

Phase 8 followups for later phases:
- Sulking returns null (stand still); Phase 17 may add Wandering soft-break
  that issues a random-walk job
- Bed ownership (_owner_pawn) reserved but not used in Phase 8 — Phase 17
  may add 'bedrooms' where each pawn claims a specific bed
- _tick_sleep's using_bed local-var reset pattern is correct but fragile;
  cleanup pass when status interrupts (Phase 9) wire into the eat/sleep
  cancellation path

Delegation report this phase:
- Agent A: Bed entity (buildable, quality-tinted, claim/release)
- Agent B: Pawn.sleep + SleepProvider + KIND_SLEEP toil + JobRunner._tick_sleep
- Agent C: Thought + ThoughtCatalog + Pawn mood/sulking + Decision Layer 1
  + JobRunner thought hooks in _tick_eat / _tick_sleep
- Opus: scene wiring + 3 beds in demo seed + MCP runtime verification

~75% of Phase 8 GDScript was subagent-authored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:21:15 +01:00

634 lines
22 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class_name JobRunner
extends Node
## Executes a Job's toils on behalf of a Pawn.
##
## Sits between the Decision layer and the Pawn's physical state. The
## Decision layer (or a WorkProvider) hands us a Job; we tick through its
## toils one-by-one and fire job_completed when the last toil is done.
##
## Design notes (docs/architecture.md — Pawn AI 5-layer pipeline):
## - JobRunner is layer 3 of 5. Don't add control-flow that belongs to
## Decision (layer 1) or WorkProvider (layer 2) here.
## - Pawn and Pathfinder are held as untyped vars to avoid class_name
## registration-order issues between autoloads and scene scripts.
## - tick() is called from Pawn._on_sim_tick each sim tick. Never spin
## render-frame work off this function.
##
## Save / load contract (NON-NEGOTIABLE, Phase 3 acceptance criterion):
## to_dict() / from_dict() round-trip mid-toil state exactly. A WALK
## toil with started=true restores correctly: on the first tick after load
## the runner sits in the "already started, waiting for walk_completed"
## branch, so pawn.walk_along_path() is NOT called again (which would
## reset the pawn's progress). The pawn finishes its own restored walk
## under its own steam, eventually fires walk_completed, and the toil is
## marked done. See _tick_walk() for the branch logic.
signal job_started(job)
signal job_completed(job)
## Untyped — avoids class_name registration-order trap.
var pawn = null
## Untyped — avoids class_name registration-order trap.
var pathfinder = null
## Current Job being executed; null when idle.
var job = null
# ── lifecycle ────────────────────────────────────────────────────────────────
## Wire refs. Must be called once before any other method.
## Connects pawn.walk_completed → _on_pawn_walk_completed.
func setup(pawn_ref, pathfinder_ref) -> void:
pawn = pawn_ref
pathfinder = pathfinder_ref
pawn.walk_completed.connect(_on_pawn_walk_completed)
# ── public API ───────────────────────────────────────────────────────────────
## Replace the current job (if any) and begin executing the new one.
## Resets nothing on the new job — current_toil_index is used as-is so
## that a restored-from-save job continues from its saved toil position.
func start_job(j) -> void:
job = j
Audit.log(
"job_runner",
"%s start: %s (%d toils)" % [pawn.pawn_name, j.label, j.toils.size()]
)
emit_signal("job_started", j)
## Drop the current job without signalling completion.
## Any walk already in progress is left to finish naturally
## (Phase 3 simplicity; Phase 5+ may add a hard-abort path).
func cancel_job() -> void:
job = null
## True when a job is currently assigned.
func has_job() -> bool:
return job != null
# ── sim tick ────────────────────────────────────────────────────────────────
## Called from Pawn._on_sim_tick each sim tick.
## Executes the active toil; advances to the next when it is done;
## emits job_completed when the last toil completes.
func tick() -> void:
if job == null:
return
var t = job.active_toil()
if t == null:
_emit_complete()
return
match t.kind:
Toil.KIND_WALK:
_tick_walk(t)
Toil.KIND_WAIT:
_tick_wait(t)
Toil.KIND_IDLE:
pass # Never completes on its own — Decision or player overrides.
Toil.KIND_INTERACT:
_tick_interact(t)
Toil.KIND_BUILD:
_tick_build(t)
Toil.KIND_PICKUP:
_tick_pickup(t)
Toil.KIND_DEPOSIT:
_tick_deposit(t)
Toil.KIND_CRAFT:
_tick_craft(t)
Toil.KIND_EAT:
_tick_eat(t)
Toil.KIND_SLEEP:
_tick_sleep(t)
if t.done:
job.advance()
if job.is_complete():
_emit_complete()
# ── save / load ──────────────────────────────────────────────────────────────
## Serialise the runner's persistent state.
## {"job": <dict or null>}
func to_dict() -> Dictionary:
return {
"job": job.to_dict() if job != null else null,
}
## Restore from a dict produced by to_dict().
## If the "job" key holds a Dictionary, reconstructs a Job via Job.from_dict().
func from_dict(d: Dictionary) -> void:
var job_data = d.get("job", null)
if job_data is Dictionary:
job = Job.from_dict(job_data)
# ── signal handlers ──────────────────────────────────────────────────────────
## Fired by the Pawn when it finishes walking its path.
## Marks the active WALK toil done so the next tick() advances past it.
## Does NOT call job.advance() directly — tick() handles that.
func _on_pawn_walk_completed() -> void:
if job == null:
return
var t = job.active_toil()
if t != null and t.kind == Toil.KIND_WALK:
t.done = true
# ── toil executors ──────────────────────────────────────────────────────────
## Execute one tick of a WALK toil.
##
## On the FIRST tick (started=false):
## - If the pawn is already at the destination, complete immediately.
## - Otherwise ask the pathfinder for a route. If unreachable, log and
## complete (skip-and-continue; the WorkProvider is responsible for
## vetting reachability before issuing the job).
## - Hand the path to the pawn and mark started=true. From now on this
## function is a no-op — we just wait for the walk_completed signal.
##
## On SUBSEQUENT ticks (started=true):
## - No-op. The pawn walks under its own steam.
##
## After LOAD (started=true from saved state):
## - Same as subsequent ticks — pawn restores its own path and fires
## walk_completed when it arrives. We do NOT call walk_along_path again.
func _tick_walk(t) -> void:
if not t.data.get("started", false):
var dest: Vector2i = t.get_walk_destination()
if pawn.tile == dest:
t.done = true
return
var path: Array[Vector2i] = pathfinder.find_path(pawn.tile, dest)
if path.is_empty():
Audit.log(
"job_runner",
"%s unreachable: %s%s" % [pawn.pawn_name, pawn.tile, dest]
)
t.done = true
return
pawn.walk_along_path(path)
t.data["started"] = true
## Execute one tick of a WAIT toil.
## Decrements the counter; sets done when it reaches zero.
func _tick_wait(t) -> void:
t.data["ticks_remaining"] -= 1
if t.data["ticks_remaining"] <= 0:
t.done = true
## Execute one tick of an INTERACT toil.
##
## First tick: resolve the target node from the stored NodePath string.
## If the target is gone or freed, log and skip immediately (done=true).
## Otherwise mark started and log the action start.
##
## Every subsequent tick: call tick_method on the target. After the call,
## check whether the target has been consumed (is_choppable/is_mineable
## returns false, or the node was freed). If so, mark done.
func _tick_interact(t) -> void:
var target_path := NodePath(t.data.get("target", ""))
var target = get_tree().get_root().get_node_or_null(target_path)
if not t.data.get("started", false):
if target == null or not is_instance_valid(target):
Audit.log(
"job_runner",
"%s interact target gone — skipping" % pawn.pawn_name
)
t.done = true
return
t.data["started"] = true
Audit.log(
"job_runner",
"%s interact start: %s.%s" % [pawn.pawn_name, target.name, t.data.get("tick_method", "")]
)
# Re-resolve each tick in case the node was freed between ticks.
target = get_tree().get_root().get_node_or_null(target_path)
if target == null or not is_instance_valid(target):
t.done = true
return
target.call(StringName(t.data.get("tick_method", "")))
# Re-check validity after the call (the call may have freed the node).
if target == null or not is_instance_valid(target):
t.done = true
return
# Probe any known "still-active?" predicate. First match that returns false
# closes the toil. Order matches natural work priority (chop → mine → plant).
var _probes: Array[StringName] = [
&"is_choppable", &"is_mineable", &"is_harvestable", &"is_sowable"
]
for probe in _probes:
if target.has_method(probe) and not target.call(probe):
Audit.log(
"job_runner",
"%s interact done: %s.%s() → false" % [pawn.pawn_name, target.name, probe]
)
t.done = true
return
## Execute one tick of a BUILD toil.
##
## Mirrors _tick_interact's pattern, but drives construction entities (Wall,
## Floor, Door) via on_build_tick() / is_buildable() instead of on_chop_tick()
## / is_choppable(). The site is "done" (toil complete) when is_buildable()
## returns false — meaning the entity finished building OR was removed.
##
## First tick: resolve target from NodePath. If already gone, skip immediately.
## Every subsequent tick: call on_build_tick() then check is_buildable(). Once
## false the site is built (or cancelled); mark toil done.
func _tick_build(t) -> void:
var target_path := NodePath(t.data.get("target", ""))
var target = get_tree().get_root().get_node_or_null(target_path)
if not t.data.get("started", false):
if target == null or not is_instance_valid(target):
Audit.log(
"job_runner",
"%s build target gone — skipping" % pawn.pawn_name
)
t.done = true
return
t.data["started"] = true
Audit.log(
"job_runner",
"%s build start: %s" % [pawn.pawn_name, target.name]
)
# Re-resolve each tick in case the node was freed between ticks.
target = get_tree().get_root().get_node_or_null(target_path)
if target == null or not is_instance_valid(target):
t.done = true
return
target.on_build_tick()
# Re-check after the call (on_build_tick may complete + free the ghost state).
if target == null or not is_instance_valid(target):
t.done = true
return
if target.has_method("is_buildable") and not target.is_buildable():
Audit.log(
"job_runner",
"%s build done: %s" % [pawn.pawn_name, target.name]
)
t.done = true
## Execute one tick of a PICKUP toil.
##
## Finds the first unheld Item at pawn.tile in World.items.
## Transfers it into pawn.carried_item via set_being_carried(true).
## Completes in a single tick.
func _tick_pickup(t) -> void:
var item = null
for it in World.items:
if it.tile == pawn.tile and not it.being_carried:
item = it
break
if item == null:
Audit.log(
"job_runner",
"%s pickup: no item at %s" % [pawn.pawn_name, pawn.tile]
)
t.done = true
return
pawn.carried_item = item
item.set_being_carried(true)
Audit.log(
"job_runner",
"%s pickup: %s ×%d" % [pawn.pawn_name, item.item_type, item.stack_size]
)
t.done = true
## Execute one tick of a DEPOSIT toil.
##
## Places pawn.carried_item at pawn.tile (pixel-centred in the 16 px grid).
## Clears pawn.carried_item. Completes in a single tick.
func _tick_deposit(t) -> void:
if pawn.carried_item == null:
Audit.log(
"job_runner",
"%s deposit: nothing to deposit" % pawn.pawn_name
)
t.done = true
return
var item = pawn.carried_item
item.tile = pawn.tile
item.position = Vector2(pawn.tile.x * 16 + 8, pawn.tile.y * 16 + 8)
item.set_being_carried(false)
pawn.carried_item = null
# Phase 4: clear the haul-dirty flag — item has landed at its destination.
# The periodic sweep_for_better_destinations will re-mark it if a higher-
# priority destination opens up later.
World.clear_item_haul_flag(item)
# Phase 5: if the destination tile is covered by a Crate (single-tile
# container), register the item into the crate's contents. StockpileZone
# doesn't need this — its items just live at the floor tile.
var dest = World.stockpile_at_tile(pawn.tile)
if dest != null and dest.has_method("register_item"):
dest.register_item(item)
Audit.log(
"job_runner",
"%s deposit: %s ×%d at %s" % [pawn.pawn_name, item.item_type, item.stack_size, pawn.tile]
)
t.done = true
## Execute one tick of a CRAFT toil.
##
## First tick (started=false):
## - Resolves the Workbench node from the stored NodePath. Missing → skip.
## - Looks up the Bill at data["bill_index"]. Out-of-range → skip.
## - Validates pawn position matches workbench.tile. Mismatch → skip.
## - Validates pawn is carrying the correct ingredient type. Missing → skip.
## - Calls wb.begin_craft(bill) to register the active bill + reset progress.
## - Marks started=true and logs the craft start.
##
## Every tick (including first, after the checks above):
## - Calls wb.tick_craft() which increments current_work_progress and returns
## true when bill.recipe.work_ticks is reached.
## - On completion:
## * Consumes the carried ingredient (queue_free + clear pawn slot).
## * Rolls quality via QualityCalc.roll() using the pawn's skill level.
## * Spawns an Item scene child of wb.get_parent() at wb.tile.
## * Calls wb.on_craft_complete() (records bill completion + resets state).
## * Logs the result and marks toil done.
func _tick_craft(t) -> void:
var wb_path := NodePath(t.data.get("workbench", ""))
var wb = get_tree().get_root().get_node_or_null(wb_path)
if not t.data.get("started", false):
# ── first-tick validation ─────────────────────────────────────────────
if wb == null or not is_instance_valid(wb):
Audit.log("job_runner", "%s craft: workbench gone — skipping" % pawn.pawn_name)
t.done = true
return
var bill_index: int = int(t.data.get("bill_index", -1))
if bill_index < 0 or bill_index >= wb.bills.size():
Audit.log(
"job_runner",
"%s craft: invalid bill_index %d — skipping" % [pawn.pawn_name, bill_index]
)
t.done = true
return
var bill = wb.bills[bill_index]
if pawn.tile != wb.tile:
Audit.log(
"job_runner",
"%s not at workbench (pawn=%s wb=%s) — skipping" % [pawn.pawn_name, pawn.tile, wb.tile]
)
t.done = true
return
if pawn.carried_item == null or pawn.carried_item.item_type != bill.recipe.ingredient_type:
Audit.log(
"job_runner",
"%s craft: wrong or missing ingredient — skipping" % pawn.pawn_name
)
t.done = true
return
# Register the bill with the workbench and reset its progress counter.
wb.begin_craft(bill)
t.data["started"] = true
Audit.log("job_runner", "%s craft start: %s" % [pawn.pawn_name, bill.recipe.label])
# ── per-tick progress ─────────────────────────────────────────────────────
# Re-resolve each tick in case the workbench was freed between ticks.
wb = get_tree().get_root().get_node_or_null(wb_path)
if wb == null or not is_instance_valid(wb):
t.done = true
return
# tick_craft() advances the progress counter and returns true when done.
var craft_done: bool = wb.tick_craft()
if not craft_done:
return # Still working — toil remains active.
# ── craft complete ────────────────────────────────────────────────────────
# Retrieve the bill now (before on_craft_complete clears current_bill).
var bill_index: int = int(t.data.get("bill_index", -1))
if bill_index < 0 or bill_index >= wb.bills.size():
# Guard against the workbench mutating bills mid-craft.
wb.on_craft_complete()
t.done = true
return
var bill = wb.bills[bill_index]
# Consume ingredient.
var ingredient = pawn.carried_item
pawn.carried_item = null
if ingredient != null and is_instance_valid(ingredient):
ingredient.queue_free()
# Roll quality based on pawn skill for this recipe.
var skill_level: int = pawn.get_skill(bill.recipe.required_skill)
var quality: int = QualityCalc.roll(skill_level)
# Spawn output Item as a sibling of the workbench (World scene level).
var item_scene: PackedScene = load("res://scenes/entities/item.tscn")
var output_item = item_scene.instantiate()
wb.get_parent().add_child(output_item)
output_item.setup(bill.recipe.output_type, 1, wb.tile)
output_item.quality = quality
# Record completion on the bill and reset workbench state.
wb.on_craft_complete()
Audit.log(
"job_runner",
"%s crafted %s ×1 quality=%s at %s" % [
pawn.pawn_name,
bill.recipe.output_type,
Item.Quality.keys()[quality],
wb.tile,
]
)
t.done = true
## Execute one tick of an EAT toil.
##
## Consumes pawn.carried_item and restores hunger based on the item type:
## MEAL: +60 BREAD: +45 VEGETABLE: +25 GRAIN: +10 (raw, last resort)
## The item is freed and the carry slot is cleared. Completes in a single tick.
## If the pawn has nothing to eat, logs and completes immediately so the job
## runner does not stall.
func _tick_eat(t) -> void:
if pawn.carried_item == null:
Audit.log("job_runner", "%s eat: nothing to eat" % pawn.pawn_name)
t.done = true
return
var item_type: StringName = pawn.carried_item.item_type
var nutrition: int
match item_type:
Item.TYPE_MEAL: nutrition = 60
Item.TYPE_BREAD: nutrition = 45
Item.TYPE_VEGETABLE: nutrition = 25
Item.TYPE_GRAIN: nutrition = 10
_:
# Fallback for any unrecognised food type — treat as vegetables.
nutrition = 25
pawn.set_hunger(pawn.hunger + float(nutrition))
# Phase 8 — fire ate_meal thought for cooked food (MEAL or BREAD).
# Raw food (VEGETABLE, GRAIN) gives no mood bonus — player incentive to
# build a cooking hearth per design.md "Thought list".
if item_type == Item.TYPE_MEAL or item_type == Item.TYPE_BREAD:
if pawn.has_method("add_thought"):
pawn.add_thought(ThoughtCatalog.ate_meal())
pawn.carried_item.queue_free()
pawn.carried_item = null
Audit.log(
"job_runner",
"%s ate %s (+%d hunger → %.1f)" % [pawn.pawn_name, item_type, nutrition, pawn.hunger]
)
t.done = true
## Execute one tick of a SLEEP toil.
##
## Sleep recovery rates (per sim tick):
## In a bed : 0.5 / tick (fast, restful; pawn wakes when sleep ≥ SLEEP_MAX - 1)
## On floor : 0.25 / tick (slow, poor rest — mood thought fires via Mood system)
##
## First tick (started=false):
## - Resolves the Bed node from t.data["bed"] (empty string → floor sleep).
## - If bed path given but node invalid/freed: log + fall back to floor sleep.
## - If valid bed: call bed.claim(pawn). If claim() returns false (race lost):
## log + fall back to floor sleep.
## - Reset ticks_slept = 0, mark started = true.
## - Log sleep start (bed vs floor).
##
## Every tick (including the first after validation):
## - Increment ticks_slept.
## - Restore pawn.sleep by the appropriate recovery rate.
## - When sleep ≥ SLEEP_MAX - 1 (essentially full): release bed, log, done.
## - Emergency ceiling SLEEP_TICKS_MAX: prevents stuck-asleep loops.
const SLEEP_RECOVERY_BED: float = 0.5 # /tick in bed
const SLEEP_RECOVERY_FLOOR: float = 0.25 # /tick on floor
const SLEEP_TICKS_MAX: int = 2000 # emergency wake ceiling (~100 s at 1×)
func _tick_sleep(t) -> void:
var using_bed: bool = false # resolved on first tick; re-read from data each tick
if not t.data.get("started", false):
# ── first-tick: resolve and claim bed ─────────────────────────────────
var bed_path_str: String = t.data.get("bed", "")
var bed = null
if bed_path_str != "":
var bed_path := NodePath(bed_path_str)
bed = get_tree().get_root().get_node_or_null(bed_path)
if bed == null or not is_instance_valid(bed):
# Bed gone between job creation and arrival.
Audit.log(
"job_runner",
"%s bed gone at %s — sleeping on floor" % [pawn.pawn_name, bed_path_str]
)
t.data["bed"] = ""
bed = null
elif not bed.claim(pawn):
# Bed claimed by another pawn during the walk — fall back.
Audit.log(
"job_runner",
"%s bed claim failed at %s — sleeping on floor" % [pawn.pawn_name, bed_path_str]
)
t.data["bed"] = ""
bed = null
t.data["started"] = true
t.data["ticks_slept"] = 0
var has_bed: bool = (bed != null)
Audit.log(
"job_runner",
"%s sleep start: %s" % [pawn.pawn_name, "bed" if has_bed else "floor"]
)
# ── per-tick recovery ──────────────────────────────────────────────────────
t.data["ticks_slept"] = int(t.data.get("ticks_slept", 0)) + 1
var ticks_slept: int = int(t.data["ticks_slept"])
# Determine which rate to use this tick based on whether a bed is still live.
var current_bed = null
var bed_path_str: String = t.data.get("bed", "")
if bed_path_str != "":
current_bed = get_tree().get_root().get_node_or_null(NodePath(bed_path_str))
if current_bed != null and not is_instance_valid(current_bed):
current_bed = null
using_bed = (current_bed != null)
var rate: float = SLEEP_RECOVERY_BED if using_bed else SLEEP_RECOVERY_FLOOR
pawn.set_sleep(pawn.sleep + rate)
# Wake when sleep is essentially full. Compare against the literal 99.0
# (SLEEP_MAX - 1) rather than pawn.SLEEP_MAX because pawn is duck-typed
# and const access on an untyped var fails at runtime in GDScript.
if pawn.sleep >= 99.0:
if current_bed != null:
current_bed.release()
# Phase 8 — fire sleep-quality thought on natural wakeup.
if pawn.has_method("add_thought"):
if using_bed:
pawn.add_thought(ThoughtCatalog.well_rested())
else:
pawn.add_thought(ThoughtCatalog.slept_on_floor())
Audit.log(
"job_runner",
"%s woke (slept %d ticks; sleep=%.1f)" % [pawn.pawn_name, ticks_slept, pawn.sleep]
)
t.done = true
return
# Emergency ceiling — prevent stuck-asleep loops.
if ticks_slept > SLEEP_TICKS_MAX:
if current_bed != null:
current_bed.release()
# Phase 8 — fire sleep-quality thought on emergency wake too.
if pawn.has_method("add_thought"):
if using_bed:
pawn.add_thought(ThoughtCatalog.well_rested())
else:
pawn.add_thought(ThoughtCatalog.slept_on_floor())
Audit.log(
"job_runner",
"%s emergency wake after %d ticks (sleep=%.1f)" % [pawn.pawn_name, ticks_slept, pawn.sleep]
)
t.done = true
# ── helpers ──────────────────────────────────────────────────────────────────
## Emit job_completed, log, and clear the job reference.
func _emit_complete() -> void:
var completed = job
job = null
Audit.log(
"job_runner",
"%s done: %s" % [pawn.pawn_name, completed.label]
)
emit_signal("job_completed", completed)