rimlike/scenes/ai/job_runner.gd
megaproxy d9638a4ea4 fix six critical bugs from audit sprint
save/load round-trip: workbench bills, crop static-method, bed owner,
wolf target now all survive reload via Bill.from_dict reconstruction,
_spawn_crop using setup(), and a new _post_load_resolve_references pass.

PlantProvider: sow path added; consumes 1 grain on a TILLED crop tile.

CraftingProvider: ingredient2 supported via new KIND_DEPOSIT_AT_WB toil
and Workbench.deposited_inputs buffer. Cremation pyre now actually
consumes wood.

HaulingProvider: per-item haul_retry_count + haul_rejected after 3
orphan passes; new EventBus.stockpile_layout_changed resets rejects on
any player stockpile edit.

Storyteller: 14 stubbed event effects implemented. New buff registry
(add_buff/get_buff_multiplier/has_buff, day-prune, save/load) drives
seasonal/resource events. New request_pawn_spawn signal + WANDERER
table for arrivals. New SICK status + 3 mood thoughts.

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

985 lines
35 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)
Toil.KIND_RESCUE:
_tick_rescue(t)
Toil.KIND_TREAT:
_tick_treat(t)
Toil.KIND_CLEAN:
_tick_clean(t)
Toil.KIND_PICKUP_CORPSE:
_tick_pickup_corpse(t)
Toil.KIND_DEPOSIT_CORPSE:
_tick_deposit_corpse(t)
Toil.KIND_DEPOSIT_AT_WB:
_tick_deposit_at_wb(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():
# Unreachable — abort the entire job so Decision can pick something
# else next tick. Previously this marked the walk toil done and the
# subsequent toils (pickup, deposit, etc.) ran at the wrong tile,
# making haul/pickup silently fail with "no item at <pawn.tile>".
Audit.log(
"job_runner",
"%s unreachable: %s%s (canceling job '%s')" %
[pawn.pawn_name, pawn.tile, dest, job.label]
)
cancel_job()
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 DEPOSIT_AT_WB toil.
##
## Single-tick: transfers pawn.carried_item into the workbench's deposited_inputs
## buffer (wb.add_deposited_input) without spawning the item on the floor.
## This is leg-1 of a two-ingredient craft; leg-2 fetches ingredient2, then
## _tick_craft validates the buffer before beginning the work countdown.
##
## Fails silently (t.done = true) if workbench is gone or pawn has nothing.
func _tick_deposit_at_wb(t) -> void:
var wb_path := NodePath(t.data.get("workbench", ""))
var wb = get_tree().get_root().get_node_or_null(wb_path)
if wb == null or not is_instance_valid(wb):
Audit.log("job_runner", "%s deposit_at_wb: workbench gone — skipping" % pawn.pawn_name)
t.done = true
return
if pawn.carried_item == null:
Audit.log("job_runner", "%s deposit_at_wb: nothing to deposit" % pawn.pawn_name)
t.done = true
return
var item = pawn.carried_item
wb.add_deposited_input(item.item_type, item.stack_size)
Audit.log(
"job_runner",
"%s deposit_at_wb: %s ×%d → workbench buffer" % [pawn.pawn_name, item.item_type, item.stack_size]
)
item.queue_free()
pawn.carried_item = null
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.
## - For recipes with ingredient_type: validates pawn is carrying it OR (for
## two-ingredient recipes) validates wb.deposited_inputs has ingredient1
## and pawn is carrying ingredient2.
## - For no-ingredient recipes (ingredient_type == &""): no carry check.
## - 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 pawn.carried_item (ingredient or ingredient2); queue_free + clear.
## * For two-ingredient recipes, also calls wb.consume_deposited_input for ing1.
## * 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
# Ingredient validation — three cases:
# (a) No primary ingredient (ingredient_type == &""): skip carry checks.
# (b) Single ingredient: pawn must be carrying ingredient_type.
# (c) Two ingredients: wb.deposited_inputs must hold ingredient1,
# pawn must be carrying ingredient2.
var has_primary: bool = bill.recipe.ingredient_type != &""
var has_secondary: bool = bill.recipe.ingredient2_type != &""
if has_primary:
if has_secondary:
# Two-ingredient path: check buffer (ing1) + carry (ing2).
if not wb.has_deposited_input(bill.recipe.ingredient_type, 1):
Audit.log(
"job_runner",
"%s craft: ingredient1 not in workbench buffer — skipping" % pawn.pawn_name
)
t.done = true
return
if pawn.carried_item == null or pawn.carried_item.item_type != bill.recipe.ingredient2_type:
Audit.log(
"job_runner",
"%s craft: wrong or missing ingredient2 — skipping" % pawn.pawn_name
)
t.done = true
return
else:
# Single-ingredient path: pawn carries ingredient1.
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 ingredients.
# Single-ingredient: pawn.carried_item holds the only input — free it.
# Two-ingredient: pawn.carried_item is ingredient2; ingredient1 was buffered
# in wb.deposited_inputs by _tick_deposit_at_wb — remove it from the buffer.
# No-ingredient: carried_item is null; nothing to consume.
var ingredient = pawn.carried_item
pawn.carried_item = null
if ingredient != null and is_instance_valid(ingredient):
ingredient.queue_free()
if bill.recipe.ingredient2_type != &"":
# ingredient2 was the carried item (consumed above); ingredient1 is in the buffer.
wb.consume_deposited_input(bill.recipe.ingredient_type, 1)
# 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
## Execute one tick of a RESCUE toil.
##
## Single-tick marker. The doctor walks to the patient via a preceding WALK_TO
## toil; this toil records the visit and immediately completes. The actual
## "transport" of the patient to the medical bed is handled by the subsequent
## WALK_TO(bed) + TREAT toil sequence: the TREAT toil snaps the patient to the
## doctor's tile on its first tick, which is the bed tile.
##
## If the patient is gone or no longer downed, we log and skip (done=true)
## so the job runner doesn't stall on a phantom task.
func _tick_rescue(t) -> void:
var patient = _resolve_patient(t)
if patient == null:
Audit.log(
"job_runner",
"%s rescue: patient gone or invalid — skipping" % pawn.pawn_name
)
t.done = true
return
if not patient.has_method("is_downed") or not patient.is_downed():
Audit.log(
"job_runner",
"%s rescue: patient %s is not downed — skipping" % [pawn.pawn_name, patient.pawn_name]
)
t.done = true
return
t.data["started"] = true
Audit.log(
"job_runner",
"%s reached downed %s — walking to bed for treatment" % [pawn.pawn_name, patient.pawn_name]
)
t.done = true
## Execute one tick of a TREAT toil.
##
## First tick (started=false):
## - Resolve the patient node. If gone or no longer downed, skip immediately.
## - Snap the patient to the doctor's current tile (the medical-bed tile the
## doctor just walked to). This is the Phase 9 simplification: no carry
## visual; Phase 17 may replace this with a proper carry animation.
## - Mark started=true, ticks_treating=0.
##
## Every tick (including first, after the snap):
## - Increment ticks_treating.
## - Call patient.heal(0.5).
## - Every 100 ticks: remove "bleeding" status if present (full cure, Phase 9
## simplification; Phase 17 may reduce severity incrementally).
## - Done when patient.hp >= HP_REVIVE_THRESHOLD AND no "bleeding" status.
## - Done (safety ceiling) when ticks_treating > 600.
##
## HP_REVIVE_THRESHOLD mirrors Pawn.HP_REVIVE_THRESHOLD (50.0). Compared as a
## literal so the duck-typed pawn ref doesn't need const access.
const _HP_REVIVE_THRESHOLD: float = 50.0
const _TREAT_TICKS_MAX: int = 600
func _tick_treat(t) -> void:
var patient = _resolve_patient(t)
if not t.data.get("started", false):
# ── first-tick: validate and snap patient to bed tile ─────────────────
if patient == null:
Audit.log("job_runner", "%s treat: patient gone — skipping" % pawn.pawn_name)
t.done = true
return
if not patient.has_method("is_downed") or not patient.is_downed():
Audit.log(
"job_runner",
"%s treat: patient %s already up — skipping" % [pawn.pawn_name, patient.pawn_name]
)
t.done = true
return
# Snap patient to the doctor's current tile (the medical-bed tile).
# Phase 9 simplification: instant teleport; Phase 17 may add carry visual.
patient.tile = pawn.tile
patient.position = Vector2(pawn.tile.x * 16 + 8, pawn.tile.y * 16 + 8)
t.data["started"] = true
t.data["ticks_treating"] = 0
Audit.log(
"job_runner",
"%s treating %s at %s" % [pawn.pawn_name, patient.pawn_name, pawn.tile]
)
# ── per-tick treatment ─────────────────────────────────────────────────────
if patient == null or not is_instance_valid(patient):
t.done = true
return
t.data["ticks_treating"] = int(t.data.get("ticks_treating", 0)) + 1
var ticks: int = int(t.data["ticks_treating"])
patient.heal(0.5)
# Cure bleeding in full after 100 ticks of treatment (Phase 9 simplification).
if ticks % 100 == 0 and patient.has_method("has_status") and patient.has_status(&"bleeding"):
patient.remove_status_by_id(&"bleeding")
Audit.log(
"job_runner",
"%s cleared bleeding on %s (tick %d)" % [pawn.pawn_name, patient.pawn_name, ticks]
)
# Done when HP is above revive threshold AND bleeding is cleared.
var hp_ok: bool = patient.hp >= _HP_REVIVE_THRESHOLD
var bleed_ok: bool = not (patient.has_method("has_status") and patient.has_status(&"bleeding"))
if hp_ok and bleed_ok:
Audit.log(
"job_runner",
"%s finished treating %s (hp=%.1f, ticks=%d)" % [pawn.pawn_name, patient.pawn_name, patient.hp, ticks]
)
t.done = true
return
# Safety ceiling — prevent the toil from stalling if HP/bleed can't converge.
if ticks > _TREAT_TICKS_MAX:
Audit.log(
"job_runner",
"%s treatment timeout for %s (hp=%.1f, ticks=%d)" % [pawn.pawn_name, patient.pawn_name, patient.hp, ticks]
)
t.done = true
## Execute one tick of a CLEAN toil.
##
## First tick (started=false):
## - Validate the dirtiness system is available. If not, skip immediately.
## - Validate the target tile still has dirt >= DIRTY_THRESHOLD. If not, skip.
## - Mark started=true and log the clean start.
##
## Every tick:
## - Reduce dirt at the tile by DIRT_REDUCTION_PER_TICK via DirtinessSystem.bump_clean().
## - Done when dirt <= 0.
##
## DIRTY_THRESHOLD and DIRT_REDUCTION_PER_TICK mirror CleaningProvider constants.
## 100.0 / 40 ticks = 2.5/tick ensures any tile (max 100 dirt) is clean in 40 ticks.
const _CLEAN_DIRTY_THRESHOLD: float = 25.0
const _DIRT_REDUCTION_PER_TICK: float = 2.5 # 100 / 40 ticks
func _tick_clean(t) -> void:
var tile := Vector2i(int(t.data.get("clean_x", 0)), int(t.data.get("clean_y", 0)))
# Safety — dirtiness system may not be wired yet during early boot.
var ds = World.get("dirtiness_system")
if ds == null:
t.done = true
return
if not t.data.get("started", false):
# ── first-tick: validate tile is still worth cleaning ─────────────────
var current_dirt: float = ds.dirt_at(tile)
if current_dirt < _CLEAN_DIRTY_THRESHOLD:
Audit.log(
"job_runner",
"%s clean: tile %s already clean (dirt=%.1f) — skipping" % [pawn.pawn_name, tile, current_dirt]
)
t.done = true
return
t.data["started"] = true
Audit.log(
"job_runner",
"%s clean start at %s (dirt=%.1f)" % [pawn.pawn_name, tile, current_dirt]
)
# ── per-tick cleaning ──────────────────────────────────────────────────────
ds.bump_clean(tile, _DIRT_REDUCTION_PER_TICK)
var remaining: float = ds.dirt_at(tile)
if remaining <= 0.0:
Audit.log(
"job_runner",
"%s clean done at %s" % [pawn.pawn_name, tile]
)
t.done = true
## Phase 14 — Execute one tick of a PICKUP_CORPSE toil.
##
## Scans World.corpses for a Corpse at pawn.tile that is not flagged as
## being_carried_corpse. Transfers it into pawn.carried_item and marks
## the corpse's being_carried_corpse flag so the sweep skips it.
## Completes in a single tick.
func _tick_pickup_corpse(t) -> void:
var corpse = null
for c in World.corpses:
if c.tile == pawn.tile and not c.get("being_carried_corpse"):
corpse = c
break
if corpse == null:
Audit.log(
"job_runner",
"%s pickup_corpse: no corpse at %s" % [pawn.pawn_name, pawn.tile]
)
t.done = true
return
pawn.carried_item = corpse
# Use a dynamic property so Corpse script need not be modified (Agent A owns it).
corpse.set_meta("being_carried_corpse", true)
# Also hide the corpse while being carried.
corpse.visible = false
Audit.log(
"job_runner",
"%s pickup_corpse: '%s' at %s" % [pawn.pawn_name, corpse.deceased_name, pawn.tile]
)
t.done = true
## Phase 14 — Execute one tick of a DEPOSIT_CORPSE toil.
##
## Finds the GraveSlot in World.stockpiles covering pawn.tile (must be dug).
## Calls slot.accept_corpse(corpse, world_parent) which spawns the GraveMarker,
## emits EventBus.corpse_buried, and frees both the corpse and the slot.
## Clears pawn.carried_item. Completes in a single tick.
func _tick_deposit_corpse(t) -> void:
if pawn.carried_item == null:
Audit.log(
"job_runner",
"%s deposit_corpse: nothing to bury" % pawn.pawn_name
)
t.done = true
return
var corpse = pawn.carried_item
# Resolve the GraveSlot covering pawn.tile.
var slot = null
for dest in World.stockpiles:
if dest.has_method("is_grave_slot_dug") and dest.covers_tile(pawn.tile):
slot = dest
break
if slot == null or not slot.is_grave_slot_dug():
Audit.log(
"job_runner",
"%s deposit_corpse: no dug GraveSlot at %s — dropping corpse" % [pawn.pawn_name, pawn.tile]
)
# Restore corpse visibility and clear carry flag so it can be re-hauled.
if is_instance_valid(corpse):
corpse.set_meta("being_carried_corpse", false)
corpse.visible = true
pawn.carried_item = null
t.done = true
return
# Hand off to GraveSlot — it spawns the marker, emits the signal, frees both.
var world_parent := get_tree().get_root().get_node_or_null("World")
if world_parent == null:
world_parent = pawn.get_parent()
slot.accept_corpse(corpse, world_parent)
pawn.carried_item = null
Audit.log(
"job_runner",
"%s deposit_corpse: '%s' buried at %s" % [pawn.pawn_name, corpse.deceased_name, pawn.tile]
)
t.done = true
## Resolve the patient Pawn node from the NodePath stored in `t.data["patient"]`.
## Returns null and logs if the node is absent or no longer valid.
## Shared by _tick_rescue and _tick_treat.
func _resolve_patient(t):
var path := NodePath(t.data.get("patient", ""))
if path == NodePath(""):
return null
var node = get_tree().get_root().get_node_or_null(path)
if node == null or not is_instance_valid(node):
return null
return node
# ── 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)