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

@ -26,6 +26,12 @@ class_name Pawn
const STEP_TICKS: int = 10
const TILE_SIZE_PX: int = 16 # Mirrors World.TILE_SIZE_PX; standalone so Pawn needs no World reference.
# ── mood + sulking constants (docs/architecture.md "MoodSystem"; docs/design.md "Mood bands") ──
const MOOD_BASE: float = 50.0
const MOOD_SULK_THRESHOLD: float = 25.0 # < 25 sustained → sulking (docs/design.md "Breaking" band)
const MOOD_SULK_RECOVERY: float = 35.0 # mood must reach ≥ 35 to exit sulking
const MOOD_SULK_SUSTAIN_TICKS: int = 600 # 30 in-game min at 1× (20 ticks/s × 60 s/min × 30 min)
# ── hunger constants (docs/design.md "Health & status effects") ───────────────
# Decay rate: 0.10 / tick × 20 ticks/s = 2.0 / real-sec at 1×.
# 100 → 0 in 50 sim seconds (1×). At Fast (5×): ~10 real seconds.
@ -35,6 +41,13 @@ const TILE_SIZE_PX: int = 16 # Mirrors World.TILE_SIZE_PX; standalone so Pawn n
const HUNGER_MAX: float = 100.0
const HUNGER_DECAY_PER_TICK: float = 0.02 # ~100→0 over 5000 ticks; ~4 min at 1×, ~20 s at Ultra. Tune Phase 20.
# ── sleep constants (docs/design.md "Sleep mood" + Phase 8) ──────────────────
# Decay: 0.015 / tick × 20 ticks/s = 0.3 / real-sec at 1×.
# 100 → 0 in ~6667 ticks (1×) ≈ ~5.6 real minutes. At Fast (5×): ~67 s.
# Slower than hunger — pawns tire in ~30 sim minutes at 1×. Tune Phase 20.
const SLEEP_MAX: float = 100.0
const SLEEP_DECAY_PER_TICK: float = 0.015 # Tune Phase 20.
# ── skill definitions (docs/design.md "Skills") ──────────────────────────────
# Five skills, levels 010. Level by use; multiplicative speed/quality bonus.
# Skills modify duration and quality, never permission (design.md:35).
@ -59,6 +72,9 @@ var tile: Vector2i = Vector2i.ZERO
# Phase 7 — hunger need (design.md "Hungry" status). Full at spawn.
var hunger: float = HUNGER_MAX
# Phase 8 — sleep need (design.md "Sleep mood" gradient). Full at spawn.
var sleep: float = SLEEP_MAX
# Player override slot — set by Selection; consumed by Decision on next sim tick.
# Untyped to dodge the autoload-class-name-ordering trap (Phase 2 gotcha).
var forced_job = null
@ -77,6 +93,24 @@ var carried_item = null
# for batch operations (e.g. from_dict restoring saved data).
var skills: Dictionary = {}
# Phase 8 — mood and thoughts (docs/architecture.md "MoodSystem").
## Ordered list of active Thought entries. Do not mutate directly — use
## add_thought() / remove_thought_by_id() which keep mood in sync.
var thoughts: Array = [] # Array[Thought]
## Cached mood score 0100. Recomputed by _recompute_mood() whenever the
## thoughts array changes. Do not write directly.
var mood: float = MOOD_BASE
## Counts consecutive sim ticks where mood < MOOD_SULK_THRESHOLD.
## Reset to 0 when mood rises above the threshold.
var _sulk_low_ticks: int = 0
## True after mood has been below MOOD_SULK_THRESHOLD for MOOD_SULK_SUSTAIN_TICKS
## consecutive ticks. Cleared once mood recovers to MOOD_SULK_RECOVERY.
## Decision Layer 1 short-circuits for sulking pawns.
var sulking: bool = false
var _path: Array[Vector2i] = []
var _step_progress: float = 0.0
var _selected: bool = false
@ -155,6 +189,26 @@ func set_hunger(value: float) -> void:
hunger = clampf(value, 0.0, HUNGER_MAX)
# ── sleep API (Phase 8) ───────────────────────────────────────────────────────
## True when sleep is low enough that the pawn should seek a bed.
## Threshold mirrors design.md "Tired" state-driven thought trigger (< 30).
func is_tired() -> bool:
return sleep < 30.0
## True when the pawn is critically sleep-deprived.
## Phase 9 status interrupt hook — not yet wired.
func is_exhausted() -> bool:
return sleep < 5.0
## Set sleep to `value`, clamped to [0, SLEEP_MAX].
## Used by JobRunner's _tick_sleep and save/load.
func set_sleep(value: float) -> void:
sleep = clampf(value, 0.0, SLEEP_MAX)
## Returns the pawn's current level (010) for the given skill.
## Returns 0 for unknown skills so callers need no nil-guard.
func get_skill(skill: StringName) -> int:
@ -167,6 +221,121 @@ func set_skill(skill: StringName, level: int) -> void:
skills[skill] = clampi(level, 0, 10)
# ── thought / mood API (Phase 8) ─────────────────────────────────────────────
## Add a thought, merging stacks if one with the same id already exists.
## For EVENT thoughts, ticks_remaining is refreshed to the new value when
## the incoming duration is longer (keeps the best remaining window).
## Calls _recompute_mood() after any mutation.
func add_thought(t: Thought) -> void:
for existing in thoughts:
if existing.id == t.id:
existing.stacks = mini(existing.stacks + 1, existing.max_stacks)
if t.lifetime == Thought.Lifetime.EVENT:
existing.ticks_remaining = maxi(existing.ticks_remaining, t.ticks_remaining)
_recompute_mood()
return
thoughts.append(t)
_recompute_mood()
## Remove all thoughts with the given id. Calls _recompute_mood() if any were removed.
func remove_thought_by_id(id: StringName) -> void:
var removed := false
for i in range(thoughts.size() - 1, -1, -1):
if thoughts[i].id == id:
thoughts.remove_at(i)
removed = true
if removed:
_recompute_mood()
## Returns true if the pawn currently has any thought with the given id.
func has_thought(id: StringName) -> bool:
for t in thoughts:
if t.id == id:
return true
return false
## Returns true when the pawn is in a sulking soft-break state.
## Decision Layer 1 short-circuits for sulking pawns (no work accepted).
func is_sulking() -> bool:
return sulking
# ── thought / mood internals (called from _on_sim_tick) ──────────────────────
## Main per-tick thought update. Call AFTER hunger/sleep decay, BEFORE _orchestrate_ai.
##
## Sequence:
## 1. Decay EVENT thoughts — remove expired ones.
## 2. Sync PERSISTENT thoughts to current pawn state (hungry, tired).
## 3. Recompute mood if EVENT thoughts changed.
## 4. Update sulking transitions.
func _process_thoughts() -> void:
# 1. Decay EVENT thoughts.
var dirty := false
for i in range(thoughts.size() - 1, -1, -1):
var t = thoughts[i]
if t.lifetime == Thought.Lifetime.EVENT:
t.ticks_remaining -= 1
if t.ticks_remaining <= 0:
thoughts.remove_at(i)
dirty = true
# 2. Sync PERSISTENT thoughts.
_sync_persistent_thought(&"hungry", is_hungry(), ThoughtCatalog.hungry())
_sync_persistent_thought(&"tired", is_tired(), ThoughtCatalog.tired())
# 3. Recompute if EVENT thoughts expired (persistent syncs call _recompute_mood internally).
if dirty:
_recompute_mood()
# 4. Sulking transitions.
_process_sulking()
## Add or remove a PERSISTENT thought based on a boolean state flag.
## Calls add_thought() / remove_thought_by_id() (which recompute mood) only
## when the presence actually needs to change — avoids redundant recomputes.
func _sync_persistent_thought(id: StringName, state_active: bool, factory_result: Thought) -> void:
var present := has_thought(id)
if state_active and not present:
add_thought(factory_result)
elif not state_active and present:
remove_thought_by_id(id)
## Recompute mood from scratch using the locked formula:
## base 50 + sum(modifier × min(stacks, max_stacks))
## Clamps to [0, 100] and emits EventBus.pawn_mood_changed.
## (docs/architecture.md "MoodSystem" — MAX_STACKS_PER_THOUGHT = 5 locked)
func _recompute_mood() -> void:
var m := MOOD_BASE
for t in thoughts:
m += float(t.modifier) * float(mini(t.stacks, t.max_stacks))
mood = clampf(m, 0.0, 100.0)
EventBus.pawn_mood_changed.emit(self, mood)
## Track sustained low-mood ticks and transition sulking state accordingly.
##
## Enter sulking: mood has been < MOOD_SULK_THRESHOLD for MOOD_SULK_SUSTAIN_TICKS
## consecutive ticks (30 in-game min at 1× — docs/design.md "Breaking" band).
## Exit sulking: mood recovers to >= MOOD_SULK_RECOVERY (35).
## _sulk_low_ticks resets whenever mood rises above the threshold, so momentary
## dips do not trigger a break.
func _process_sulking() -> void:
if mood < MOOD_SULK_THRESHOLD:
_sulk_low_ticks += 1
if not sulking and _sulk_low_ticks >= MOOD_SULK_SUSTAIN_TICKS:
sulking = true
Audit.log("pawn", "%s soft-break: SULKING (mood=%.1f)" % [pawn_name, mood])
else:
_sulk_low_ticks = 0
if sulking and mood >= MOOD_SULK_RECOVERY:
sulking = false
Audit.log("pawn", "%s recovered from sulking (mood=%.1f)" % [pawn_name, mood])
# ── save / load ─────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
@ -178,6 +347,10 @@ func to_dict() -> Dictionary:
var skills_data: Dictionary = {}
for skill in ALL_SKILLS:
skills_data[String(skill)] = get_skill(skill)
# Serialise Phase 8 thoughts as an array of thought dicts.
var thoughts_data: Array = []
for t in thoughts:
thoughts_data.append(t.to_dict())
return {
"name": pawn_name,
"tile_x": tile.x,
@ -189,6 +362,11 @@ func to_dict() -> Dictionary:
"job_runner": job_runner.to_dict() if job_runner != null else null,
"skills": skills_data,
"hunger": hunger,
"sleep": sleep,
"thoughts": thoughts_data,
"mood": mood,
"sulk_low_ticks": _sulk_low_ticks,
"sulking": sulking,
}
@ -212,6 +390,18 @@ func from_dict(d: Dictionary) -> void:
# Phase 7 — restore hunger; default to full if missing (pre-Phase-7 save compat).
hunger = clampf(float(d.get("hunger", HUNGER_MAX)), 0.0, HUNGER_MAX)
# Phase 8 — restore sleep; default to full if missing (pre-Phase-8 save compat).
sleep = clampf(float(d.get("sleep", SLEEP_MAX)), 0.0, SLEEP_MAX)
# Phase 8 — restore thoughts, mood, and sulking state.
thoughts.clear()
var thoughts_raw: Variant = d.get("thoughts", [])
if thoughts_raw is Array:
for td in thoughts_raw:
if td is Dictionary:
thoughts.append(Thought.from_dict(td))
mood = clampf(float(d.get("mood", MOOD_BASE)), 0.0, 100.0)
_sulk_low_ticks = int(d.get("sulk_low_ticks", 0))
sulking = bool(d.get("sulking", false))
# Restore skills — set directly on the dict to bypass the ALL_SKILLS assert
# (from_dict must be resilient to saves that pre-date a new skill being added).
@ -235,6 +425,12 @@ func _on_sim_tick(_tick_number: int) -> void:
# Phase 7 — decay hunger before orchestration so the AI sees the updated value
# this tick and can immediately seek food once hunger < 30.
hunger = maxf(0.0, hunger - HUNGER_DECAY_PER_TICK)
# Phase 8 — decay sleep before orchestration so the AI sees the updated value
# this tick and can immediately seek a bed once sleep < 30.
sleep = maxf(0.0, sleep - SLEEP_DECAY_PER_TICK)
# Phase 8 — process thoughts AFTER hunger/sleep decay so is_hungry() / is_tired()
# reflect the freshly-decayed values when _sync_persistent_thought fires.
_process_thoughts()
_orchestrate_ai()
_advance_walk()
# Phase 4 — the carry indicator changes when PICKUP/DEPOSIT toils mutate