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:
parent
61dcf6760b
commit
43e52ffe75
18 changed files with 920 additions and 6 deletions
|
|
@ -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 0–10. 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 0–100. 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 (0–10) 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue