rimlike/scenes/pawn/pawn.gd
megaproxy 9cf9b7dbfd Phase 13: Rooms + Auto-roof + Beauty + Dirtiness + Cleaning
Three-agent fan-out — Opus pre-wrote Room class, World.rooms/room_at_tile/is_indoor,
4 EventBus signals before dispatch so the slices ran fully parallel.

DECISION: Big-room UX = bump auto-roof cap to 16, banner above. Cabin
(24 tiles) intentionally exceeds cap to exercise the warning path; a
5×5 test shed (9 interior tiles) was added to exercise the roof path.

Room detection (Agent A):
- scenes/world/room.gd — class_name Room, tiles/bounds/is_under_roof,
  contains_tile() bounds-then-list-checked, recompute_bounds()
- scenes/world/room_detector.gd — class_name RoomDetector, BFS 4-dir
  from floor/door tiles, walls/terrain as boundary, doors counted as
  room interior. Detects up to 4× cap; auto-roofs only ≤16.
- World.mark_wall_tile/mark_floor_tile/mark_door_tile hook BFS recompute
- Door._complete() now erases wall-layer stamp + registers door tile
- Designation.TOOL_NO_ROOF paint mode wired (UI button deferred Phase 17)
- EventBus.room_changed / room_too_large signals

Indoor/Shelter (Agent B):
- Pawn._is_sheltered() rerouted: World.is_indoor() first, floor-proxy fallback
- IndoorTintOverlay Node2D — _draw fills roofed-room tiles at α=0.10 warm
- Crop._on_sim_tick skips stage advance when World.is_indoor(tile)

Beauty + Dirtiness + Cleaning + Room thoughts (Agent C):
- BeautySystem sparse map, linear falloff radius=3, Quality multiplier
  (SHODDY 0.5 → LEGENDARY 2.5). Base: Bed +2, Workbench +1, Torch +3, Hearth +4
- DirtinessSystem 0-100, tier crossings (clean<25/dirty<60/filthy≥60)
  emit tile_dirtiness_changed. bump/bump_clean/bump_pawn_traffic API
- CleaningProvider priority=2, KIND_CLEAN toil, 2.5 dirt/tick for ~40 ticks
- Bed/Torch/Workbench _complete() now register with BeautySystem
- 7 room mood thoughts: clean_room (+2), dirty_room (-3), filthy_room (-6),
  beautiful_room (+4), ugly_room (-3), slept_in_room (+3 EVENT, wires Ph 17),
  ate_without_table (-3 EVENT, wires Ph 17)
- Pawn._sync_room_thoughts called from _process_thoughts after cold block,
  defensive against null rooms/systems

Integration recovery (Opus):
- Agent C's BeautySystem/DirtinessSystem/CleaningProvider/IndoorTintOverlay
  instantiation in world.gd never landed (only field declarations + entity
  hooks survived). Added preloads + runtime add_child + autoload bindings +
  CleaningProvider registration + furniture pre-seed in _ready
- Added _prestamp_test_shed_for_room_detector with _spawn_complete_wall/floor
  helpers so a 5×5 visible shed exercises the auto-roof path at boot

MCP runtime verified:
- Rooms: cabin Room#2 size=24 roofed=false (room_too_large fires),
  shed Room#3 size=9 roofed=true (auto-roof active)
- beauty_map size=50 around prebuilt furniture; bed at (47,24) beauty=4.0
- Bram teleported to (36, 25) in shed → indoor=true, sheltered=true,
  thoughts=[clean_room +2], mood=52.0
- Screenshot: shed walls + brown floor visible; cabin warmly torch-lit;
  Spring 1/12 indicator; Day 1 07:52

Delegation: 3× gdscript-refactor (Sonnet) agents in parallel;
integration recovery + MCP verify on Opus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:19:23 +01:00

886 lines
36 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends Node2D
## Pawn entity — grid-snapped, sim-tick-driven movement with smooth render lerp.
##
## Movement model (docs/architecture.md "Pawn movement"):
## At 1× speed, crossing one tile costs STEP_TICKS sim ticks (10 ticks = 0.5 s
## at 20 Hz). Each sim tick advances _step_progress by 1/STEP_TICKS. When
## progress reaches 1.0 the pawn snaps to the next waypoint.
##
## Speed scaling is free: Pause → no ticks → pawn frozen; Ultra → 12× ticks/s →
## pawn crosses the map in ~7 s real time. No per-pawn speed handling needed.
##
## Render: _process() lerps world-position between current and next tile every
## render frame at 60 Hz — motion is smooth even at low sim Hz.
##
## Phase 3 additions:
## - `forced_job` slot (player override via Selection)
## - `job_runner` Node child wired externally by the World scene
## - On each sim tick: orchestrate AI first (Decision → JobRunner.tick), then
## advance the walk. The walk is still owned by the Pawn — JobRunner's WALK
## toil delegates to `walk_along_path()` and listens for `walk_completed`.
## - to_dict() / from_dict() round-trip the entire mid-walk + mid-toil state
## (architecture.md "Save format" — mid-tick suspend safe).
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.
# At Ultra (12×): ~4 real seconds. Phase 7 demo-friendliness: at Fast a pawn
# needs food within ~10 real seconds of spawn. Keep items in-world so hunger
# triggers before it empties entirely. Tune in Phase 20.
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.
# ── HP + status constants (docs/design.md "Health & status effects"; Phase 9) ──
# Single HP pool per pawn. No body-part injury (design.md "Simplifications").
const HP_MAX: float = 100.0
# HP at or below this threshold triggers the Downed status (design.md "Downed & death").
const HP_DOWNED_THRESHOLD: float = 30.0
# HP at or above this threshold clears the Downed status after treatment.
# Gap between thresholds prevents rapid down/revive oscillation.
const HP_REVIVE_THRESHOLD: float = 50.0
# ── 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).
const SKILL_MANUAL_LABOR: StringName = &"manual_labor"
const SKILL_CRAFTING: StringName = &"crafting"
const SKILL_COOKING: StringName = &"cooking"
const SKILL_MEDICINE: StringName = &"medicine"
const SKILL_COMBAT: StringName = &"combat"
const ALL_SKILLS: Array[StringName] = [
SKILL_MANUAL_LABOR, SKILL_CRAFTING, SKILL_COOKING, SKILL_MEDICINE, SKILL_COMBAT,
]
signal walk_started
signal walk_completed
signal arrived_at_destination(tile: Vector2i)
@export var pawn_name: String = ""
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
# JobRunner node ref. Set externally by World during pawn spawn (so the runner
# can be paired with the pathfinder). May be null in tests / pre-Phase-3 scenes.
var job_runner = null
# Phase 4 — carry slot for hauling. Holds an Item node while carrying; null
# when empty-handed. PICKUP toil sets this; DEPOSIT clears it. One stack /
# one type at a time per design.md.
var carried_item = null
# Phase 6 — skill levels. Initialized to 0 for all five skills in _ready().
# Use get_skill() / set_skill() to access; direct dict mutation is allowed
# 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
# Phase 9 — HP (design.md "Single HP per pawn", default 100).
var hp: float = HP_MAX
# Phase 9 — active status effects. Do not mutate directly — use add_status() /
# remove_status_by_id() which emit EventBus signals and enforce stack-merge logic.
var statuses: Array = [] # Array[Status]
# Phase 12 — wet / cold accumulators (0100).
# _wet_accum rises while the pawn is outdoors in rain; decays otherwise.
# Crosses WET_DAMP_THRESHOLD (25) → Damp status; WET_SOAKED_THRESHOLD (60) → Soaked.
# _cold_accum rises in winter or during a cold snap outdoors; decays otherwise.
# Mirroring StatusCatalog constants — do not read StatusCatalog inside property
# access paths; use _wet_severity() / _cold_severity() helpers instead.
var _wet_accum: float = 0.0
var _cold_accum: float = 0.0
# Phase 13 — shelter debug tracking.
## When SHELTER_DEBUG is true, any false→true or true→false transition in
## _is_sheltered() emits an Audit.log line. Off by default — debug noise.
const SHELTER_DEBUG: bool = false
var _shelter_prev: bool = false
var _path: Array[Vector2i] = []
var _step_progress: float = 0.0
var _selected: bool = false
@onready var _name_label: Label = $NameLabel
@onready var _state_label: Label = $StateLabel
func _ready() -> void:
EventBus.sim_tick.connect(_on_sim_tick)
_state_label.text = Strings.t(&"pawn.state.idle")
# Initialise all five skills to 0 if not already set (from_dict sets them
# before _ready() fires in some load paths — only fill missing keys here).
for skill in ALL_SKILLS:
if not skills.has(skill):
skills[skill] = 0
func setup(p_name: String, start_tile: Vector2i) -> void:
pawn_name = p_name
tile = start_tile
position = _tile_to_world(tile)
_name_label.text = pawn_name
_state_label.text = Strings.t(&"pawn.state.idle")
Audit.log("pawn", "%s spawned at %s" % [pawn_name, start_tile])
# ── public API ──────────────────────────────────────────────────────────────
func walk_along_path(new_path: Array[Vector2i]) -> void:
if new_path.is_empty():
return
var was_walking := is_walking()
_path = new_path.duplicate()
# _step_progress carries over; when it hits 1.0 the pawn snaps to
# the first tile of the new path and picks up the new direction.
if not was_walking:
walk_started.emit()
_state_label.text = Strings.t(&"pawn.state.walking")
Audit.log("pawn", "%s walk path len %d%s" % [pawn_name, new_path.size(), new_path[-1]])
func is_walking() -> bool:
return not _path.is_empty()
func set_selected(value: bool) -> void:
if _selected == value:
return
_selected = value
queue_redraw()
func is_selected() -> bool:
return _selected
# ── hunger API (Phase 7) ──────────────────────────────────────────────────────
## True when hunger is low enough that the pawn should seek food.
## Threshold matches the design.md "Hungry" state-driven thought trigger (< 30).
func is_hungry() -> bool:
return hunger < 30.0
## True when the pawn is critically hungry and health damage is imminent.
## Exposed for Phase 9 Decision Layer-3 interrupt wiring (starvation HP drain).
## Not yet connected to a work interrupt — Phase 9 follow-up.
func is_starving() -> bool:
return hunger < 5.0
## Set hunger to `value`, clamped to [0, HUNGER_MAX].
## Used by EatProvider's _tick_eat and save/load.
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 Decision Layer-3 interrupt hook — not yet wired (exhaustion collapse).
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)
# ── HP + status API (Phase 9) ─────────────────────────────────────────────────
## Reduce HP by amount, clamped to [0, HP_MAX]. Emits pawn_took_damage.
## Pass a non-empty source string for Audit context (e.g. "bleeding", "wolf bite").
## Does NOT log per-tick bleed ticks — only logs on direct named calls.
func take_damage(amount: float, source: String = "") -> void:
hp = maxf(0.0, hp - amount)
EventBus.pawn_took_damage.emit(self, amount)
if source != "":
Audit.log("pawn", "%s took %.1f damage from '%s' (hp=%.1f)" % [pawn_name, amount, source, hp])
_check_downed()
## Restore HP by amount, clamped to [0, HP_MAX]. Checks revive condition.
func heal(amount: float) -> void:
hp = minf(HP_MAX, hp + amount)
_check_revive()
## Add a status to this pawn, merging severity if one with the same id already exists.
## Stack-merge: severity increments by 1 up to max_severity (mirrors add_thought stacking).
## Emits pawn_status_added when a genuinely new status entry is appended.
func add_status(s: Status) -> void:
for existing in statuses:
if existing.id == s.id:
existing.severity = mini(existing.severity + 1, existing.max_severity)
return
statuses.append(s)
Audit.log("pawn", "%s gained status: %s (severity=%d)" % [pawn_name, s.label, s.severity])
EventBus.pawn_status_added.emit(self, s)
## Remove all statuses with the given id. Emits pawn_status_removed for each removed entry.
func remove_status_by_id(id: StringName) -> void:
for i in range(statuses.size() - 1, -1, -1):
if statuses[i].id == id:
var s: Status = statuses[i]
statuses.remove_at(i)
Audit.log("pawn", "%s lost status: %s" % [pawn_name, s.label])
EventBus.pawn_status_removed.emit(self, s)
## Returns true if the pawn has any active status with the given id.
func has_status(id: StringName) -> bool:
for s in statuses:
if s.id == id:
return true
return false
## True when the pawn is Downed (HP near zero, cannot act, awaiting rescue).
func is_downed() -> bool:
return has_status(&"downed")
## True when the pawn cannot accept any job. Decision Layer 1 probes this.
## Phase 9: only Downed blocks the pawn entirely. Future phases may add Unconscious.
func is_incapacitated() -> bool:
return is_downed()
## Internal: enter Downed state when HP drops to or below the threshold.
## add_status() emits pawn_status_added — listeners watch that to learn of Downed.
func _check_downed() -> void:
if hp <= HP_DOWNED_THRESHOLD and not is_downed():
add_status(StatusCatalog.downed())
Audit.log("pawn", "%s DOWNED (hp=%.1f)" % [pawn_name, hp])
## Internal: clear Downed when HP recovers to or above the revive threshold.
## Called by heal() — the doctor's treatment flow goes through heal().
func _check_revive() -> void:
if hp >= HP_REVIVE_THRESHOLD and is_downed():
remove_status_by_id(&"downed")
Audit.log("pawn", "%s revived from Downed (hp=%.1f)" % [pawn_name, hp])
## 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:
return int(skills.get(skill, 0))
## Sets skill to level, clamped to [0, 10]. Asserts the key is a known skill.
func set_skill(skill: StringName, level: int) -> void:
assert(skill in ALL_SKILLS, "set_skill: unknown skill '%s'" % skill)
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())
# Phase 11 — in_darkness fires when past dusk/before dawn AND the pawn's
# tile is unlit. architecture.md "LightingSystem": is_lit = light_map > 0.2;
# darkness_factor > 0.3 spans dusk-mid through dawn-mid.
# Phase 13 may add wall-occlusion via room BFS; for now radius-8 falloff only.
var _dark_time := Clock.darkness_factor() > 0.3
var _lit := World.is_tile_lit(tile)
_sync_persistent_thought(&"in_darkness", _dark_time and not _lit, ThoughtCatalog.in_darkness())
# Phase 12 — wet mood thoughts (mutually exclusive tiers; damp removed when soaked kicks in).
_sync_persistent_thought(&"damp", has_status(&"wet") and _wet_severity() == StatusCatalog.WET_DAMP_LEVEL, ThoughtCatalog.damp())
_sync_persistent_thought(&"soaked", has_status(&"wet") and _wet_severity() == StatusCatalog.WET_SOAKED_LEVEL, ThoughtCatalog.soaked())
# Phase 12 — cold mood thought (any cold severity triggers the single cold thought).
_sync_persistent_thought(&"cold", has_status(&"cold"), ThoughtCatalog.cold_thought())
# Phase 13 — room beauty and dirtiness thoughts.
# Defensive: World.room_at_tile returns null if rooms are empty (Agent A may land later).
_sync_room_thoughts()
# 3. Recompute if EVENT thoughts expired (persistent syncs call _recompute_mood internally).
if dirty:
_recompute_mood()
# 4. Sulking transitions.
_process_sulking()
## Phase 13 — sync beauty and dirtiness room thoughts for this pawn's current tile.
## Called from _process_thoughts() after the cold/damp/soaked block.
## Defensive: returns early if rooms or the beauty/dirtiness systems are not yet wired
## (Agent A's RoomDetector may land slightly after this code during startup).
##
## Beauty thoughts (mutually exclusive — only one fires):
## avg beauty >= 4.0 → beautiful_room
## avg beauty < 0.0 → ugly_room (Phase 14 corpses drive this below 0)
## else → neither
##
## Dirtiness thoughts (mutually exclusive — only one fires):
## avg dirt < 25 → clean_room
## avg dirt 25..60 → dirty_room
## avg dirt >= 60 → filthy_room
func _sync_room_thoughts() -> void:
var room = World.room_at_tile(tile)
# ── no room (outdoors or RoomDetector not yet live) → clear all room thoughts ──
if room == null:
_sync_persistent_thought(&"beautiful_room", false, ThoughtCatalog.beautiful_room())
_sync_persistent_thought(&"ugly_room", false, ThoughtCatalog.ugly_room())
_sync_persistent_thought(&"clean_room", false, ThoughtCatalog.clean_room())
_sync_persistent_thought(&"dirty_room", false, ThoughtCatalog.dirty_room())
_sync_persistent_thought(&"filthy_room", false, ThoughtCatalog.filthy_room())
return
# ── beauty ──────────────────────────────────────────────────────────────────
var avg_beauty: float = 0.0
var bs = World.get("beauty_system")
if bs != null and room.tiles.size() > 0:
var beauty_sum: float = 0.0
for rt in room.tiles:
beauty_sum += bs.beauty_at(rt)
avg_beauty = beauty_sum / float(room.tiles.size())
_sync_persistent_thought(&"beautiful_room", avg_beauty >= 4.0, ThoughtCatalog.beautiful_room())
_sync_persistent_thought(&"ugly_room", avg_beauty < 0.0, ThoughtCatalog.ugly_room())
# ── dirtiness ───────────────────────────────────────────────────────────────
var avg_dirt: float = 0.0
var ds = World.get("dirtiness_system")
if ds != null and room.tiles.size() > 0:
var dirt_sum: float = 0.0
for rt in room.tiles:
dirt_sum += ds.dirt_at(rt)
avg_dirt = dirt_sum / float(room.tiles.size())
# Mutually exclusive — only one fires (filthy wins over dirty wins over clean).
var is_filthy: bool = avg_dirt >= 60.0
var is_dirty: bool = avg_dirt >= 25.0 and not is_filthy
var is_clean: bool = avg_dirt < 25.0
_sync_persistent_thought(&"filthy_room", is_filthy, ThoughtCatalog.filthy_room())
_sync_persistent_thought(&"dirty_room", is_dirty, ThoughtCatalog.dirty_room())
_sync_persistent_thought(&"clean_room", is_clean, ThoughtCatalog.clean_room())
## 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])
# ── status tick (Phase 9) ─────────────────────────────────────────────────────
## Per-tick status update. Call AFTER _process_thoughts, BEFORE _orchestrate_ai.
##
## Sequence:
## 1. Decay EVENT statuses — remove expired ones.
## 2. Apply per-tick effects (Bleeding drains HP).
## 3. Phase 12 — tick wet / cold accumulators and sync statuses.
##
## Bleeding does NOT log per-tick — would flood Audit. Named-source logging
## happens in take_damage() only when source is non-empty.
func _process_statuses() -> void:
for i in range(statuses.size() - 1, -1, -1):
var s: Status = statuses[i]
# 1. Decay EVENT statuses.
if s.lifetime == Status.Lifetime.EVENT:
s.ticks_remaining -= 1
if s.ticks_remaining <= 0:
statuses.remove_at(i)
continue
# 2. Apply per-tick effects.
if s.kind == Status.Kind.BLEEDING:
# No source string to suppress per-tick Audit flood.
hp = maxf(0.0, hp - StatusCatalog.BLEED_HP_PER_TICK * float(s.severity))
EventBus.pawn_took_damage.emit(self, StatusCatalog.BLEED_HP_PER_TICK * float(s.severity))
_check_downed()
# 3. Phase 12 — wet / cold environment exposure.
_tick_wet()
_tick_cold()
## Tick the wet accumulator and sync the Wet status severity.
## Called every sim tick from _process_statuses().
func _tick_wet() -> void:
var sheltered := _is_sheltered()
if Weather.is_raining() and not sheltered:
var rate := StatusCatalog.WET_GAIN_PER_TICK * (2.0 if Weather.is_storming() else 1.0)
_wet_accum = clampf(_wet_accum + rate, 0.0, 100.0)
else:
_wet_accum = clampf(_wet_accum - StatusCatalog.WET_DECAY_PER_TICK, 0.0, 100.0)
_sync_wet_status()
## Tick the cold accumulator and sync the Cold status severity.
## Cold accumulates outdoors in winter OR during any cold snap regardless of season.
func _tick_cold() -> void:
var sheltered := _is_sheltered()
var cold_conditions := (Clock.current_season() == Clock.SEASON_WINTER or Weather.is_cold_snap())
if cold_conditions and not sheltered:
var rate := StatusCatalog.COLD_GAIN_PER_TICK * (2.0 if Weather.is_cold_snap() else 1.0)
_cold_accum = clampf(_cold_accum + rate, 0.0, 100.0)
else:
_cold_accum = clampf(_cold_accum - StatusCatalog.COLD_DECAY_PER_TICK, 0.0, 100.0)
_sync_cold_status()
## Compute the target wet severity from the current accumulator.
## Returns 0 (no status), 1 (Damp), or 2 (Soaked).
func _wet_severity() -> int:
for s in statuses:
if s.id == &"wet":
return s.severity
return 0
## Compute the target cold severity from the current accumulator.
## Returns 0 (no status), 1, 2, or 3.
func _cold_severity() -> int:
for s in statuses:
if s.id == &"cold":
return s.severity
return 0
## Sync the Wet status to match the current _wet_accum tier.
## Adds, updates severity, or removes the status with one Audit log per transition.
func _sync_wet_status() -> void:
var target: int
if _wet_accum >= StatusCatalog.WET_SOAKED_THRESHOLD:
target = StatusCatalog.WET_SOAKED_LEVEL
elif _wet_accum >= StatusCatalog.WET_DAMP_THRESHOLD:
target = StatusCatalog.WET_DAMP_LEVEL
else:
target = 0
var current := _wet_severity()
if target == current:
return
if target == 0:
remove_status_by_id(&"wet")
Audit.log("pawn", "%s dried off (wet=%.1f)" % [pawn_name, _wet_accum])
elif current == 0:
# Not wet → newly wet.
var label := "Damp" if target == StatusCatalog.WET_DAMP_LEVEL else "Soaked"
add_status(StatusCatalog.wet(target))
Audit.log("pawn", "%s now %s (wet=%.1f)" % [pawn_name, label, _wet_accum])
else:
# Severity shift — update in place to preserve the status object.
for s in statuses:
if s.id == &"wet":
var old_sev: int = s.severity
s.severity = target
var label := "Soaked" if target == StatusCatalog.WET_SOAKED_LEVEL else "Damp"
Audit.log("pawn", "%s now %s (wet=%.1f, sev %d%d)" % [pawn_name, label, _wet_accum, old_sev, target])
break
## Sync the Cold status to match the current _cold_accum tier.
## Adds, updates severity, or removes the status with one Audit log per transition.
func _sync_cold_status() -> void:
var target: int
if _cold_accum >= StatusCatalog.COLD_EXTREME_THRESHOLD:
target = 3
elif _cold_accum >= StatusCatalog.COLD_SEVERE_THRESHOLD:
target = 2
elif _cold_accum >= StatusCatalog.COLD_MILD_THRESHOLD:
target = 1
else:
target = 0
var current := _cold_severity()
if target == current:
return
if target == 0:
remove_status_by_id(&"cold")
Audit.log("pawn", "%s warmed up (cold=%.1f)" % [pawn_name, _cold_accum])
elif current == 0:
add_status(StatusCatalog.cold(target))
Audit.log("pawn", "%s now Cold severity %d (cold=%.1f)" % [pawn_name, target, _cold_accum])
else:
for s in statuses:
if s.id == &"cold":
var old_sev: int = s.severity
s.severity = target
Audit.log("pawn", "%s Cold sev %d%d (cold=%.1f)" % [pawn_name, old_sev, target, _cold_accum])
break
## Phase 13 — returns true if the pawn's current tile is inside an enclosed,
## roofed Room (via World.is_indoor). Falls back to the Phase 12 has-floor proxy
## during the brief window before RoomDetector populates the registry (boot,
## mid-build, cabin not-yet-enclosed). The fallback is graceful enough for those
## transient states and produces no false positives on open terrain.
##
## Callers: _tick_wet() and _tick_cold() both call this once per sim tick.
## If SHELTER_DEBUG is true, false→true and true→false transitions emit Audit lines.
func _is_sheltered() -> bool:
var sheltered: bool
if World.is_indoor(tile):
sheltered = true
else:
sheltered = World.floor_layer.get_cell_source_id(tile) != -1
if SHELTER_DEBUG and sheltered != _shelter_prev:
var direction := "→sheltered" if sheltered else "→unsheltered"
Audit.log("pawn", "%s shelter transition %s at %s" % [pawn_name, direction, tile])
_shelter_prev = sheltered
return sheltered
# ── save / load ─────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
var path_data: Array = []
for v in _path:
path_data.append([v.x, v.y])
# Serialise skills as {"manual_labor": 0, "crafting": 3, ...} — StringName
# keys must be stored as plain Strings for JSON round-trip safety.
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())
# Serialise Phase 9 statuses as an array of status dicts.
var statuses_data: Array = []
for s in statuses:
statuses_data.append(s.to_dict())
return {
"name": pawn_name,
"tile_x": tile.x,
"tile_y": tile.y,
"path": path_data,
"step_progress": _step_progress,
"selected": _selected,
"forced_job": forced_job.to_dict() if forced_job != null else null,
"job_runner": job_runner.to_dict() if job_runner != null else null,
"skills": skills_data,
"hunger": hunger,
"sleep": sleep,
"thoughts": thoughts_data,
"mood": mood,
"hp": hp,
"statuses": statuses_data,
"sulk_low_ticks": _sulk_low_ticks,
"sulking": sulking,
# Phase 12 — wet / cold accumulators. Default 0 for pre-Phase-12 save compat.
"wet_accum": _wet_accum,
"cold_accum": _cold_accum,
}
func from_dict(d: Dictionary) -> void:
pawn_name = d.get("name", "")
tile = Vector2i(int(d.get("tile_x", 0)), int(d.get("tile_y", 0)))
_path.clear()
for entry in d.get("path", []):
if entry is Array and entry.size() == 2:
_path.append(Vector2i(int(entry[0]), int(entry[1])))
_step_progress = float(d.get("step_progress", 0.0))
_selected = bool(d.get("selected", false))
var fj_dict: Variant = d.get("forced_job")
forced_job = Job.from_dict(fj_dict) if fj_dict is Dictionary else null
var jr_dict: Variant = d.get("job_runner")
if jr_dict is Dictionary and job_runner != null:
job_runner.from_dict(jr_dict)
# 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))
# Phase 9 — restore HP; default to HP_MAX for backward compat with pre-Phase-9 saves.
hp = clampf(float(d.get("hp", HP_MAX)), 0.0, HP_MAX)
# Phase 9 — restore statuses.
statuses.clear()
var statuses_raw: Variant = d.get("statuses", [])
if statuses_raw is Array:
for sd in statuses_raw:
if sd is Dictionary:
statuses.append(Status.from_dict(sd))
# Phase 12 — restore wet / cold accumulators; default 0 for pre-Phase-12 saves.
_wet_accum = clampf(float(d.get("wet_accum", 0.0)), 0.0, 100.0)
_cold_accum = clampf(float(d.get("cold_accum", 0.0)), 0.0, 100.0)
# 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).
var saved_skills: Variant = d.get("skills")
if saved_skills is Dictionary:
for raw_key in saved_skills.keys():
var skill := StringName(raw_key)
if skill in ALL_SKILLS:
skills[skill] = clampi(int(saved_skills[raw_key]), 0, 10)
_name_label.text = pawn_name
_state_label.text = Strings.t(&"pawn.state.walking") if is_walking() else Strings.t(&"pawn.state.idle")
position = _tile_to_world(tile)
queue_redraw()
Audit.log("pawn", "%s restored at %s (walking=%s, path len=%d)" % [pawn_name, tile, is_walking(), _path.size()])
# ── sim tick: orchestrate AI, then advance walk ─────────────────────────────
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()
# Phase 9 — process statuses AFTER thoughts (bleed damage may alter mood via
# future "recently injured" thought), BEFORE AI so is_incapacitated() is current.
_process_statuses()
_orchestrate_ai()
_advance_walk()
# Phase 4 — the carry indicator changes when PICKUP/DEPOSIT toils mutate
# carried_item directly. Cheapest reliable redraw hook is here.
queue_redraw()
func _orchestrate_ai() -> void:
# Phase 3: ask Decision for a job when the pawn is idle OR when a forced job
# is queued (forced_job preempts the current job — player override semantics).
# Decision's layer 2 consumes the forced_job slot; layer 4 falls back to work
# providers when no override is queued.
if job_runner == null:
return
if forced_job != null or not job_runner.has_job():
var next_job = Decision.pick_next_job(self, World.work_providers)
if next_job != null:
job_runner.start_job(next_job)
# Tick the runner (a freshly-started job's first toil executes here in the
# same sim tick — WALK calls pawn.walk_along_path so _advance_walk below
# immediately starts moving on this tick).
if job_runner.has_job():
job_runner.tick()
func _advance_walk() -> void:
if not is_walking():
return
_step_progress += 1.0 / float(STEP_TICKS)
if _step_progress >= 1.0:
tile = _path[0]
_path.remove_at(0)
_step_progress = 0.0
if _path.is_empty():
_state_label.text = Strings.t(&"pawn.state.idle")
walk_completed.emit()
arrived_at_destination.emit(tile)
Audit.log("pawn", "%s arrived at %s" % [pawn_name, tile])
# ── render ──────────────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
var from_world := _tile_to_world(tile)
var next := _path[0] if is_walking() else tile
var to_world := _tile_to_world(next)
position = from_world.lerp(to_world, _step_progress)
func _draw() -> void:
# Body disc — colour derived deterministically from pawn name so each pawn
# is visually distinct without any art dependency.
var hue := float(pawn_name.hash() % 360) / 360.0
var body_colour := Color.from_hsv(hue, 0.7, 0.85)
if is_downed():
# Phase 9 — Downed pawn: rotated 90° (lying on ground) + desaturated.
# draw_set_transform applies to all subsequent draw_* calls in this _draw.
draw_set_transform(Vector2.ZERO, PI / 2.0, Vector2.ONE)
draw_circle(Vector2.ZERO, 6.0, body_colour.lerp(Color(0.5, 0.5, 0.5), 0.6))
draw_arc(Vector2.ZERO, 7.0, 0.0, TAU, 24, Color(0.0, 0.0, 0.0, 0.4), 1.0)
# Reset transform so selection ring and carry indicator render upright.
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
else:
draw_circle(Vector2.ZERO, 6.0, body_colour)
# Dark outline ring.
draw_arc(Vector2.ZERO, 7.0, 0.0, TAU, 24, Color(0.0, 0.0, 0.0, 0.6), 1.0)
# Selection ring — drawn after body regardless of downed state.
if _selected:
draw_arc(Vector2.ZERO, 10.0, 0.0, TAU, 32, Color(1.0, 0.9, 0.2, 0.85), 2.0)
# Phase 4 — carry indicator: small coloured square at upper-right of body.
if carried_item != null:
var ci_hue := float(carried_item.item_type.hash() % 360) / 360.0
var ci_color := Color.from_hsv(ci_hue, 0.6, 0.85)
draw_rect(Rect2(6, -10, 7, 7), ci_color)
draw_rect(Rect2(6, -10, 7, 7), Color(0, 0, 0, 0.7), false, 1.0)
# ── helpers ─────────────────────────────────────────────────────────────────
func _tile_to_world(t: Vector2i) -> Vector2:
return Vector2(
t.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
t.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0
)