class_name Decision ## Static utility — picks the next Job for a pawn via the 5-layer pipeline. ## ## Layer order (top wins): ## 1. Incapacitation — has_method probe; implementation lands Phase 9. ## 2. Forced job — pawn.forced_job; cleared (consumed) on pick. ## 3. Status interrupt — stub; implementation lands Phase 9. ## 4. Work providers — iterated highest priority first; first non-null Job wins. ## 5. Idle — returns null (caller interprets as "stand still"). ## ## Callers pass the world-scoped provider list so Decision is fully stateless. ## This makes it safe to call from any pawn tick without shared mutable state. ## Returns the best Job for `pawn`, or null if the pawn should idle. ## ## `work_providers` is the current world-scoped list of WorkProvider nodes ## (e.g. [RestProvider]). Order does not matter — the method sorts by priority. ## `pawn` is duck-typed: must expose .pawn_name, .forced_job, and ## has_method("is_incapacitated"). static func pick_next_job(pawn, work_providers: Array) -> Job: # ── Layer 1: Incapacitation / sulking ─────────────────────────────────── # has_method probes so these don't break before Phase 9 adds is_incapacitated. if pawn.has_method("is_incapacitated") and pawn.is_incapacitated(): return null # Sulking pawns refuse all jobs until mood recovers to MOOD_SULK_RECOVERY. # Phase 17 may replace this with a Wandering soft-break variant that moves # the pawn to a quiet tile instead of standing still. if pawn.has_method("is_sulking") and pawn.is_sulking(): return null # ── Layer 2: Forced job ────────────────────────────────────────────────── if pawn.forced_job != null: var fj: Job = pawn.forced_job pawn.forced_job = null Audit.log("decision", "%s: forced '%s'" % [pawn.pawn_name, fj.label]) return fj # ── Layer 3: Status interrupt ───────────────────────────────────────────── # Phase 9: status interrupt (Bleeding → seek bed/doctor) lands here. # ── Layer 4: Work providers ────────────────────────────────────────────── # Needs-driven categories are handled entirely by Layer-3 status interrupts # and need-threshold providers (eat/sleep/doctor fire when hunger/sleep # thresholds trigger, not via player priority). We skip the priority filter # for them so a pawn can never accidentally starve because the player set # &"eat" to OFF. The player-configurable list is the 7 work categories: # construction / chop / plant / mine / crafting / haul / clean # Doctor IS in the matrix (player can opt a pawn out of doctor duty) but # the needs-driven "go heal yourself" path bypasses this filter at Layer 3. # # Rest is NOT a need-gated category — RestProvider always returns a job (its # job IS "stand here"), so if rest ran before elective work, pawns would # never chop/mine/build. Rest is the eligible-bucket fallback: it sorts last # because provider.priority=0, so any real work wins. Bug ref: 2026-05-12 # session — pawns idled with valid chop/mine/build designations because # &"rest" was in NEEDS_CATEGORIES and RestProvider preempted everything. const NEEDS_CATEGORIES: Array = [&"eat", &"sleep"] # Pawn's work_priorities dict — empty dict if the field is absent (pre-Phase-17 # pawns loaded from old saves). Missing category key defaults to 3 (NORMAL) so # legacy behaviour is preserved exactly. var priorities: Dictionary = {} if pawn.get("work_priorities") != null: priorities = pawn.work_priorities # Partition providers: needs bypass the matrix; work providers are filtered. var eligible: Array = [] var needs: Array = [] for wp in work_providers: if wp.category in NEEDS_CATEGORIES: needs.append(wp) else: # OFF (0) means pawn refuses this category entirely. var lvl: int = int(priorities.get(wp.category, 3)) if lvl > 0: eligible.append(wp) # Sort eligible by pawn priority ascending (Critical=1 first, Low=4 last), # then by provider.priority descending as tiebreaker. eligible.sort_custom(func(a, b) -> bool: var pa: int = int(priorities.get(a.category, 3)) var pb: int = int(priorities.get(b.category, 3)) if pa != pb: return pa < pb # lower pawn-priority-number = higher precedence return a.priority > b.priority # higher provider.priority = first within tier ) # Needs providers retain their natural order (sorted by provider.priority only). needs.sort_custom(func(a, b): return a.priority > b.priority) # Try needs first (hunger/sleep/rest fire before elective work), then eligible. var to_try: Array = needs + eligible for wp in to_try: var j: Job = wp.find_best_for(pawn) if j != null: var lvl_str: String = "" if not (wp.category in NEEDS_CATEGORIES): lvl_str = "(pri=%d)" % int(priorities.get(wp.category, 3)) Audit.log("decision", "%s: %s%s → '%s'" % [pawn.pawn_name, String(wp.category), lvl_str, j.label]) return j # ── Layer 5: Idle ──────────────────────────────────────────────────────── # No log — would fire every tick for every idle pawn (too chatty). return null