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

## Phase 14 — corpse scene instantiated on death.
const CORPSE_SCENE: PackedScene = preload("res://scenes/entities/corpse.tscn")

## Slice-1 pawn sprite helper. Preloaded (not class_name) because the global
## class registry isn't reliable during cold-start parse for sibling scripts.
const _PAWN_SPRITE_FRAMES: Script = preload("res://scenes/pawn/pawn_sprite_frames.gd")

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 0–10. 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 = ""

## Last-known facing direction as a unit Vector2i (one of (0,1) down, (-1,0)
## left, (1,0) right, (0,-1) up). Updated when walking; persists when idle
## so the idle sprite faces the last direction walked. Defaults to down.
var facing: Vector2i = Vector2i(0, 1)

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 17 — per-pawn work-priority matrix. Maps WorkProvider.category
# (StringName) → priority level 1..4 (1 = Critical, 4 = Low), 0 = OFF.
# Decision layer filters work providers by `pawn.work_priorities.get(p.category, 3)`
# so a missing entry defaults to NORMAL (3).
# UI matrix (Agent C) writes here on tap-to-cycle. Survives save/load via
# to_dict / from_dict.
var work_priorities: Dictionary = {
	&"construction": 3,
	&"chop":         3,
	&"plant":        3,
	&"mine":         3,
	&"crafting":     3,
	&"haul":         3,
	&"clean":        3,
	&"doctor":       3,
	# Needs categories (rest/eat/sleep) are not player-configurable — they
	# fire from need thresholds, not from a priority cell.
}

# 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

# Phase 9 — HP (design.md "Single HP per pawn", default 100).
var hp: float = HP_MAX

# Phase 14 — body colour derived once from the pawn's name hash (same formula
# as _draw() used to compute inline).  Stored so the Corpse entity can carry it
# for its head-dot visual without access to the living pawn.
var portrait_color: Color = Color.WHITE

# Phase 14 — last named damage source passed to take_damage(); used by
# _check_death() to label the corpse cause when bleeding is not active.
var _last_damage_source: StringName = &""

# Phase 14 — bleed-out timeout counter.  Increments each sim tick while the
# pawn has a Bleeding status AND is still alive (HP > 0).  Resets when the
# Bleeding status is cleared.  If this reaches StatusCatalog.BLEED_OUT_TICKS
# (432000 ticks ≈ 6 in-game hours) and no doctor has treated the pawn, a
# lethal take_damage() call forces _check_death().
var _bleed_ticks: int = 0

# 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 (0–100).
# _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

## AnimatedSprite2D child painted with peasant atlases. Built in _ready();
## animation switched by _update_anim() each _process tick.
var _sprite: AnimatedSprite2D = null

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
	# Phase 14 — connect to burial / cremation signals for closure thoughts.
	# Defensive: signals are declared in EventBus Phase 14 block; guard for
	# environments where a pre-Phase-14 shim might be in use.
	if EventBus.has_signal("corpse_buried"):
		EventBus.corpse_buried.connect(_on_corpse_buried)
	if EventBus.has_signal("corpse_cremated"):
		EventBus.corpse_cremated.connect(_on_corpse_cremated)

	# Sprite mount is deferred to setup() / from_dict() because the atlas pick
	# depends on pawn_name (deterministic name-hash), which isn't assigned yet
	# when _ready() fires.  See _mount_sprite() below.


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")
	# Phase 14 — compute portrait_color once so Corpse can carry it.
	# Same formula as _draw() body disc: deterministic hue from name hash.
	var hue := float(pawn_name.hash() % 360) / 360.0
	portrait_color = Color.from_hsv(hue, 0.7, 0.85)
	# Slice-1 character sprite: depends on pawn_name, so mount here (not _ready).
	_mount_sprite()
	Audit.log("pawn", "%s spawned at %s" % [pawn_name, start_tile])


## Build the AnimatedSprite2D child from the peasant atlas trio picked
## deterministically from pawn_name. Idempotent — safe to call from setup()
## AND from_dict() (the save-load path also re-enters setup).
func _mount_sprite() -> void:
	if _sprite != null:
		_sprite.queue_free()
		_sprite = null
	var atlases := _atlas_for_pawn(self)
	var sf: SpriteFrames = _PAWN_SPRITE_FRAMES.build(atlases)
	_sprite = AnimatedSprite2D.new()
	_sprite.name = "Sprite"
	_sprite.sprite_frames = sf
	_sprite.centered = true
	_sprite.offset = Vector2(0, -8)  # bottom-anchor: feet ≈ tile bottom edge
	# show_behind_parent lets the sprite render at the same z_index as the
	# parent Pawn (so it stays above the floor TileMap), while ALSO drawing
	# beneath the parent's _draw() callback — so selection ring + carry
	# indicator overlay on top.  z_index=-1 would sink below the floor too.
	_sprite.show_behind_parent = true
	_sprite.play(&"idle_down")
	add_child(_sprite)


# ── 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.
## Phase 14: tracks _last_damage_source for corpse cause labelling; calls
## _check_death() after _check_downed() so death is detected on the same tick.
func take_damage(amount: float, source: StringName = &"") -> void:
	hp = maxf(0.0, hp - amount)
	if source != &"":
		_last_damage_source = source
	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()
	_check_death()


## 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])


# ── death (Phase 14) ─────────────────────────────────────────────────────────

## True when the pawn's HP has reached zero.
func is_dead() -> bool:
	return hp <= 0.0


## Internal: detect death and run the corpse-spawn pipeline.
## Called by take_damage() after _check_downed(), and by _process_statuses()
## after the bleed-out timeout fires.
##
## Re-entrant guard: returns immediately if hp > 0 or node is already freed.
##
## Pipeline (in order):
##   1. Determine cause (bleeding > _last_damage_source > "unknown").
##   2. Audit.log the death event.
##   3. Emit EventBus.pawn_died BEFORE removal so listeners see a valid pawn.
##   4. Spawn Corpse at death tile; call setup() then add_child() on parent.
##   5. Emit EventBus.corpse_spawned so hauling + UI systems react.
##   6. Unregister from World.pawns; queue_free() this node.
func _check_death() -> void:
	if not is_dead():
		return
	# Guard against re-entrant calls (bleed tick and take_damage on the same tick).
	if is_queued_for_deletion():
		return

	# Determine cause of death.
	var cause: StringName
	if has_status(&"bleeding"):
		cause = &"bleeding"
	elif _last_damage_source != &"":
		cause = _last_damage_source
	else:
		cause = &"unknown"

	Audit.log("pawn", "%s DIED (cause=%s, tile=%s)" % [pawn_name, cause, tile])

	# Notify listeners before the pawn is removed (e.g. future "saw colonist die" mood thought).
	EventBus.pawn_died.emit(self, cause)

	# Spawn corpse at the death tile.  get_parent() is the World scene root,
	# same as the WolfSpawner pattern — no get_node("/root/...") smell.
	var corpse: Corpse = CORPSE_SCENE.instantiate()
	corpse.setup(tile, pawn_name, portrait_color, cause)
	get_parent().add_child(corpse)
	Audit.log("pawn", "corpse spawned for %s at %s" % [pawn_name, tile])
	EventBus.corpse_spawned.emit(corpse)

	# Remove from World registry before freeing; _exit_tree on Corpse handles corpse registration.
	World.unregister_pawn(self)
	queue_free()


## Returns the work-speed penalty fraction from the SICK status (0.0 if not sick).
## severity 1 → 0.25 penalty (75% speed), severity 2 → 0.50, severity 3 → 0.75.
## The resulting multiplier is clamped to at least 0.25 so pawns never stop
## working entirely from illness alone (design.md — sick pawns work slowly, not stop).
func _sick_speed_penalty() -> float:
	for s in statuses:
		if s.kind == Status.Kind.SICK:
			return clampf(0.25 * float(s.severity), 0.0, 0.75)
	return 0.0


## 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:
	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()
	# Phase 14 — corpse proximity and colony-wide rotting thoughts.
	_sync_corpse_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())


## Phase 14 — sync corpse-related thoughts each sim tick.
##
## saw_corpse (EVENT): fires when any corpse is within 5 Manhattan tiles and
##   the pawn doesn't already have this thought (natural EVENT decay handles
##   the refresh window; do not re-add while it is still ticking).
##
## rotting_body_in_colony (PERSISTENT): synced from World.corpses.  After
##   sync, the existing thought's stacks are updated to match the rotting
##   count (capped at max_stacks=3) so severity scales with corpse count.
func _sync_corpse_thoughts() -> void:
	# saw_corpse — scan for any corpse within 5 Manhattan tiles.
	if not has_thought(&"saw_corpse"):
		for c in World.corpses:
			var d: int = abs(c.tile.x - tile.x) + abs(c.tile.y - tile.y)
			if d <= 5:
				add_thought(ThoughtCatalog.saw_corpse())
				Audit.log("pawn", "%s: saw_corpse thought added (corpse '%s' at dist %d)" % [pawn_name, c.deceased_name, d])
				break   # One add per tick — stacks via add_thought merge on next sighting

	# rotting_body_in_colony — PERSISTENT, severity scales with rotting count.
	var rotting_count: int = _count_rotting_corpses()
	_sync_persistent_thought(&"rotting_body_in_colony", rotting_count > 0, ThoughtCatalog.rotting_body_in_colony())
	if rotting_count > 0 and has_thought(&"rotting_body_in_colony"):
		for t in thoughts:
			if t.id == &"rotting_body_in_colony":
				t.stacks = mini(rotting_count, t.max_stacks)
				break


## Returns the count of corpses in World.corpses that are currently rotting.
func _count_rotting_corpses() -> int:
	var count: int = 0
	for c in World.corpses:
		if c.is_rotting():
			count += 1
	return count


## Phase 14 — fires when EventBus.corpse_buried is emitted (Agent B).
## Gives the buried_friend thought if this pawn was nearby (8 tiles).
## Defensive: marker may be null or lack expected fields if Agent B's
## GraveMarker shape differs; access only via null-safe .get() checks.
func _on_corpse_buried(corpse, marker) -> void:
	if corpse == null:
		return
	# Prefer the marker's tile for the burial site; fall back to corpse tile.
	var burial_tile: Vector2i = corpse.tile
	if marker != null and marker.get("tile") != null:
		burial_tile = marker.tile
	var dist: int = abs(burial_tile.x - tile.x) + abs(burial_tile.y - tile.y)
	if dist <= 8:
		add_thought(ThoughtCatalog.buried_friend())
		Audit.log("pawn", "%s: buried_friend thought added (burial at %s, dist=%d)" % [pawn_name, burial_tile, dist])


## Phase 14 — fires when EventBus.corpse_cremated is emitted (CremationPyre).
## Gives the cremated_friend thought if this pawn was nearby (8 tiles).
func _on_corpse_cremated(corpse, pyre) -> void:
	if corpse == null or pyre == null:
		return
	var pyre_tile: Vector2i = pyre.tile if pyre.get("tile") != null else Vector2i(-999, -999)
	var dist: int = abs(pyre_tile.x - tile.x) + abs(pyre_tile.y - tile.y)
	if dist <= 8:
		add_thought(ThoughtCatalog.cremated_friend())
		Audit.log("pawn", "%s: cremated_friend thought added (pyre at %s, dist=%d)" % [pawn_name, pyre_tile, dist])


## 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 14 — bleed-out timeout: if a pawn has been bleeding for
##      StatusCatalog.BLEED_OUT_TICKS (432000 ticks ≈ 6 in-game hours) with
##      no doctor treating them, force-kill via take_damage(hp) to trigger
##      _check_death(). _bleed_ticks resets when the Bleeding status is cleared
##      (i.e. when a doctor successfully treats the wound).
##   4. Phase 14 — check death from bleed damage (hp reached 0 this tick).
##   5. 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:
	var was_bleeding := false
	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:
			was_bleeding = true
			# 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 14 — bleed-out timeout.
	# Design note: BLEED_OUT_TICKS = 432000 ≈ 6 in-game hours at 20 Hz.
	# Simplified: death-by-bleed = hp ≤ 0 path through _check_death() handles
	# the moment-of-death case; the _bleed_ticks timer is the "long neglect"
	# safety net that prevents an immortal downed pawn when bleed rate is low.
	if was_bleeding:
		_bleed_ticks += 1
		if _bleed_ticks >= StatusCatalog.BLEED_OUT_TICKS:
			Audit.log("pawn", "%s bleed-out timeout after %d ticks" % [pawn_name, _bleed_ticks])
			take_damage(hp, &"bleed_out")  # lethal; triggers _check_death()
	else:
		_bleed_ticks = 0  # reset when bleeding is cleared (doctor treated)
	# 4. Phase 14 — detect death from bleed tick this frame (hp → 0 via bleeding).
	_check_death()
	# 5. 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 {
		"class_id": &"pawn",
		"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,
		# Phase 14 — bleed-out timeout counter.  Default 0 for pre-Phase-14 saves.
		"bleed_ticks": _bleed_ticks,
		"last_damage_source": String(_last_damage_source),
		"facing_x": facing.x,
		"facing_y": facing.y,
		# Phase 17 — per-pawn work-priority matrix. Keys stored as plain Strings for
		# JSON round-trip safety (StringName keys survive the cast back via StringName()).
		"work_priorities": _serialise_work_priorities(),
	}


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)))
	facing = Vector2i(int(d.get("facing_x", 0)), int(d.get("facing_y", 1)))
	# Re-mount sprite now that pawn_name is set (atlas pick is name-hash driven).
	_mount_sprite()

	_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)

	# Phase 14 — restore bleed-out counter and last damage source.
	_bleed_ticks = int(d.get("bleed_ticks", 0))
	_last_damage_source = StringName(d.get("last_damage_source", ""))
	# Recompute portrait_color from pawn_name (same formula as setup()).
	var pc_hue := float(pawn_name.hash() % 360) / 360.0
	portrait_color = Color.from_hsv(pc_hue, 0.7, 0.85)

	# 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)

	# Phase 17 — restore work_priorities; default to 3 (NORMAL) for missing keys
	# so pre-Phase-17 saves and unknown categories degrade gracefully.
	var saved_priorities: Variant = d.get("work_priorities")
	if saved_priorities is Dictionary:
		for raw_key in saved_priorities.keys():
			var cat := StringName(raw_key)
			if work_priorities.has(cat):
				work_priorities[cat] = clampi(int(saved_priorities[raw_key]), 0, 4)

	_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()])


## Serialise work_priorities as plain String keys for JSON round-trip safety.
## Only serialise categories in the known default set; unknown keys are skipped
## so save files stay forward-compatible when categories are added in future phases.
func _serialise_work_priorities() -> Dictionary:
	var out: Dictionary = {}
	for cat in work_priorities.keys():
		out[String(cat)] = int(work_priorities[cat])
	return out


# ── 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_decay buff (summer): multiplier > 1.0 → faster depletion.
	sleep = maxf(0.0, sleep - SLEEP_DECAY_PER_TICK * Storyteller.get_buff_multiplier(&"sleep_decay"))
	# 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:
		var next_tile: Vector2i = _path[0]
		# Guard: next tile may have become impassable mid-walk (e.g. another
		# pawn finished a wall on it). Walking onto it would strand us on a
		# non-walkable cell with no escape. Abort the walk; Decision reroutes.
		if World.pathfinder != null and not World.pathfinder.is_walkable(next_tile):
			_path.clear()
			_step_progress = 0.0
			if job_runner != null:
				job_runner.cancel_job()
			Audit.log("pawn", "%s walk aborted: %s became impassable" % [pawn_name, next_tile])
			return
		var delta := next_tile - tile
		if delta != Vector2i.ZERO:
			facing = _canonical_facing(delta)
		tile = next_tile
		_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)
			EventBus.pawn_arrived_at_destination.emit(self, 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)
	_update_anim()


## Pick the right animation each tick based on walk state, facing, and downed.
## Called from _process(). Cheap: only calls _sprite.play() when the target
## animation differs from the current one (AnimatedSprite2D restarts from
## frame 0 on every play() call, so we must guard).
func _update_anim() -> void:
	if _sprite == null:
		return
	if is_downed():
		if _sprite.animation != &"dead":
			_sprite.play(&"dead")
		return
	var prefix: StringName = &"walk" if is_walking() else &"idle"
	var dir_suffix: StringName = _facing_suffix()
	var target: StringName = StringName("%s_%s" % [prefix, dir_suffix])
	if _sprite.animation != target:
		_sprite.play(target)


## Map `facing` Vector2i to the animation-name suffix (down/left/right/up).
func _facing_suffix() -> StringName:
	if facing == Vector2i(0, -1):
		return &"up"
	if facing == Vector2i(-1, 0):
		return &"left"
	if facing == Vector2i(1, 0):
		return &"right"
	return &"down"


func _draw() -> void:
	# Body is the AnimatedSprite2D child (see _ready). _draw() is now overlay-only.
	# Selection ring — drawn on top of the sprite (parent _draw runs after the
	# child's z_index=-1 sprite draws).
	if _selected:
		draw_arc(Vector2.ZERO, 10.0, 0.0, TAU, 32, Color(1.0, 0.9, 0.2, 0.85), 2.0)

	# Carry indicator — draw a shrunk version of the actual item shape at the
	# pawn's upper-right ("held out at chest height").  Uses Item.draw_item_shape
	# at 0.55× scale (the shapes are authored for a 12×12 box; 0.55× → ~7×7).
	# Falls back to a hue-hashed square if the item type isn't shape-registered.
	if carried_item != null:
		var ci_center := Vector2(7.0, -12.0)
		var ci_scale := 0.55
		draw_set_transform(ci_center, 0.0, Vector2(ci_scale, ci_scale))
		if not Item.draw_item_shape(self, carried_item.item_type, carried_item.subtype):
			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, -6, 12, 12), ci_color)
			draw_rect(Rect2(-6, -6, 12, 12), Color(0, 0, 0, 0.7), false, 1.0)
		draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)


# ── 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
	)


## Maps any tile-delta to a cardinal Vector2i facing direction.  Prefers the
## axis with larger absolute magnitude; ties favor horizontal.  Returns down
## (0, 1) for zero delta as a safe default.
static func _canonical_facing(delta: Vector2i) -> Vector2i:
	if delta == Vector2i.ZERO:
		return Vector2i(0, 1)
	if abs(delta.x) >= abs(delta.y):
		return Vector2i(sign(delta.x), 0) if delta.x != 0 else Vector2i(0, 1)
	return Vector2i(0, sign(delta.y))


## Returns the {idle, walk, dead} atlas trio for a pawn.  Slice 1: always
## peasant, picked deterministically from name hash (mod 15, +1 for 001-015
## naming).  Slice 2 will branch on equipped armor (helm + cuirass + boots →
## knight atlas, etc.) at this single extension point.
const _PEASANT_COUNT: int = 15

static func _atlas_for_pawn(pawn) -> Dictionary:
	var idx: int = (absi(pawn.pawn_name.hash()) % _PEASANT_COUNT) + 1
	var n: String = "%03d" % idx
	return {
		"idle": load("res://art/sprites/characters/Character_%s_Idle.png" % n),
		"walk": load("res://art/sprites/characters/Character_%s_Walk.png" % n),
		"dead": load("res://art/sprites/characters/Character_%s_Dead.png" % n),
	}
