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. 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 # ── 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)