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) if t.done: job.advance() if job.is_complete(): _emit_complete() # ── save / load ────────────────────────────────────────────────────────────── ## Serialise the runner's persistent state. ## {"job": } 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 ". 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 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 ## 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)