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>
985 lines
35 KiB
GDScript
985 lines
35 KiB
GDScript
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)
|