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>
This commit is contained in:
megaproxy 2026-05-11 16:11:36 +01:00
parent a1e5b38dd6
commit 1b6ad2bcc6
21 changed files with 1016 additions and 35 deletions

View file

@ -48,6 +48,15 @@ const HUNGER_DECAY_PER_TICK: float = 0.02 # ~100→0 over 5000 ticks; ~4 min a
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).
@ -111,6 +120,13 @@ var _sulk_low_ticks: int = 0
## 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
@ -177,8 +193,8 @@ func is_hungry() -> bool:
## True when the pawn is critically hungry and health damage is imminent.
## Used by Phase 9 status interrupts — not yet wired; exposed early for the
## save round-trip and future interrupt hook.
## 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
@ -198,7 +214,7 @@ func is_tired() -> bool:
## True when the pawn is critically sleep-deprived.
## Phase 9 status interrupt hook — not yet wired.
## Phase 9 Decision Layer-3 interrupt hook — not yet wired (exhaustion collapse).
func is_exhausted() -> bool:
return sleep < 5.0
@ -209,6 +225,83 @@ 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:
@ -343,6 +436,33 @@ func _process_sulking() -> void:
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:
@ -358,6 +478,10 @@ func to_dict() -> Dictionary:
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,
@ -372,6 +496,8 @@ func to_dict() -> Dictionary:
"sleep": sleep,
"thoughts": thoughts_data,
"mood": mood,
"hp": hp,
"statuses": statuses_data,
"sulk_low_ticks": _sulk_low_ticks,
"sulking": sulking,
}
@ -410,6 +536,16 @@ func from_dict(d: Dictionary) -> void:
_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")
@ -438,6 +574,9 @@ func _on_sim_tick(_tick_number: int) -> void:
# 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
@ -493,12 +632,20 @@ func _draw() -> void:
var hue := float(pawn_name.hash() % 360) / 360.0
var body_colour := Color.from_hsv(hue, 0.7, 0.85)
draw_circle(Vector2.ZERO, 6.0, body_colour)
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)
# 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.
# 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)