Player report: pawns starve even with harvested crops because cooking never happens. Root cause: CraftingProvider handled both crafting-skill and cooking-skill bills with priority 4, below Plant=5 and Chop=5 in Decision's tiebreaker. Pawns endlessly harvested + chopped instead of cooking the food already on the floor; raw +25 vegetable couldn't outpace HUNGER_DECAY × 3 pawns. CraftingProvider now filters bills to required_skill == &"crafting" only. New CookingProvider (category=&"cooking", priority=6) handles required_skill == &"cooking" bills (bread, meal_from_vegetables) with identical find/score logic including the ingredient2 buffer flow. pawn.work_priorities default now includes &"cooking": 3 (matches the 9-category design spec). decision.gd category-list comment updated. WorkPriorityMatrix gains a "Cook" column. MCP runtime verified: pawns now decide `cooking(pri=3) → Craft Veggie meal at Hearth` immediately after vegetables exist; 2 bread items appeared by tick 261 of a fresh boot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
5.4 KiB
GDScript
106 lines
5.4 KiB
GDScript
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 8 work categories:
|
|
# construction / cooking / 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
|