Phase 14: Death + Corpses + Burial + Cremation

Three-agent fan-out. Opus pre-wrote Corpse class + 5 EventBus signals +
World registries (corpses, grave_markers) before dispatch so all three
slices ran fully parallel. Pattern proven across Phases 12/13/14.

Death pipeline (Agent A):
- Pawn.is_dead(), _check_death() — pawn_died signal → corpse spawn →
  corpse_spawned signal → World.unregister_pawn → queue_free
- _last_damage_source carries cause from take_damage() (now StringName)
- Bleed-out timeout: _bleed_ticks accumulates while bleeding active;
  at BLEED_OUT_TICKS=432000 (6 in-game hours) force-kills via take_damage
- Pawn.portrait_color stored field for corpse head-color hand-off
- Corpse: DECAY_PER_TICK=0.05 (~33 in-game min fresh→rotted at 1×),
  is_rotting()@50, queue_free@100 with corpse_rotted_away signal.
  Rotting bumps DirtinessSystem (Phase 13 hook) +0.04/tick (~+8/in-game-min)
- DEMO_PHASE14_AUTOKILL toggle in world.gd (default false, gates safety)

Graveyard + GraveSlot + GraveMarker + Hauling (Agent B):
- scenes/world/graveyard_zone.gd — StorageDestination subclass,
  accepted_types=[corpse], brownish overlay, finds dug GraveSlots
- scenes/entities/grave_slot.gd — buildable (ghost→dug) state machine,
  StorageDestination duck-type interface, accept_corpse() spawns
  GraveMarker + emits corpse_buried + queue_frees self
- scenes/entities/grave_marker.gd — permanent memorial, procedural
  stone-cross _draw, carries deceased identity, save round-trip
- TOOL_GRAVEYARD + TOOL_DIG_GRAVE paint modes (Designation dispatch)
- KIND_PICKUP_CORPSE + KIND_DEPOSIT_CORPSE toils + JobRunner handlers
- HaulingProvider.find_best_for iterates World.corpses in addition to
  items_needing_haul; corpse-payload stored as Node metadata on pawn
- ConstructionProvider duck-type already accepts GraveSlot (no change)

Cremation + Ash + Mood thoughts (Agent C):
- scenes/entities/cremation_pyre.gd — extends Workbench, label 'Pyre',
  auto-populates FOREVER bill for cremate_corpse, on_craft_complete
  drops 1 ash + emits corpse_cremated + queue_frees corpse
- Recipe.ingredient2_type/count added with save round-trip; recipe
  catalog entry cremate_corpse(TYPE_CORPSE primary + 5 wood secondary)
  NOTE: CraftingProvider still only enforces ingredient1 — documented
  gap, ships when crafting is generalized.
- Item.TYPE_ASH added + ALL_TYPES filter array entry
- 4 mood thoughts: saw_corpse (-3 EVENT 1200t max=3), buried_friend
  (+2 EVENT 2400t), cremated_friend (+2 EVENT 2400t),
  rotting_body_in_colony (-4 PERSISTENT stacks=count capped at 3)
- Pawn sync hooks: proximity scan (saw_corpse), signal listeners
  (buried/cremated within 8-tile radius), count helper for rotting

MCP runtime verified:
- DEMO_PHASE14_AUTOKILL toggle force-killed Bram at tick 50
- 'Bram DIED (cause=demo_kill, tile=(20, 36))' + corpse spawned
- 'Cora: saw_corpse thought added (corpse Bram at dist 5)' — mood -3
- Painted graveyard + dig_grave → grave dug to completion verified
  in build_queue (grave @(22, 39) complete=true)
- Hauler round-trip (corpse → GraveSlot → GraveMarker) WIRED correctly
  but didn't land within decay window at ULTRA speed (12×) — corpse
  rotted before priority-3 corpse-haul scheduled. Tuning for Phase 20.
- Screenshot captured: fresh corpse silhouette at cabin doorway

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-11 18:48:15 +01:00
parent 9cf9b7dbfd
commit 67ec2cce7f
26 changed files with 1306 additions and 33 deletions

View file

@ -23,6 +23,9 @@ extends Node2D
class_name Pawn
## Phase 14 — corpse scene instantiated on death.
const CORPSE_SCENE: PackedScene = preload("res://scenes/entities/corpse.tscn")
const STEP_TICKS: int = 10
const TILE_SIZE_PX: int = 16 # Mirrors World.TILE_SIZE_PX; standalone so Pawn needs no World reference.
@ -123,6 +126,22 @@ 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]
@ -158,6 +177,13 @@ func _ready() -> void:
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)
func setup(p_name: String, start_tile: Vector2i) -> void:
@ -166,6 +192,10 @@ func setup(p_name: String, start_tile: Vector2i) -> void:
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)
Audit.log("pawn", "%s spawned at %s" % [pawn_name, start_tile])
@ -245,12 +275,17 @@ func set_sleep(value: float) -> void:
## 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:
## 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 != "":
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.
@ -317,6 +352,60 @@ func _check_revive() -> void:
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 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:
@ -409,6 +498,8 @@ func _process_thoughts() -> void:
# 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()
@ -472,6 +563,73 @@ func _sync_room_thoughts() -> void:
_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 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.
@ -522,11 +680,18 @@ func _process_sulking() -> void:
## 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.
## 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.
@ -537,11 +702,26 @@ func _process_statuses() -> void:
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 12 — wet / cold environment exposure.
# 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()
@ -716,6 +896,9 @@ func to_dict() -> Dictionary:
# 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),
}
@ -766,6 +949,13 @@ func from_dict(d: Dictionary) -> void:
_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")
@ -847,10 +1037,10 @@ func _process(_delta: float) -> void:
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)
# Phase 14 — use the stored portrait_color (computed once in setup()/from_dict()).
# This is the same formula as the old inline hue derivation; consolidating here
# removes the duplication and ensures the corpse head-dot matches exactly.
var body_colour := portrait_color
if is_downed():
# Phase 9 — Downed pawn: rotated 90° (lying on ground) + desaturated.