rimlike/scenes/pawn/pawn.gd
megaproxy 1b6ad2bcc6 Phase 9+10: Status/Doctor/Medical bed + Wolves/WolfSpawner
The 'drama pair' shipped together via 3-agent fan-out.

Phase 9 — Status effects + Medicine:
- Status data class (PERSISTENT/EVENT, severity stacks max=3) + StatusCatalog
  (Bleeding ticks HP loss; Downed = incapacitated)
- Pawn HP (100 max, 30 downed threshold, 50 revive threshold), take_damage,
  heal, add_status/remove_status_by_id, is_downed/is_incapacitated, downed
  visual (body rotated 90° + desaturated)
- DoctorProvider (priority 9, highest) — scans World.pawns for nearest downed
  pawn, finds medical bed (or any bed fallback), emits 4-toil job:
  walk_to_patient → rescue → walk_to_bed → treat
- Bed.is_medical with red-cross marker draw on pillow; round-trips save
- KIND_RESCUE + KIND_TREAT toils + JobRunner _tick_rescue/_tick_treat
  (snap-to-bed on first treat tick, +0.5 hp/tick, bleed cure at 100-tick
  intervals; done at HP≥50 + no bleeding, 600-tick timeout)
- EventBus: pawn_took_damage, pawn_status_added, pawn_status_removed

Phase 10 — Combat + Wolves (wolf-first slice):
- Wolf entity (Node2D, 4-state APPROACH/ENGAGE/FLEE/DEAD, procedural
  canine sprite with red glowing eyes, 40 HP)
- Two-roll combat: 70% hit + 50% chance to apply Bleeding(1) on hit
- WolfSpawner — triggers at Clock.darkness_factor()≥0.8 with 1-in-game-day
  cooldown, packs of 1–2 at random map-edge cluster
- World.wolves registry + register_wolf/unregister_wolf

Integration: world.tscn load_steps 15→17 with DoctorProvider + WolfSpawner
nodes. world.gd registers doctor at top of provider list (priority 9 >
sleep 8 > eat 7 > construction 6 > chop≈plant 5 > mine≈craft 4 > haul 3
> rest 0). Middle bed at (47,24) marked is_medical=true.

MCP runtime verified: Bram took 75 dmg + Bleeding(2) → Downed (hp 25) →
Edda + Cora both volunteered doctor job → walked to patient → carried to
medical bed → treated → Bram healed to 94.2 hp, statuses cleared, back to
work. Wolf raid at day 3 22:00 fired; 4 wolves alive across raid cycles
by day 4 01:51. Screenshots confirm red-cross medical bed and wolf
silhouettes at night.

Phase 10 deliberately partial: wolf-side combat ships, pawn-side
weapons/armor/cover/friendly-fire deferred — full chain
(wolf→bites→pawn→bleeds→doctor) awaits player weapons.
Bleed-out timer at demo value (1200) vs design value (432000 = 6 in-game
hours) — documented in status_catalog.gd for first time-balance pass.

Delegation: Agent A (status + pawn HP), Agent B (doctor + treatment),
Agent C (wolf + spawner) — all Sonnet gdscript-refactor; integration on
Opus.

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

666 lines
27 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]
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())
# 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])
# ── 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).
##
## 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()
# ── 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,
}
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))
# 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
)