Phase 8 — Beds, sleep need, thoughts, mood, Sulking soft-break

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>
This commit is contained in:
megaproxy 2026-05-11 13:21:15 +01:00
parent 61dcf6760b
commit 43e52ffe75
18 changed files with 920 additions and 6 deletions

View file

@ -103,6 +103,8 @@ func tick() -> void:
_tick_craft(t)
Toil.KIND_EAT:
_tick_eat(t)
Toil.KIND_SLEEP:
_tick_sleep(t)
if t.done:
job.advance()
@ -490,6 +492,12 @@ func _tick_eat(t) -> void:
nutrition = 25
pawn.set_hunger(pawn.hunger + float(nutrition))
# Phase 8 — fire ate_meal thought for cooked food (MEAL or BREAD).
# Raw food (VEGETABLE, GRAIN) gives no mood bonus — player incentive to
# build a cooking hearth per design.md "Thought list".
if item_type == Item.TYPE_MEAL or item_type == Item.TYPE_BREAD:
if pawn.has_method("add_thought"):
pawn.add_thought(ThoughtCatalog.ate_meal())
pawn.carried_item.queue_free()
pawn.carried_item = null
@ -500,6 +508,119 @@ func _tick_eat(t) -> void:
t.done = true
## Execute one tick of a SLEEP toil.
##
## Sleep recovery rates (per sim tick):
## In a bed : 0.5 / tick (fast, restful; pawn wakes when sleep ≥ SLEEP_MAX - 1)
## On floor : 0.25 / tick (slow, poor rest — mood thought fires via Mood system)
##
## First tick (started=false):
## - Resolves the Bed node from t.data["bed"] (empty string → floor sleep).
## - If bed path given but node invalid/freed: log + fall back to floor sleep.
## - If valid bed: call bed.claim(pawn). If claim() returns false (race lost):
## log + fall back to floor sleep.
## - Reset ticks_slept = 0, mark started = true.
## - Log sleep start (bed vs floor).
##
## Every tick (including the first after validation):
## - Increment ticks_slept.
## - Restore pawn.sleep by the appropriate recovery rate.
## - When sleep ≥ SLEEP_MAX - 1 (essentially full): release bed, log, done.
## - Emergency ceiling SLEEP_TICKS_MAX: prevents stuck-asleep loops.
const SLEEP_RECOVERY_BED: float = 0.5 # /tick in bed
const SLEEP_RECOVERY_FLOOR: float = 0.25 # /tick on floor
const SLEEP_TICKS_MAX: int = 2000 # emergency wake ceiling (~100 s at 1×)
func _tick_sleep(t) -> void:
var using_bed: bool = false # resolved on first tick; re-read from data each tick
if not t.data.get("started", false):
# ── first-tick: resolve and claim bed ─────────────────────────────────
var bed_path_str: String = t.data.get("bed", "")
var bed = null
if bed_path_str != "":
var bed_path := NodePath(bed_path_str)
bed = get_tree().get_root().get_node_or_null(bed_path)
if bed == null or not is_instance_valid(bed):
# Bed gone between job creation and arrival.
Audit.log(
"job_runner",
"%s bed gone at %s — sleeping on floor" % [pawn.pawn_name, bed_path_str]
)
t.data["bed"] = ""
bed = null
elif not bed.claim(pawn):
# Bed claimed by another pawn during the walk — fall back.
Audit.log(
"job_runner",
"%s bed claim failed at %s — sleeping on floor" % [pawn.pawn_name, bed_path_str]
)
t.data["bed"] = ""
bed = null
t.data["started"] = true
t.data["ticks_slept"] = 0
var has_bed: bool = (bed != null)
Audit.log(
"job_runner",
"%s sleep start: %s" % [pawn.pawn_name, "bed" if has_bed else "floor"]
)
# ── per-tick recovery ──────────────────────────────────────────────────────
t.data["ticks_slept"] = int(t.data.get("ticks_slept", 0)) + 1
var ticks_slept: int = int(t.data["ticks_slept"])
# Determine which rate to use this tick based on whether a bed is still live.
var current_bed = null
var bed_path_str: String = t.data.get("bed", "")
if bed_path_str != "":
current_bed = get_tree().get_root().get_node_or_null(NodePath(bed_path_str))
if current_bed != null and not is_instance_valid(current_bed):
current_bed = null
using_bed = (current_bed != null)
var rate: float = SLEEP_RECOVERY_BED if using_bed else SLEEP_RECOVERY_FLOOR
pawn.set_sleep(pawn.sleep + rate)
# Wake when sleep is essentially full. Compare against the literal 99.0
# (SLEEP_MAX - 1) rather than pawn.SLEEP_MAX because pawn is duck-typed
# and const access on an untyped var fails at runtime in GDScript.
if pawn.sleep >= 99.0:
if current_bed != null:
current_bed.release()
# Phase 8 — fire sleep-quality thought on natural wakeup.
if pawn.has_method("add_thought"):
if using_bed:
pawn.add_thought(ThoughtCatalog.well_rested())
else:
pawn.add_thought(ThoughtCatalog.slept_on_floor())
Audit.log(
"job_runner",
"%s woke (slept %d ticks; sleep=%.1f)" % [pawn.pawn_name, ticks_slept, pawn.sleep]
)
t.done = true
return
# Emergency ceiling — prevent stuck-asleep loops.
if ticks_slept > SLEEP_TICKS_MAX:
if current_bed != null:
current_bed.release()
# Phase 8 — fire sleep-quality thought on emergency wake too.
if pawn.has_method("add_thought"):
if using_bed:
pawn.add_thought(ThoughtCatalog.well_rested())
else:
pawn.add_thought(ThoughtCatalog.slept_on_floor())
Audit.log(
"job_runner",
"%s emergency wake after %d ticks (sleep=%.1f)" % [pawn.pawn_name, ticks_slept, pawn.sleep]
)
t.done = true
# ── helpers ──────────────────────────────────────────────────────────────────
## Emit job_completed, log, and clear the job reference.