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) 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(): Audit.log( "job_runner", "%s unreachable: %s → %s" % [pawn.pawn_name, pawn.tile, dest] ) t.done = true return pawn.walk_along_path(path) t.data["started"] = true ## Execute one tick of a WAIT toil. ## Decrements the counter; sets done when it reaches zero. func _tick_wait(t) -> void: t.data["ticks_remaining"] -= 1 if t.data["ticks_remaining"] <= 0: t.done = true ## Execute one tick of an INTERACT toil. ## ## First tick: resolve the target node from the stored NodePath string. ## If the target is gone or freed, log and skip immediately (done=true). ## Otherwise mark started and log the action start. ## ## Every subsequent tick: call tick_method on the target. After the call, ## check whether the target has been consumed (is_choppable/is_mineable ## returns false, or the node was freed). If so, mark done. func _tick_interact(t) -> void: var target_path := NodePath(t.data.get("target", "")) var target = get_tree().get_root().get_node_or_null(target_path) if not t.data.get("started", false): if target == null or not is_instance_valid(target): Audit.log( "job_runner", "%s interact target gone — skipping" % pawn.pawn_name ) t.done = true return t.data["started"] = true Audit.log( "job_runner", "%s interact start: %s.%s" % [pawn.pawn_name, target.name, t.data.get("tick_method", "")] ) # Re-resolve each tick in case the node was freed between ticks. target = get_tree().get_root().get_node_or_null(target_path) if target == null or not is_instance_valid(target): t.done = true return target.call(StringName(t.data.get("tick_method", ""))) # Re-check validity after the call (the call may have freed the node). if target == null or not is_instance_valid(target): t.done = true return if target.has_method("is_choppable") and not target.is_choppable(): Audit.log("job_runner", "%s interact done: %s chopped" % [pawn.pawn_name, target.name]) t.done = true return if target.has_method("is_mineable") and not target.is_mineable(): Audit.log("job_runner", "%s interact done: %s mined" % [pawn.pawn_name, target.name]) t.done = true ## 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 # ── 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)