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:
parent
a1e5b38dd6
commit
1b6ad2bcc6
21 changed files with 1016 additions and 35 deletions
|
|
@ -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 0–10. 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 (0–10) 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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue