Three gdscript-refactor agents in parallel; Opus integrated and verified
the sleep+wake cycle via MCP runtime.
Bed entity (Agent A, scenes/entities/bed.{gd,tscn} + world.gd, ~280 lines):
- class Bed extends Node2D — bottom-anchored 3/4 perspective like Wall/Workbench
- BuildJob interface (is_buildable / on_build_tick / _complete) — same pattern
as Wall / Crate / Workbench. blocks_pathing_when_complete=false (walkable).
- Quality-tinted sheet colours by Item.Quality tier (drab grey → blue →
gold-brown → regal pink); white pillow + dark frame constant across tiers.
- claim(pawn) / release() / is_available() — atomic occupancy; claim re-checks
is_available() inside to avoid race conditions during pawn walk-to-bed.
- World.beds registry + register_bed / unregister_bed (mirrors workbench pattern)
Sleep need + SleepProvider + KIND_SLEEP toil (Agent B, ~220 lines):
- Pawn.sleep: float 0..100. SLEEP_DECAY_PER_TICK=0.015 (~6667 ticks / 5.5 min
at 1× / 1 min at Ultra to fully tire). Slower than hunger.
- is_tired() at <30; is_exhausted() at <5 (Phase 9 status interrupt hook)
- SleepProvider priority=8 (highest — sleep beats eat=7 when both urgent)
- Toil.KIND_SLEEP + Toil.sleep_in_bed(NodePath) factory
- JobRunner._tick_sleep: first-tick bed claim (with race-loss → floor fallback),
per-tick recovery (bed=0.5/tick, floor=0.25/tick), wake-when-full at ≥99,
emergency ceiling SLEEP_TICKS_MAX=2000 prevents stuck-asleep loops
Thoughts + mood + Sulking (Agent C, ~290 lines):
- scenes/ai/thought.gd: class Thought (RefCounted) with id, modifier, lifetime
(PERSISTENT/EVENT), stacks, ticks_remaining; MAX_STACKS_PER_THOUGHT=5 locked
- scenes/ai/thought_catalog.gd: ThoughtCatalog with 5 Phase 8 thoughts —
hungry(-6, PERSISTENT) / tired(-4, PERSISTENT) / well_rested(+5, EVENT 1200t)
/ slept_on_floor(-5, EVENT 1200t) / ate_meal(+3, EVENT 800t, stacks up to 3)
- Pawn extended: thoughts: Array, mood: float (base 50), sulking: bool,
_sulk_low_ticks. add_thought (stack-merge by id), remove_thought_by_id,
has_thought, is_sulking. _process_thoughts in sim_tick decays EVENT thoughts,
syncs PERSISTENT thoughts to state (hungry/tired), recomputes mood, checks
sulking transition: mood < 25 for MOOD_SULK_SUSTAIN_TICKS=600 ticks → SULKING;
mood >= 35 → recover.
- Decision Layer 1 extended: pawn.is_sulking() → return null (sulking pawns
refuse all work; Phase 17 may add Wandering variant)
- EventBus.pawn_mood_changed signal
- JobRunner._tick_eat: fires ate_meal thought when consuming MEAL/BREAD
- JobRunner._tick_sleep: fires well_rested or slept_on_floor on wake
Opus integration:
- world.tscn: SleepProvider node added (9 providers total)
- world.gd registers in priority order:
sleep=8 > eat=7 > construction=6 > chop=5 ≈ plant=5 > mine=4 ≈ crafting=4 > haul=3 > rest=0
- Demo seed: 3 beds along cabin's north row at (45/47/49, 24), pre-built
so pawns can sleep immediately when tired
Acceptance — MCP-verified end-to-end:
- Pre-tired Bram at sleep=25 → SleepProvider issued 'Sleep at (45, 24)' job
- Bram walked to bed, claimed, slept 200 ticks, woke at sleep≥99
- Bed released back to available; well_rested thought fired (+5 mood)
- After ~12000 ticks total: all 3 pawns slept (sleep recovered to 67/86/51),
thoughts active (1-2 per pawn — well_rested + ate_meal from Phase 7 cooked
bread consumption), beds all back to available, no claim leaks
- Mood compute working (base 50 + thought modifiers); sulking transition
ready but didn't fire — would need misery accumulation (Phase 9 Cold +
Bleeding statuses) to drive mood < 25 sustained
Phase 8 followups for later phases:
- Sulking returns null (stand still); Phase 17 may add Wandering soft-break
that issues a random-walk job
- Bed ownership (_owner_pawn) reserved but not used in Phase 8 — Phase 17
may add 'bedrooms' where each pawn claims a specific bed
- _tick_sleep's using_bed local-var reset pattern is correct but fragile;
cleanup pass when status interrupts (Phase 9) wire into the eat/sleep
cancellation path
Delegation report this phase:
- Agent A: Bed entity (buildable, quality-tinted, claim/release)
- Agent B: Pawn.sleep + SleepProvider + KIND_SLEEP toil + JobRunner._tick_sleep
- Agent C: Thought + ThoughtCatalog + Pawn mood/sulking + Decision Layer 1
+ JobRunner thought hooks in _tick_eat / _tick_sleep
- Opus: scene wiring + 3 beds in demo seed + MCP runtime verification
~75% of Phase 8 GDScript was subagent-authored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
55 lines
3 KiB
GDScript
55 lines
3 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 ──────────────────────────────────────────────
|
|
# Sort a local copy so the original list order is never mutated.
|
|
var sorted: Array = work_providers.duplicate()
|
|
sorted.sort_custom(func(a, b): return a.priority > b.priority)
|
|
|
|
for wp in sorted:
|
|
var j: Job = wp.find_best_for(pawn)
|
|
if j != null:
|
|
Audit.log("decision", "%s: %s → '%s'" % [pawn.pawn_name, String(wp.category), j.label])
|
|
return j
|
|
|
|
# ── Layer 5: Idle ────────────────────────────────────────────────────────
|
|
# No log — would fire every tick for every idle pawn (too chatty).
|
|
return null
|