rimlike/scenes/ai/decision.gd
megaproxy 87a7beb22b split CookingProvider out of CraftingProvider — fixes starvation
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>
2026-05-16 21:18:26 +01:00

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