From 43e52ffe7568a08d6c092f26470098917396b138 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Mon, 11 May 2026 13:21:15 +0100 Subject: [PATCH] =?UTF-8?q?Phase=208=20=E2=80=94=20Beds,=20sleep=20need,?= =?UTF-8?q?=20thoughts,=20mood,=20Sulking=20soft-break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- autoload/event_bus.gd | 3 + autoload/world.gd | 15 ++ docs/implementation.md | 3 +- scenes/ai/decision.gd | 9 +- scenes/ai/job_runner.gd | 121 ++++++++++++++ scenes/ai/sleep_provider.gd | 84 ++++++++++ scenes/ai/sleep_provider.gd.uid | 1 + scenes/ai/thought.gd | 79 +++++++++ scenes/ai/thought.gd.uid | 1 + scenes/ai/thought_catalog.gd | 92 +++++++++++ scenes/ai/thought_catalog.gd.uid | 1 + scenes/ai/toil.gd | 20 +++ scenes/entities/bed.gd | 267 +++++++++++++++++++++++++++++++ scenes/entities/bed.gd.uid | 1 + scenes/entities/bed.tscn | 8 + scenes/pawn/pawn.gd | 196 +++++++++++++++++++++++ scenes/world/world.gd | 19 ++- scenes/world/world.tscn | 6 +- 18 files changed, 920 insertions(+), 6 deletions(-) create mode 100644 scenes/ai/sleep_provider.gd create mode 100644 scenes/ai/sleep_provider.gd.uid create mode 100644 scenes/ai/thought.gd create mode 100644 scenes/ai/thought.gd.uid create mode 100644 scenes/ai/thought_catalog.gd create mode 100644 scenes/ai/thought_catalog.gd.uid create mode 100644 scenes/entities/bed.gd create mode 100644 scenes/entities/bed.gd.uid create mode 100644 scenes/entities/bed.tscn diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd index 720704e..8d6c4b5 100644 --- a/autoload/event_bus.gd +++ b/autoload/event_bus.gd @@ -13,3 +13,6 @@ signal speed_changed(new_speed: int) ## Emitted when Sim.current_speed changes; # Phase 5 — Designation paint mode. signal designation_added(cell: Vector2i, tool: StringName) ## Ghost placed + job queued. signal designation_cleared(cell: Vector2i) ## Ghost removed (job cancelled). + +# Phase 8 — Mood system. +signal pawn_mood_changed(pawn, mood: float) ## Emitted by Pawn._recompute_mood() whenever mood is recalculated. diff --git a/autoload/world.gd b/autoload/world.gd index fa26793..01aa8b8 100644 --- a/autoload/world.gd +++ b/autoload/world.gd @@ -48,6 +48,12 @@ var workbenches: Array = [] # harvestable (READY) and sowable (TILLED) crops for eligible pawns. var crops: Array = [] +# Phase 8 — bed entities. Bed._ready() calls register_bed(); +# _exit_tree() calls unregister_bed(). SleepProvider iterates this to find +# available (completed, unoccupied) beds for tired pawns. +# Storyteller also reads beds.size() for the "First Beds" state predicate. +var beds: Array = [] + # Phase 4 — hauling dirty set. Keys are Items, value is unused (we just use .keys()). # An Item is added when it spawns (Tree.fell, Rock.mined, workbench drop, ...) # and removed when it lands at its highest-priority valid destination. @@ -178,6 +184,15 @@ func unregister_crop(c) -> void: crops.erase(c) +func register_bed(b) -> void: + if not beds.has(b): + beds.append(b) + + +func unregister_bed(b) -> void: + beds.erase(b) + + # Called by Wall.on_build_tick() when construction completes. # Stamps the data-only Wall TileMap layer so room/roof/save logic sees the # wall. World scene exposes wall_layer via a getter set during _ready. diff --git a/docs/implementation.md b/docs/implementation.md index 349188c..d4c11ad 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -14,7 +14,8 @@ Effort estimates are wall-time at **focused solo pace**. Scale up generously for | ✅ done — Designation paint mode, BuildJob queue, ConstructionProvider, Wall/Floor/Door entities (Y-sorted), Crate as StorageDestination. **Rendering pivot to 3/4 perspective locked.** | **Phase 5 — Building, walls, floors, containers** | | ✅ done — Recipe + Bill data, Workbench entity (Carpenter / Smelter via label_text), CraftingProvider, KIND_CRAFT toil, 5-tier Quality system, Pawn skills, wall-trap fix | **Phase 6 — Production: workbenches, recipes, bills, quality** | | ✅ done — Crop entity (6-stage state machine), PlantProvider (harvest), Hunger need + EatProvider w/ food-priority ladder, Hearth/Millstone via label_text, grain/flour/bread/meal types | **Phase 7 — Plants, cooking, hunger** | -| ⏳ next | **Phase 8 — Sleep, mood, thoughts** | +| ✅ done — Bed entity (quality-tinted, claim/release), Sleep need + SleepProvider + KIND_SLEEP toil, Thought registry + mood compute + Sulking soft-break, Decision Layer-1 interrupt | **Phase 8 — Sleep, mood, thoughts** | +| ⏳ next | **Phase 9 — Status effects + Medicine** | Use this doc as a checklist: tick boxes as items complete, and update the **Status** row above whenever a phase rolls over. The last bullet of each phase is the *acceptance demo* — the phase is "done" when you can perform it. diff --git a/scenes/ai/decision.gd b/scenes/ai/decision.gd index 1b47d3c..34e969a 100644 --- a/scenes/ai/decision.gd +++ b/scenes/ai/decision.gd @@ -19,10 +19,15 @@ class_name Decision ## `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 ────────────────────────────────────────────── - # has_method probe so this doesn't break before Phase 9 adds the method. + # ── 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: diff --git a/scenes/ai/job_runner.gd b/scenes/ai/job_runner.gd index cb7d36f..18ee299 100644 --- a/scenes/ai/job_runner.gd +++ b/scenes/ai/job_runner.gd @@ -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. diff --git a/scenes/ai/sleep_provider.gd b/scenes/ai/sleep_provider.gd new file mode 100644 index 0000000..4921426 --- /dev/null +++ b/scenes/ai/sleep_provider.gd @@ -0,0 +1,84 @@ +class_name SleepProvider extends WorkProvider +## WorkProvider for the Sleep work category. Slots into the 5-layer pawn AI +## (Decision → WorkProvider → Job + JobRunner) at priority 8 — above eating (7) +## so a maximally tired AND hungry pawn prioritises rest. +## +## When a pawn is tired and not carrying an item, scans World.beds for the +## nearest available bed and builds a 2-toil sleep job: walk → sleep. +## If no bed is available, the pawn sleeps on the floor at its current tile +## (walk toil omitted since it's already there). +## +## Bed claim race: the bed is NOT claimed here. Claiming in find_best_for() +## would leak a held claim if the job is later cancelled by a player override +## (Layer 5) before execution starts. Instead _tick_sleep in JobRunner claims +## on first tick, when the pawn has actually arrived and is ready to sleep. +## If the claim fails at that point (another pawn beat us) the fallback is +## floor sleep — no deadlock, no leaked claim. +## +## Pawn and Bed are intentionally duck-typed to avoid the class_name +## registration-order trap documented in Phase 2/3. +## +## See docs/architecture.md "Pawn AI 5-layer pipeline" and +## docs/design.md "Sleep mood" gradient. + + +func _init() -> void: + category = &"sleep" + # Priority 8 > eat (7) > construction (6) > hauling (3) > rest (0). + # Phase 9 status interrupts will preempt from Decision layer 3 once the + # Status registry lands — for now sleep rides at the WorkProvider level. + priority = 8 + + +# ── WorkProvider override ───────────────────────────────────────────────────── + +## Returns a sleep Job for `pawn`, or null if the pawn is not tired. +## The job has 1 or 2 toils: +## - walk_to(target_tile) [omitted when target == pawn.tile] +## - sleep_in_bed(bed_path) [bed_path is empty string for floor sleep] +## +## `pawn` is duck-typed: must expose .is_tired(), .carried_item, .tile (Vector2i). +func find_best_for(pawn) -> Job: + if not pawn.is_tired(): + return null + # Don't interrupt an ongoing carry — finish the carry before going to sleep. + if pawn.carried_item != null: + return null + + # Scan World.beds for the nearest available bed (duck-typed — Bed exposes + # .is_available() -> bool and .tile -> Vector2i). + var best_bed = null + var best_dist: int = 999999 + for bed in World.beds: + if not bed.is_available(): + continue + var d: int = abs(bed.tile.x - pawn.tile.x) + abs(bed.tile.y - pawn.tile.y) + if d < best_dist: + best_dist = d + best_bed = bed + + # Resolve target tile and bed NodePath. An empty NodePath means floor sleep. + var target_tile: Vector2i + var bed_path: NodePath + if best_bed != null: + target_tile = best_bed.tile + bed_path = best_bed.get_path() + else: + # Phase 8 fallback — sleep on the floor at the current tile. + # The Mood system (Agent C, Phase 8) will fire a "slept_on_floor" thought + # via the thought registry; analogous to EatProvider's "ate raw food". + target_tile = pawn.tile + bed_path = NodePath("") + + var j := Job.new() + if best_bed != null: + j.label = "Sleep at %s" % target_tile + else: + j.label = "Sleep on the floor at %s" % target_tile + + # Only prepend a walk if the pawn is not already at the target. + if pawn.tile != target_tile: + j.toils.append(Toil.walk_to(target_tile)) + + j.toils.append(Toil.sleep_in_bed(bed_path)) + return j diff --git a/scenes/ai/sleep_provider.gd.uid b/scenes/ai/sleep_provider.gd.uid new file mode 100644 index 0000000..4017414 --- /dev/null +++ b/scenes/ai/sleep_provider.gd.uid @@ -0,0 +1 @@ +uid://bu0ekfg0w7q3c diff --git a/scenes/ai/thought.gd b/scenes/ai/thought.gd new file mode 100644 index 0000000..59aeb6a --- /dev/null +++ b/scenes/ai/thought.gd @@ -0,0 +1,79 @@ +class_name Thought extends RefCounted +## A single mood modifier entry, either state-driven (PERSISTENT) or +## one-shot event-driven (EVENT). +## +## PERSISTENT thoughts are added / removed by Pawn._refresh_persistent_thoughts +## each sim tick based on the pawn's current state (e.g. is_hungry()). +## They have no ticks_remaining — they exist for exactly as long as the +## triggering state is true. +## +## EVENT thoughts fire once on a game event (eating a meal, sleeping in a bed) +## and decay after ticks_remaining reaches zero. They stack up to max_stacks — +## for example, eating multiple meals in a row adds stacks to &"ate_meal" +## rather than duplicating entries. +## +## Save/load contract: +## var t2 := Thought.from_dict(t.to_dict()) +## assert(t2.id == t.id and t2.stacks == t.stacks) +## +## docs/architecture.md "MoodSystem" + MAX_STACKS_PER_THOUGHT = 5 (locked). + +enum Lifetime { + PERSISTENT, ## Present while a triggering state is true; Pawn manages add/remove. + EVENT, ## Fires once on a transition; decays after ticks_remaining ticks. +} + +## Hard cap per thought type (docs/architecture.md "MoodSystem" — locked). +const MAX_STACKS_PER_THOUGHT: int = 5 + +## Unique identifier, e.g. &"hungry", &"well_rested". Used as the merge key. +var id: StringName = &"" + +## Human-readable label for Audit logs and future pawn-detail UI. +## Not i18n'd here; call Strings.t("thought." + id) for player-visible text. +var label: String = "" + +## Mood delta per stack. Negative = bad, positive = good. +## Typical range -8..+8. Capped at max_stacks when computing mood. +var modifier: int = 0 + +## Whether this thought decays over time (EVENT) or tracks live state (PERSISTENT). +var lifetime: Lifetime = Lifetime.EVENT + +## Per-thought stack ceiling. Usually MAX_STACKS_PER_THOUGHT; some thoughts are +## capped lower (e.g. hungry is max_stacks=1 because it's binary present/absent). +var max_stacks: int = MAX_STACKS_PER_THOUGHT + +# ── Runtime state (owned by the Pawn after add_thought()) ──────────────────── + +## Number of times this thought is currently stacked (1..max_stacks). +var stacks: int = 1 + +## Remaining sim ticks before this EVENT thought expires. PERSISTENT ignores this. +var ticks_remaining: int = 0 + + +# ── save / load ─────────────────────────────────────────────────────────────── + +func to_dict() -> Dictionary: + return { + "id": String(id), + "label": label, + "modifier": modifier, + "lifetime": lifetime, + "max_stacks": max_stacks, + "stacks": stacks, + "ticks_remaining": ticks_remaining, + } + + +static func from_dict(d: Dictionary) -> Thought: + var t := Thought.new() + t.id = StringName(d.get("id", "")) + t.label = str(d.get("label", "")) + t.modifier = int(d.get("modifier", 0)) + t.lifetime = int(d.get("lifetime", Lifetime.EVENT)) as Lifetime + t.max_stacks = int(d.get("max_stacks", MAX_STACKS_PER_THOUGHT)) + t.stacks = int(d.get("stacks", 1)) + t.ticks_remaining = int(d.get("ticks_remaining", 0)) + return t diff --git a/scenes/ai/thought.gd.uid b/scenes/ai/thought.gd.uid new file mode 100644 index 0000000..44a4075 --- /dev/null +++ b/scenes/ai/thought.gd.uid @@ -0,0 +1 @@ +uid://dola0cuqx2pt diff --git a/scenes/ai/thought_catalog.gd b/scenes/ai/thought_catalog.gd new file mode 100644 index 0000000..0da496e --- /dev/null +++ b/scenes/ai/thought_catalog.gd @@ -0,0 +1,92 @@ +class_name ThoughtCatalog +## Static factory registry for named thoughts. +## +## Phase 8 ships 5 thoughts (hungry, tired, well_rested, slept_on_floor, +## ate_meal). Phase 17 expands with: slept_in_good_bed (quality tiers), +## ate_raw_food, witnessed_corpse, in_darkness, cramped_quarters, +## beautiful_room, ugly_room, damp, soaked, cold. +## +## Usage pattern: +## pawn.add_thought(ThoughtCatalog.ate_meal()) +## +## Each factory returns a fresh Thought with all fields set to correct defaults +## for that thought type. Callers must not mutate the returned object before +## passing it to add_thought() — add_thought() handles stack merging. +## +## docs/architecture.md "MoodSystem"; docs/design.md "Thought list (~13)". + + +# ── PERSISTENT thoughts ─────────────────────────────────────────────────────── +# Pawn._refresh_persistent_thoughts adds / removes these based on live state. +# max_stacks=1 because each is binary (either hungry or not). + +## Mood penalty while pawn.is_hungry() is true. +## modifier=-6, max_stacks=1, PERSISTENT. +static func hungry() -> Thought: + var t := Thought.new() + t.id = &"hungry" + t.label = "Hungry" + t.modifier = -6 + t.lifetime = Thought.Lifetime.PERSISTENT + t.max_stacks = 1 + return t + + +## Mood penalty while pawn.is_tired() is true. +## modifier=-4, max_stacks=1, PERSISTENT. +static func tired() -> Thought: + var t := Thought.new() + t.id = &"tired" + t.label = "Tired" + t.modifier = -4 + t.lifetime = Thought.Lifetime.PERSISTENT + t.max_stacks = 1 + return t + + +# ── EVENT thoughts ──────────────────────────────────────────────────────────── +# Fire on a transition and decay after ticks_remaining reaches zero. +# ticks_remaining is in sim ticks at 1× speed (20 Hz). +# ~10 in-game min at 1× = 1200 ticks (20 ticks/s × 60 s/min × 10 min). + +## Positive mood boost after waking from a full bed-sleep. +## Fires in _tick_sleep (Agent B) when had_bed=true. +## modifier=+5, max_stacks=1, EVENT, ~10 in-game min at 1×. +static func well_rested() -> Thought: + var t := Thought.new() + t.id = &"well_rested" + t.label = "Well rested" + t.modifier = 5 + t.lifetime = Thought.Lifetime.EVENT + t.ticks_remaining = 1200 + t.max_stacks = 1 + return t + + +## Mood penalty after sleeping without a bed. +## Fires in _tick_sleep (Agent B) when had_bed=false. +## modifier=-5, max_stacks=1, EVENT, ~10 in-game min at 1×. +static func slept_on_floor() -> Thought: + var t := Thought.new() + t.id = &"slept_on_floor" + t.label = "Slept on the floor" + t.modifier = -5 + t.lifetime = Thought.Lifetime.EVENT + t.ticks_remaining = 1200 + t.max_stacks = 1 + return t + + +## Small mood boost after eating a cooked meal or bread. +## Fires in _tick_eat when item_type is TYPE_MEAL or TYPE_BREAD. +## Stacks up to 3 (multiple good meals compound, but cap at 3). +## modifier=+3, max_stacks=3, EVENT, ~800 ticks (~40 in-game sec at 1×). +static func ate_meal() -> Thought: + var t := Thought.new() + t.id = &"ate_meal" + t.label = "Ate a meal" + t.modifier = 3 + t.lifetime = Thought.Lifetime.EVENT + t.ticks_remaining = 800 + t.max_stacks = 3 + return t diff --git a/scenes/ai/thought_catalog.gd.uid b/scenes/ai/thought_catalog.gd.uid new file mode 100644 index 0000000..1d161b7 --- /dev/null +++ b/scenes/ai/thought_catalog.gd.uid @@ -0,0 +1 @@ +uid://cwhkbh8cm37uf diff --git a/scenes/ai/toil.gd b/scenes/ai/toil.gd index 90b6c2c..2b285a8 100644 --- a/scenes/ai/toil.gd +++ b/scenes/ai/toil.gd @@ -19,6 +19,7 @@ const KIND_DEPOSIT: StringName = &"deposit" # Place pawn.carried_item at paw const KIND_BUILD: StringName = &"build" # Timed construction on a Wall / Floor / Door entity const KIND_CRAFT: StringName = &"craft" # Timed crafting at a Workbench driven by a Bill const KIND_EAT: StringName = &"eat" # Consume pawn.carried_item and restore hunger +const KIND_SLEEP: StringName = &"sleep" # Sleep in a Bed (or on the floor) until pawn.sleep is full var kind: StringName = KIND_IDLE ## Toil-specific params — all values must be int, float, bool, String, Dict, or Array. @@ -115,6 +116,25 @@ static func eat() -> Toil: return t +## Sleep in the given Bed until pawn.sleep is full. +## `bed_path` is the NodePath of the Bed entity, stored as String for JSON safety. +## An empty string means "sleep on the floor" — no bed is claimed. +## +## data keys: +## "bed" — String(bed_path); empty means floor sleep. +## "started" — bool; false on first tick, true after claim resolved. +## "ticks_slept" — int; incremented each tick for audit logging + emergency wake. +static func sleep_in_bed(bed_path: NodePath) -> Toil: + var t := Toil.new() + t.kind = KIND_SLEEP + t.data = { + "bed": String(bed_path), + "started": false, + "ticks_slept": 0, + } + return t + + ## Timed crafting action at a Workbench. ## `workbench_path` is the NodePath of the Workbench entity (stored as String for JSON safety). ## `bill_index` is the index into workbench.bills that this toil should run. diff --git a/scenes/entities/bed.gd b/scenes/entities/bed.gd new file mode 100644 index 0000000..9fac915 --- /dev/null +++ b/scenes/entities/bed.gd @@ -0,0 +1,267 @@ +class_name Bed extends Node2D +## Bed furniture entity — buildable, optionally pawn-owned, quality-affected sleep. +## +## Rendered as a bottom-anchored 3/4-perspective sprite within the 16×16 tile, +## matching the workbench / wall / door rendering convention. Ghost state (40% +## alpha) while construction is in progress; solid once _completed. +## +## Quality tints the sheet colour (SLEEP_MOOD_BY_QUALITY maps Item.Quality int +## to the mood modifier awarded when a pawn finishes sleeping here). Phase 8 +## spawns beds at NORMAL quality; Phase 17+ may roll quality from crafter skill. +## +## Build model (docs/implementation.md Phase 8): +## BUILD_TICKS ticks via the standard BuildJob toil (same shape as Wall/Crate/ +## Workbench). blocks_pathing_when_complete() returns false — pawns walk ONTO +## the bed tile to sleep. +## +## Occupancy model: +## _owner_pawn — null = unowned (any tired pawn may use); Phase 8 leaves null. +## _occupant_pawn — set by SleepProvider.claim(); released on SleepProvider.release(). +## is_available() — true when completed AND no current occupant. +## claim(pawn) — atomically sets _occupant_pawn; returns false if unavailable. +## release() — clears _occupant_pawn. +## +## Save/load: +## to_dict() serialises all persistent fields. _occupant_pawn is always saved +## as null (sleep is mid-toil state; the JobRunner saves its own side). Re-wiring +## owner_pawn from name → Pawn reference is deferred to Phase 16's full save layer. +## +## World registration: World.register_bed / World.unregister_bed called from +## _ready / _exit_tree. + +const TILE_SIZE_PX: int = 16 + +## Sim ticks to build a bed (80 ticks ≈ 4 sim seconds at 1×). +const BUILD_TICKS: int = 80 + +## Sleep mood modifier indexed by Item.Quality int (SHODDY=0 … LEGENDARY=4). +## Applied via the "slept_in_X" thought when a pawn finishes a sleep job. +## Numbers are design.md placeholders — tune in Phase 20. +## SHODDY=-8, NORMAL=-2, EXCELLENT=0, MASTERWORK=5, LEGENDARY=8 +const SLEEP_MOOD_BY_QUALITY: Array[int] = [-8, -2, 0, 5, 8] + +# ── exports ─────────────────────────────────────────────────────────────────── + +## Tile position of this bed in world-tile coordinates. +@export var tile: Vector2i = Vector2i.ZERO + +## Quality tier as an int matching Item.Quality enum (0=SHODDY … 4=LEGENDARY). +## Determines sheet colour and sleep mood modifier. +@export var quality: int = 1 + +## Player-visible name. Defaults to "Bed"; extended types (medical bed, etc.) +## can override via label_text without needing a subclass. +@export var label_text: String = "Bed" + +# ── state ───────────────────────────────────────────────────────────────────── + +## Ticks of construction work applied so far. 0..BUILD_TICKS. +var build_progress: int = 0 + +## True once build_progress >= BUILD_TICKS. +var _completed: bool = false + +## The pawn who "owns" this bed (has first right of use). null = unowned. +## Phase 8 leaves this null; Phase 17 wires per-pawn bed assignment UI. +var _owner_pawn = null + +## The pawn currently lying in this bed. Set by claim(); cleared by release(). +## Always null on save (SleepProvider re-claims after load if the pawn still +## has an active sleep job; the JobRunner handles reconnection). +var _occupant_pawn = null + + +# ── lifecycle ───────────────────────────────────────────────────────────────── + +func _ready() -> void: + # Bottom-anchor: position.y at tile bottom so Y-sort occludes pawns correctly. + position = Vector2( + tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0, + tile.y * TILE_SIZE_PX + TILE_SIZE_PX + ) + World.register_bed(self) + queue_redraw() + + +func _exit_tree() -> void: + World.unregister_bed(self) + + +## One-shot initialiser. Call after add_child() so _ready() has fired. +func setup(p_tile: Vector2i) -> void: + tile = p_tile + position = Vector2( + tile.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0, + tile.y * TILE_SIZE_PX + TILE_SIZE_PX + ) + queue_redraw() + + +# ── BuildJob interface (matches Wall / Crate / Workbench shape) ─────────────── + +## True while the bed still needs construction work. +## JobRunner's BUILD toil checks this to decide when the toil is done. +func is_buildable() -> bool: + return not _completed + + +## Human-readable label for job descriptions and Audit logs. +func label() -> String: + return label_text + + +## Called by the BUILD toil in JobRunner once per sim tick while the pawn works. +## Advances build_progress and completes the bed at BUILD_TICKS. +func on_build_tick() -> void: + if _completed: + return + build_progress += 1 + queue_redraw() + if build_progress >= BUILD_TICKS: + _complete() + + +## True once the bed has been fully built. +func is_completed() -> bool: + return _completed + + +## Beds remain walkable after completion — pawns walk ONTO the tile to sleep. +func blocks_pathing_when_complete() -> bool: + return false + + +# ── occupancy ───────────────────────────────────────────────────────────────── + +## Returns true when this bed is built and no pawn is currently using it. +## SleepProvider calls this to filter candidate beds before claiming. +func is_available() -> bool: + return _completed and _occupant_pawn == null + + +## Atomically claim this bed for `pawn`. Returns false if not available. +## SleepProvider calls this before starting the sleep toil; on false the +## provider must look for another bed. +func claim(pawn) -> bool: + if not is_available(): + return false + _occupant_pawn = pawn + return true + + +## Release this bed when the sleep job ends (normally or interrupted). +## SleepProvider calls this from its on_complete / on_cancel hook. +func release() -> void: + _occupant_pawn = null + + +# ── save / load ─────────────────────────────────────────────────────────────── + +## Serialise all persistent state for World save (wired in Phase 16). +## _occupant_pawn is always saved as null — the JobRunner holds the sleep +## toil state and the SleepProvider re-claims the bed after load. +func to_dict() -> Dictionary: + var owner_name = null + if _owner_pawn != null and _owner_pawn.has_method("get"): + owner_name = _owner_pawn.get("pawn_name") + return { + "tile_x": tile.x, + "tile_y": tile.y, + "quality": quality, + "label_text": label_text, + "build_progress": build_progress, + "completed": _completed, + "owner_pawn_name": owner_name, + # occupant_pawn always null on save — SleepProvider reconnects after load. + "occupant_pawn": null, + } + + +## Restore from a dict produced by to_dict(). +## owner_pawn re-wiring (name → Pawn reference) is deferred to Phase 16. +func from_dict(d: Dictionary) -> void: + tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0))) + quality = int(d.get("quality", 1)) + label_text = str(d.get("label_text", "Bed")) + build_progress = int(d.get("build_progress", 0)) + _completed = bool(d.get("completed", false)) + # owner_pawn: Phase 16 will walk World.pawns and match by pawn_name. + _owner_pawn = null + _occupant_pawn = null + setup(tile) + + +# ── render ───────────────────────────────────────────────────────────────────── + +func _draw() -> void: + # 3/4-perspective bed — fits within the tile (16×16 local box). + # Origin (0, 0) = tile bottom-centre. Tile spans local Y: -16 to 0. + # + # Layout (bottom-anchored, same as Wall / Workbench): + # Top band (5 px, Y -16..-11) — top surface of the bed frame (lit) + # Body band (8 px, Y -11..-3) — sheet / quilt, quality-tinted + # Leg band (3 px, Y -3.. 0) — bed-frame legs / base + # + # The pillow is a 6×3 rect inset at the top of the body band. + # Quality tints the sheet; the frame/legs stay a constant warm brown. + # Ghost state draws at 0.4 alpha. + var alpha: float = 1.0 if _completed else 0.4 + _draw_bed(alpha) + + +func _draw_bed(alpha: float) -> void: + # Frame colours — same for all quality tiers (wood frame). + var frame_top := Color(0.52, 0.38, 0.20, alpha) # lit top surface + var frame_dark := Color(0.34, 0.24, 0.12, alpha) # shaded legs / base + var frame_edge := Color(0.28, 0.18, 0.08, alpha) # top-front horizon + var outline := Color(0.20, 0.12, 0.04, 0.70 * alpha) + + # Sheet colour varies by quality tier. + var sheet_color := _sheet_color_for_quality(quality, alpha) + + # Pillow: light cream, inset at top-centre of the body band. + var pillow := Color(0.95, 0.92, 0.85, alpha) + + # ── top surface (lit band) ──────────────────────────────────────────────── + draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 5.0)), frame_top) + + # ── sheet / body band ───────────────────────────────────────────────────── + draw_rect(Rect2(Vector2(-7.0, -11.0), Vector2(14.0, 8.0)), sheet_color) + + # Pillow: 6×3, horizontally centred, flush with the top of the body band. + draw_rect(Rect2(Vector2(-3.0, -11.0), Vector2(6.0, 3.0)), pillow) + + # ── base / legs band ────────────────────────────────────────────────────── + draw_rect(Rect2(Vector2(-8.0, -3.0), Vector2(16.0, 3.0)), frame_dark) + + # ── horizon line (top → front depth edge) ──────────────────────────────── + draw_line(Vector2(-8.0, -11.0), Vector2(8.0, -11.0), frame_edge, 1.0) + + # ── outline ─────────────────────────────────────────────────────────────── + draw_rect(Rect2(Vector2(-8.0, -16.0), Vector2(16.0, 16.0)), outline, false, 1.0) + + +## Returns the sheet fill colour for the given quality int. +## Quality indices: 0=SHODDY, 1=NORMAL, 2=EXCELLENT, 3=MASTERWORK, 4=LEGENDARY. +func _sheet_color_for_quality(q: int, alpha: float) -> Color: + match q: + 0: # SHODDY — drab grey-brown + return Color(0.45, 0.40, 0.35, alpha) + 1: # NORMAL — warm tan + return Color(0.55, 0.40, 0.30, alpha) + 2: # EXCELLENT — cool blue + return Color(0.30, 0.45, 0.65, alpha) + 3: # MASTERWORK — gold-brown + return Color(0.65, 0.45, 0.20, alpha) + 4: # LEGENDARY — regal pink + return Color(0.75, 0.40, 0.55, alpha) + _: # fallback — same as NORMAL + return Color(0.55, 0.40, 0.30, alpha) + + +# ── internal ────────────────────────────────────────────────────────────────── + +func _complete() -> void: + _completed = true + queue_redraw() + Audit.log("bed", "%s built at %s" % [label_text, tile]) diff --git a/scenes/entities/bed.gd.uid b/scenes/entities/bed.gd.uid new file mode 100644 index 0000000..42e5cfe --- /dev/null +++ b/scenes/entities/bed.gd.uid @@ -0,0 +1 @@ +uid://e2ao53rg434d diff --git a/scenes/entities/bed.tscn b/scenes/entities/bed.tscn new file mode 100644 index 0000000..1c2164a --- /dev/null +++ b/scenes/entities/bed.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3 uid="uid://bed_entity"] + +[ext_resource type="Script" path="res://scenes/entities/bed.gd" id="1_bed"] + +[node name="Bed" type="Node2D"] +y_sort_enabled = true +z_index = 0 +script = ExtResource("1_bed") diff --git a/scenes/pawn/pawn.gd b/scenes/pawn/pawn.gd index 5b7774a..9e0ef28 100644 --- a/scenes/pawn/pawn.gd +++ b/scenes/pawn/pawn.gd @@ -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 diff --git a/scenes/world/world.gd b/scenes/world/world.gd index f8c8705..8e6d752 100644 --- a/scenes/world/world.gd +++ b/scenes/world/world.gd @@ -28,6 +28,7 @@ const CRATE_SCENE: PackedScene = preload("res://scenes/world/crate.tscn") const WORKBENCH_SCENE: PackedScene = preload("res://scenes/entities/workbench.tscn") const CROP_SCENE: PackedScene = preload("res://scenes/entities/crop.tscn") const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn") +const BED_SCENE: PackedScene = preload("res://scenes/entities/bed.tscn") # 3 starting pawns — Phase 2 demo. Phase 7+ replaces this with map-gen + name table. const SAMPLE_PAWNS: Array[Dictionary] = [ @@ -67,6 +68,7 @@ const HAUL_SWEEP_INTERVAL_TICKS: int = 100 @onready var crafting_provider: CraftingProvider = $CraftingProvider @onready var plant_provider: PlantProvider = $PlantProvider @onready var eat_provider: EatProvider = $EatProvider +@onready var sleep_provider: SleepProvider = $SleepProvider func _ready() -> void: @@ -96,9 +98,10 @@ func _ready() -> void: # Designation: bind the paint surface + the Selection guard. designation_ctl.bind(designation_layer, selection) - # Register all 8 providers — Decision iterates by .priority desc. - # eat=7 > construction=6 > chop=5 > mine=4 ≈ crafting=4 > plant=3 ≈ haul=3 > rest=0. + # Register all 9 providers — Decision iterates by .priority desc. + # sleep=8 > eat=7 > construction=6 > chop=5 ≈ plant=5 > mine=4 ≈ crafting=4 > haul=3 > rest=0. # Phase 17 will tune these via the work-priority matrix UI. + World.register_work_provider(sleep_provider) World.register_work_provider(eat_provider) World.register_work_provider(construction_provider) World.register_work_provider(chop_provider) @@ -380,6 +383,18 @@ func _seed_phase5_demo_buildings() -> void: crop_tiles.size(), snack_tiles.size() ]) + # Phase 8 demo — 3 beds inside the cabin's north row. Beds are pre-built + # (skip construction) so pawns have somewhere to sleep on first tired tick. + # (45, 24), (47, 24), (49, 24) — leaves (50, 24) for the existing crate. + var bed_tiles: Array[Vector2i] = [Vector2i(45, 24), Vector2i(47, 24), Vector2i(49, 24)] + for bt in bed_tiles: + var bed: Bed = BED_SCENE.instantiate() + add_child(bed) + bed.setup(bt) + while bed.is_buildable(): + bed.on_build_tick() + Audit.log("world", "phase 8 demo: %d beds pre-built inside cabin" % bed_tiles.size()) + func _spawn_sample_stockpiles() -> void: # Two zones for the Phase 4 acceptance demo: diff --git a/scenes/world/world.tscn b/scenes/world/world.tscn index e97ac07..d39c503 100644 --- a/scenes/world/world.tscn +++ b/scenes/world/world.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=14 format=3 uid="uid://rimlike_world"] +[gd_scene load_steps=15 format=3 uid="uid://rimlike_world"] [ext_resource type="Script" path="res://scenes/world/world.gd" id="1_world"] [ext_resource type="PackedScene" uid="uid://rimlike_camera_rig" path="res://scenes/world/camera_rig.tscn" id="2_camera"] @@ -13,6 +13,7 @@ [ext_resource type="Script" path="res://scenes/ai/crafting_provider.gd" id="11_crafting_provider"] [ext_resource type="Script" path="res://scenes/ai/plant_provider.gd" id="12_plant_provider"] [ext_resource type="Script" path="res://scenes/ai/eat_provider.gd" id="13_eat_provider"] +[ext_resource type="Script" path="res://scenes/ai/sleep_provider.gd" id="14_sleep_provider"] [node name="World" type="Node2D"] y_sort_enabled = true @@ -74,5 +75,8 @@ script = ExtResource("12_plant_provider") [node name="EatProvider" type="Node" parent="."] script = ExtResource("13_eat_provider") +[node name="SleepProvider" type="Node" parent="."] +script = ExtResource("14_sleep_provider") + [node name="CameraRig" parent="." instance=ExtResource("2_camera")] position = Vector2(640, 640)