Files
TheriapolisV3/theriapolis-rpg-implementation-plan-phase5.md
Christopher Wiebe b451f83174 Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback
anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md).
All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:51 -07:00

1017 lines
50 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Theriapolis — Phase 5 — Design & Implementation Plan
## Rules, Character, Inventory, and Tactical Combat
**Status:** Proposed. Targets the codebase state as of 2026-04-24
(Phase 4 + tile-art polish complete; 256×256 world; `ENABLE_RAIL=false`;
SAVE_SCHEMA_VERSION=4; 114 tests green).
**Governing docs:**
- `theriapolis-rpg-implementation-plan.md` §§ 8.1, 8.2, 8.3 (binding)
- `theriapolis-rpg-clades.md` (content authority for Clades + Species + size)
- `theriapolis-rpg-classes.md` (content authority for Classes + Subclasses + Backgrounds)
- `theriapolis-rpg-equipment.md` (content authority for Items)
- `theriapolis-rpg-implementation-plan-phase4.md` (everything Phase 5 plugs into)
---
## 1. Goals & non-goals
### Goals
1. **A real character.** The player's `Actor` stops being a placeholder and gains
ability scores, hit points, AC, an inventory, an equipped loadout, a Clade,
a Species, a Class, and a Background — all chosen on a character-creation
screen at "New Game" and saved with the world.
2. **Combat that resolves.** Hostile spawns generated by Phase 4's
`TacticalChunkGen` (already sitting in `TacticalChunk.Spawns` and ignored)
become live NPCs. When the player and a hostile end up within initiative
range, control transfers to a turn-based combat loop that runs on the
tactical grid. Attacks, damage, HP, and death all use a d20-adjacent
resolver living in `Theriapolis.Core/Rules/Combat/`.
3. **Inventory you can feel.** Equip a weapon → attacks change. Wear armor →
AC changes. Wrong size → checks suffer. Carry too much → encumbrance
bites. The size-matching rule from `equipment.md` is a first-class concern,
not a comment.
4. **Content as data.** Clades, species, classes, items, and NPC templates
are JSON in `Content/Data/`, loaded by `ContentLoader` the same way
`biomes.json` is today. New content is a JSON edit, not a code change.
5. **Determinism preserved.** A given (worldSeed, encounterId, turnSequence)
produces identical dice outcomes. Save mid-combat, load, continue — the
resolver picks up byte-identical to the live session.
6. **Phase 4 invariants intact.** Polylines stay authoritative. Core stays
MonoGame-free. All RNG goes through `SeededRng` with new named sub-streams
declared in `Constants.cs`.
### Non-goals (explicit)
- **Subclasses (level 3+).** Phase 5 ships every class at **level 1 only**.
The schema and content files carry full level tables for forward
compatibility, but only level-1 features have runtime effect. Subclass
selection is reserved for the level-up flow that Phase 5.5 / 6 builds.
- **Levelling beyond character creation.** XP is awarded and persisted, but
there is no level-up screen and `Character.Level` stays at 1. A `level-up`
command in Tools is a stretch goal for end-of-phase polish.
- **Hybrid characters.** Optional rules from `clades.md`. Defer to Phase 6.
- **Pheromone Craft, Covenant Authority, Vocalization abilities.** Each is
a non-trivial subsystem that touches social/scent state Phase 6 owns.
Their level-1 hooks (e.g. Scent-Broker's *Scent Literacy*, Covenant-Keeper's
*Mark of the Oath*) ship as **stubs** — they exist, they capture the
situation, but resolution is deferred. Combat-effect features (Fangsworn
*Fighting Style*, Bulwark *Sentinel Stance*, Feral *Rage*, Shadow-Pelt
*Sneak Attack*) ship for real.
- **Faction logic, dialogue, quests, reputation events.** Phase 6.
`SaveBody.Factions / QuestState / Reputation` stay as the empty
containers Phase 4 reserved.
- **Friendly NPCs.** `SpawnKind.Merchant` / `Patrol` chunk records are
loaded but not instantiated as live actors. Only `SpawnKind.WildAnimal`,
`Brigand`, and `PoiGuard` become combat-capable NPCs. Merchants /
patrols remain as static map markers; Phase 6 makes them interactive.
- **Long-rest / short-rest mechanics.** Phase 5 treats every combat as
fully-rested (uses-per-rest features reset between encounters). The full
rest model — campfires, time-of-day exhaustion, encumbered sleep — is
Phase 8's problem when the world clock drives diurnal state.
- **PoI interiors / dungeons.** Phase 7. Combat in Phase 5 happens in the
open world tactical view only.
---
## 2. Current-state inventory (what we plug into)
Audited 2026-04-24:
| Piece | Where | Phase 5 use |
|---|---|---|
| `Actor` skeleton | [Actor.cs](Theriapolis.Core/Entities/Actor.cs) | Add `Character?` ref + `IsHostileTo(Actor)` helper |
| `PlayerActor` | [PlayerActor.cs](Theriapolis.Core/Entities/PlayerActor.cs) | Extend `CaptureState`/`RestoreState` to include `Character` |
| `ActorManager` | [ActorManager.cs](Theriapolis.Core/Entities/ActorManager.cs) | Add `SpawnNpc`, `RemoveActor`, `Tick(dt)` |
| `TacticalChunk.Spawns` | [TacticalChunk.cs:20](Theriapolis.Core/Tactical/TacticalChunk.cs) | Source for `NpcSpawnerStage` to instantiate live NPCs |
| `SpawnKind` enum | [TacticalChunk.cs:69](Theriapolis.Core/Tactical/TacticalChunk.cs) | Maps to `NpcTemplateDef.Id` (e.g. `Brigand``npc.brigand_footpad`) |
| Reserved save fields | [SaveBody.cs:23-30](Theriapolis.Core/Persistence/SaveBody.cs) | Phase 5 leaves these empty (still Phase 6) |
| `IPersistable<T>` | [IPersistable.cs](Theriapolis.Core/Persistence/IPersistable.cs) | New persistables: `EncounterState`, `NpcRosterState` |
| `ContentLoader` | [ContentLoader.cs](Theriapolis.Core/Data/ContentLoader.cs) | Add `LoadClades`, `LoadSpecies`, `LoadClasses`, `LoadItems`, `LoadNpcTemplates` |
| `SeededRng` | [SeededRng.cs](Theriapolis.Core/Util/SeededRng.cs) | New sub-streams: `RNG_CHARACTER`, `RNG_COMBAT`, `RNG_NPC_SPAWN`, `RNG_LOOT` |
| `PlayScreen` | [PlayScreen.cs](Theriapolis.Game/Screens/PlayScreen.cs) | Push/pop `CombatHUDScreen` when an encounter starts; route input to `CombatController` |
| Tactical step counter | [PlayerController.cs:103-104](Theriapolis.Game/Input/PlayerController.cs) | Repurpose: distance-walked drives both clock and **encounter trigger checks** |
| `WorldClock` | [WorldClock.cs](Theriapolis.Core/Time/WorldClock.cs) | Combat suspends world-clock advancement; resumes after |
| Constants | [Constants.cs](Theriapolis.Core/Constants.cs) | All Phase 5 numbers (XP table, encounter trigger radius, AC ceiling, etc.) live here |
Three facts that materially shape Phase 5:
- **Spawns already exist per chunk.** No new generation pass; just an
*instantiation* pass that happens when `ChunkStreamer` brings a chunk into
the active window.
- **`SaveBody` has reserved containers** that match the schema for
Phase 5/6 needs — but `Player` only carries `PlayerActorState`, not
character data. We extend `PlayerActorState` with `Character` (or add a
parallel `PlayerCharacterState` field on `SaveBody`). The plan below
takes the second path so `PlayerActorState` stays a pure positional
snapshot — clean separation of "where the body is" from "what the body
is".
- **Tactical combat happens in the same coordinate space as movement.**
The combat resolver doesn't need a new map — it uses the live
`ChunkStreamer` window. This is the payoff of Phase 4's coordinate model.
---
## 3. Phase 5 architecture
### 3.1 Module layout (new directories — all greenfield except where noted)
```
Theriapolis.Core/
Rules/
Stats/
AbilityScores.cs struct — STR/DEX/CON/INT/WIS/CHA + Mod() helper
ProficiencyBonus.cs static — level → +N table
SkillId.cs enum — 18 skills
SaveId.cs enum — 6 saves + which ability backs each
DamageType.cs enum — bludg/pierce/slash/fire/cold/lightning/poison/psychic/thunder
Condition.cs enum — prone/frightened/restrained/grappled/dazed/exhausted/blinded/stunned/unconscious
DerivedStats.cs static — HP/AC/Initiative/Speed/CarryCap from (clade, species, class, equipment)
Character/
Character.cs class — runtime aggregate; refs CladeDef + SpeciesDef + ClassDef + ability scores + level + xp + HP curr/max + Inventory
CharacterBuilder.cs class — fluent builder; used by both screen and tests
Background.cs record — id + name (mechanical effects: Phase 6)
Combat/
Encounter.cs class — owns participants, initiative order, current turn pointer, log, per-encounter SeededRng
Turn.cs struct — current actor's remaining action / bonus / reaction / movement
Resolver.cs static — pure functions: AttemptAttack, RollDamage, ApplyDamage, MakeSave, ApplyCondition
AttackProfile.cs record — derived from (weapon, attacker, target, situation); immutable per-resolution
DamageRoll.cs record — dice pool + flat mods + type
LineOfSight.cs static — tactical-tile Bresenham; blocked by IsBlockingDeco / wall
ReachAndCover.cs static — size-aware reach, cover from same-size terrain features
Entities/
Actor.cs EXTEND — add `Character? Character`, `bool IsAlive`, `Faction Allegiance` (placeholder enum)
NpcActor.cs class — Actor + AI state (current target, last-seen position, behavior tree id)
PlayerActor.cs EXTEND — CaptureState/RestoreState include character snapshot
ActorManager.cs EXTEND — `SpawnNpc(NpcTemplateDef, Vec2)`, `RemoveActor(int)`, `EncounterCheck()` per tick
Ai/
BehaviorBrigand.cs — move toward player, attack when in melee reach
BehaviorWildAnimal.cs — attack if engaged, flee at low HP
BehaviorPoiGuard.cs — patrol around POI center, engage intruders
AiContext.cs — read-only view of map + actors used by behaviors
Items/
ItemDef.cs record — JSON-loaded; id/kind/weight/cost/properties/damage/ac/sizes
ItemKind.cs enum — Weapon, Armor, Shield, Consumable, AdventuringGear, NaturalWeaponEnhancer
ItemInstance.cs class — runtime: ref to ItemDef + qty + condition + (optional) custom name
Inventory.cs class — list of instances + equip-slot map + encumbrance accessor
EquipSlot.cs enum — MainHand, OffHand, Body, Helm, Cloak, Boots, AdaptivePack, NaturalWeaponEnhancer×3
SizeMatch.cs static — small/medium/large weapon/armor compatibility check
Data/
ContentLoader.cs EXTEND — LoadClades/LoadSpecies/LoadClasses/LoadItems/LoadNpcTemplates
CladeDef.cs record — JSON: id, name, abilityMods, traits[], detriments[], languages[]
SpeciesDef.cs record — JSON: id, cladeId, name, size, abilityMods, traits[], detriments[]
ClassDef.cs record — JSON: id, name, hitDie, primaryAbility[], saves[], armorProf[], weaponProf[], skillsChoose, skillOptions[], levelTable[]
SubclassDef.cs record — JSON: id, classId, name, levelFeatures[] (loaded but unused in Phase 5)
BackgroundDef.cs record — JSON: id, name, skillProf[], toolProf[], featureText (mechanical effects deferred)
NpcTemplateDef.cs record — JSON: id, name, abilityScores, hp, ac, attacks[], speed, size, behaviorId, lootTableId
Persistence/
SaveBody.cs EXTEND — add `PlayerCharacterState PlayerCharacter`, `List<NpcDelta> ChunkNpcs`, `EncounterState? ActiveEncounter`
PlayerCharacterState.cs class — serializable: clade/species/class/background ids, ability scores, level, xp, hp curr, conditions[], inventory[], equipped slots
NpcDelta.cs struct — chunkCoord + localPos + templateId + isAlive + hpCurrent (for surviving NPCs)
EncounterState.cs class — only present mid-combat: encounterId, participants[], initiativeOrder, currentTurnIndex, perEncounterRngState
SaveCodec.cs EXTEND — add tag IDs ≥100: TAG_CHARACTER=100, TAG_NPC_ROSTER=101, TAG_ENCOUNTER=102
Theriapolis.Game/
Screens/
CharacterCreationScreen.cs — pre-game: clade → species → class → background → name → confirm
InventoryScreen.cs — modal during play (TAB key)
CombatHUDScreen.cs — overlay screen pushed when encounter starts; replaces PlayerController input routing
UI/
StatBlockPanel.cs — Myra panel; reusable in CharacterCreation + Inventory + CombatHUD
InitiativeStrip.cs — turn order + HP bars
CombatActionBar.cs — action buttons (Attack / Move / Use Item / End Turn / etc)
DamageNumberOverlay.cs — floating damage numbers in tactical view
Input/
CombatController.cs — replaces PlayerController while CombatHUDScreen is on top
Theriapolis.Tools/Commands/
CharacterRoll.cs — `dotnet run -- character-roll --seed N --clade canidae --species wolf --class fangsworn`
CombatDuel.cs — `dotnet run -- combat-duel --seed N --a npc.brigand --b npc.wolf --rounds 20`
ContentValidate.cs — sanity check: every species references a real clade, every npc uses real items, every class has level 1 features, etc.
Theriapolis.Tests/
Rules/
AbilityScoreTests.cs
DerivedStatsTests.cs
LevelTableTests.cs
CharacterBuilderTests.cs — every (clade × species × class) combo produces a valid level-1 character
InventoryEquipTests.cs
SizeMatchTests.cs
AttackResolutionTests.cs
DamageDeterminismTests.cs — same (encounterSeed, sequence) → identical rolls
ConditionTests.cs
LineOfSightTests.cs
ReachAndCoverTests.cs
Combat/
EncounterScenarioTests.cs — scripted: brigand vs wolf-folk fangsworn → expected outcome at seed X
InitiativeTests.cs
AiBehaviorTests.cs
Persistence/
CharacterSaveRoundTripTests.cs
NpcRosterRoundTripTests.cs
MidCombatSaveRoundTripTests.cs — save mid-encounter, load, finish, expected outcome unchanged
Architecture/
CoreNoDependencyTests.cs EXISTING — extended assertion: Rules/* also has no MonoGame ref
Content/Data/
clades.json — all 7 clades (Canidae, Felidae, Mustelidae, Ursidae, Cervidae, Bovidae, Leporidae)
species.json — all 19+ species per the quick-ref table in clades.md
classes.json — all 8 classes; full level table for forward compat; level-1 features only have runtime effect
subclasses.json — content present, mechanics deferred
backgrounds.json — all 12 backgrounds; mechanical effects deferred
items.json — Phase 5 subset: ~12 weapons, ~6 armors, ~3 shields, ~6 consumables, ~6 adventuring gear, ~4 natural weapon enhancers
npc_templates.json — Phase 5 set: brigand_footpad, brigand_marauder, brigand_archer, wolf, dire_wolf, bear_brown, wolverine, poi_guard_skeletal, poi_guard_corrupted
```
### 3.2 The actor / character split
Keep `Actor` lean (position, facing, speed — what the renderer cares about).
All gameplay state hangs off the optional `Character` ref:
```csharp
public class Actor {
public int Id { get; init; }
public Vec2 Position { get; set; }
public float FacingAngleRad { get; set; }
public float SpeedWorldPxPerSec { get; set; } = C.PLAYER_TRAVEL_PX_PER_SEC;
// NEW in Phase 5:
public Character? Character { get; set; } // null on actors that aren't combat-capable yet
public Allegiance Allegiance { get; set; } // Player | Allied | Neutral | Hostile
public bool IsAlive => Character?.IsAlive ?? true;
}
```
`PlayerActor` and `NpcActor` both have a non-null `Character`. The
`Character?` nullability is for forward-compat: future actor types
(merchants, scenery NPCs) may not need stat blocks.
This avoids a single mega-class and keeps the rendering layer ignorant of
the stat layer. `Camera2D` doesn't need to know what HP is.
### 3.3 Coordinate systems for combat
Combat happens entirely in **tactical-tile space**, which is the same as
**world-pixel space** (1 tactical tile = 1 world pixel — Phase 4
contract). Reach, range, and movement are measured in tactical tiles.
Size-to-occupancy mapping (from `clades.md` size appendix):
| Size | Tactical-tile footprint | Reach (melee) |
|------------|-------------------------|---------------|
| Small | 1×1 | 1 tile |
| Medium | 1×1 | 1 tile |
| Medium-Large | 1×1 (counts as Large for grappling/carrying) | 1 tile |
| Large | 2×2 | 2 tiles |
(The plan reserves 3×3 for Huge but no Phase 5 NPC uses it.)
This is a deliberate simplification: in tabletop terms a "5 ft. reach"
weapon = 1 tile, and "10 ft. reach" / Large body = 2 tiles. Anything
finer (15 ft. reach polearms, etc.) becomes a weapon `Reach: 2` tag in
its `ItemDef`.
### 3.4 Combat encounter lifecycle
```
Player walks tactical → ChunkStreamer hands ActorManager the chunk's
spawned NPCs (instantiated lazily on first stream-in).
Per tick (PlayScreen.Update):
ActorManager.EncounterCheck():
For each HOSTILE NPC within ENCOUNTER_TRIGGER_TILES (default 8) of
player AND with line of sight:
→ start an Encounter (auto-triggered)
→ push CombatHUDScreen
→ halt PlayerController, hand input to CombatController
→ halt WorldClock advance until encounter ends
ActorManager.NeutralProximityCheck():
For each FRIENDLY/NEUTRAL NPC within INTERACT_PROMPT_TILES (default 2)
of player AND with line of sight:
→ show "Press F to interact with <name>" prompt overlay
→ on F press → InteractionScreen (Phase 6 attaches dialogue)
While CombatHUDScreen is on top:
CombatController drives the active actor's Turn:
Player turn: input → AttackProfile → Resolver.AttemptAttack → log + damage numbers
NPC turn: behavior → AttackProfile → Resolver.AttemptAttack → log + damage numbers
When all hostiles are dead OR all hostiles are out of sight + 3 turns:
→ encounter ends
→ resolve XP award (player only) and loot drops
→ pop CombatHUDScreen
→ resume WorldClock + PlayerController
```
**Two distinct interaction models, by allegiance:**
- **Hostile** → auto-trigger combat on LOS-within-range. The player
doesn't need to press anything — walking into a brigand's sight line
*is* the engagement. This matches CRPG convention and removes a
finicky "press F to attack" step in a continuous-movement view.
- **Friendly / Neutral** → walk-up prompt. A small overlay
(`"[F] Talk to Mara the Innkeeper"`) appears when the player gets
within 2 tactical tiles. Pressing F opens an `InteractionScreen`.
Phase 5 ships the prompt + screen *shell*; the screen body is empty
(placeholder text) until Phase 6 attaches dialogue. This carves out
the input path now so Phase 6 doesn't have to refactor encounter
triggering.
`Allegiance` is set per-NPC at instantiation from
`NpcTemplateDef.DefaultAllegiance`. Brigands / Wild Animals / PoI
Guards default Hostile; Merchants / Patrols default Neutral. Future
faction logic can mutate this at runtime (Phase 6).
### 3.5 The dice contract
All combat dice go through a per-encounter `SeededRng` keyed by:
```
encounterSeed = worldSeed
^ C.RNG_COMBAT
^ EncounterId
```
`EncounterId` = `(chunkCoord.X << 32) | chunkCoord.Y << 16 | encounterIndexInChunk`.
This means:
- The **same** encounter triggered the **same** way in two playthroughs at
the same seed produces identical first-roll outcomes.
- Player choices (which target, which weapon) immediately diverge the
state, so realistic playthroughs see independent outcomes after move 1.
- Save mid-combat captures `currentTurnIndex` + `nextRollSequence`
`Encounter.RollD20()` and friends pull from
`SeededRng(encounterSeed).Skip(nextRollSequence).NextUInt64()`
byte-identical resume.
This is the same pattern Phase 4 uses for chunk gen. New constants:
```csharp
public const ulong RNG_CHARACTER = 0xC4A2AC7EUL; // ability score rolls + starting inventory
public const ulong RNG_COMBAT = 0xC0B47UL; // per-encounter dice
public const ulong RNG_NPC_SPAWN = 0xA7C2AUL; // template variation when instantiating from chunk spawn list
public const ulong RNG_LOOT = 0x107EUL; // post-encounter drops
```
---
## 4. Subsystem detail
### 4.1 Stats
`AbilityScores` is a 6-byte struct (each score 130; level-1 cap 18 before
clade/species mods, hard cap 20 before level-20 features). `Mod(int score)`
returns `(score - 10) / 2` rounded toward negative infinity per d20 rules.
`DerivedStats` is computed from the live `Character` and re-runs whenever
a relevant input changes (level up, equip change, condition applied):
| Stat | Formula |
|---|---|
| MaxHP | `class.HitDie + Mod(CON)` at level 1; later levels add `RollHitDie() + Mod(CON)` (rolled at level-up time, then frozen) |
| AC | `armor.BaseAc + min(Mod(DEX), armor.MaxDexBonus) + shield.AcBonus + featureBonuses` |
| Initiative | `Mod(DEX) + featureBonuses` (e.g. Feral L7 adds Mod(WIS)) |
| Speed | `species.BaseSpeed + classMods + conditionMods` (clamped ≥ 0) |
| CarryCap | `STR * 15` lb (halved for Small, doubled for Large per equipment.md) |
Skill checks: `1d20 + Mod(stat) + (proficient ? ProfBonus : 0)`. Tools
can run an interactive `character-roll` to dump every roll.
### 4.2 Character creation
Screen flow (single Myra panel, multi-step):
1. **Clade.** Picker of 7 cards: Canidae / Felidae / Mustelidae / Ursidae /
Cervidae / Bovidae / Leporidae. Each shows ability mods + 1-line trait
summary.
2. **Species.** Filtered to the selected clade. Shows size, ability mods,
defining trait.
3. **Class.** All 8. Shows hit die, primary ability, level-1 feature
names. Recommends species/class fits (informational only — no
restrictions).
4. **Background.** All 12. Skill proficiencies + tool proficiencies +
feature text. Mechanical effects of features are stubbed for Phase 5.
5. **Stats.** Two methods, player choice:
- **Standard array:** 15, 14, 13, 12, 10, 8 (assignable). Default.
- **Roll 4d6 drop lowest** ×6, assign. Each *Reroll* press derives a
fresh seed:
```
statRollSeed = worldSeed ^ RNG_STAT_ROLL ^ msSinceGameStart
```
`msSinceGameStart` is wall-clock ms since the process started
(`Stopwatch.GetTimestamp` snapshot at game launch).
`CharacterBuilder.RollAbilityScores` accepts an
`ulong? msOverride = null` parameter; when non-null, that value is
used in place of the live ms snapshot. Tests use the override; the
game does not. This gives true non-reproducibility across plays
(player gets fresh dice every press), reproducibility within tests
(fixed override → fixed roll), and worldseed-anchored variation
(the same ms snapshot in two different worlds yields different
rolls because `worldSeed` is XORed in).
6. **Skills.** Class lists `skillsChoose` and `skillOptions`. Player
picks N from the offered list (in addition to background's two free).
7. **Name + confirm.** Default "Wanderer". On confirm: `CharacterBuilder`
produces a `Character`, `ActorManager.SpawnPlayer` is called with
that character attached.
**Validation:** every step's Next button is disabled until the field is
valid. `CharacterBuilder.TryBuild(out string error)` is the single
canonical check.
### 4.3 Inventory + equipment
```csharp
public sealed class Inventory {
public List<ItemInstance> Items { get; } = new();
public Dictionary<EquipSlot, ItemInstance?> Equipped { get; } = new();
public float TotalWeight => Items.Sum(i => i.Def.Weight * i.Qty);
public bool TryEquip(ItemInstance item, EquipSlot slot, out string err);
public bool TryUnequip(EquipSlot slot, out string err);
}
```
Equip slots:
| Slot | Notes |
|---|---|
| MainHand | Weapon or two-handed weapon |
| OffHand | Shield, light weapon (dual-wield), or empty |
| Body | Armor (Light/Medium/Heavy) or none |
| Helm | Optional |
| Cloak | Optional |
| Boots | Optional |
| AdaptivePack | Backpack (raises CarryCap) |
| NaturalWeapon×3 | Fang Caps / Claw Sheaths / Hoof Plates / Antler Tips / Horn Rings (slot N for which natural-weapon location they augment) |
`TryEquip` enforces:
- Weapon proficiency (class.WeaponProf must contain the item's
proficiency tag, or the wielder takes disadvantage on attacks).
- Armor proficiency (no proficiency = disadvantage on STR/DEX checks +
no spellcasting if applicable; for Phase 5, just disadvantage).
- Size compatibility: `SizeMatch.Check(item.Sizes, character.Size)`. If
no match and item is **not** Adaptive, equip succeeds but sets a
`WrongSize` flag → disadvantage on attack rolls / -2 AC.
- Two-handed weapons clear the OffHand slot.
- Heavy armor STR requirement (item.MinStr).
Encumbrance: at >100% capacity → speed -10 ft.; >150% → speed halved
+ disadvantage on STR/DEX/CON checks.
### 4.4 Combat resolver
`Resolver` is a static class with pure functions. The `Encounter` is the
only stateful object; everything else is computed from `(Encounter,
attackerId, targetId, abilityKey, rngSequence)`.
**`AttemptAttack(encounter, attacker, target, weapon, situation)`:**
1. Compute `AttackProfile` (attack bonus, damage dice, type, reach).
2. Roll d20 (advantage/disadvantage from `situation` flags).
3. Compare to `target.AC`. Critical hit on natural 20 (or 19-20 for
crit-range weapons — e.g. Razored Claw Sheaths).
4. On hit: roll damage dice (doubled on crit per d20 rules — *only the
dice double, not the modifiers*).
5. Apply target HP reduction.
6. Append a `CombatLogEntry` with dice values, modifiers, and outcome.
**`MakeSave(encounter, target, saveId, dc)`:** d20 + ability mod + (prof
if proficient). Returns `bool` and logs.
**`ApplyCondition(encounter, target, condition, durationRounds)`:** sets
the condition; duration ticks down at end of target's turn. Conditions
modify subsequent `AttackProfile` / `MakeSave` evaluations through
`SituationFlags`.
**`Death`:** Phase 5 ships **permadeath** as the only death model.
- NPC at HP ≤ 0 → removed from `ActorManager` immediately; chunk's
`NpcDelta` records the kill so the body stays gone after a save/load
cycle.
- Player at HP ≤ 0 → start death saves (1d20 every turn; 3 cumulative
successes ≥ 10 = stabilised at 0 HP, recoverable to 1 HP via ally's
Field Repair / Healer's Kit; 3 cumulative failures < 10 = **death**).
A natural 20 on a death save = revive at 1 HP. A natural 1 = two
failures. (Standard d20 death-save loop.)
- On player death: an unrecoverable `DeathScreen` appears with a single
option, `[Return to title]`. The player's most recent save (theirs, or
the encounter-start autosave) is the recovery mechanism — there is no
in-world respawn or resurrection in Phase 5.
**Save-anywhere is the player's hedge against permadeath.** The pause
menu (ESC during play) gains a `Save Game` action that writes to the
current slot at any time *except* during cut scenes. Phase 5 has no
cut scenes, so save-anywhere is unconditional in this phase — the
exclusion is forward-compat scaffolding for whichever phase introduces
narrative interrupts. Combined with the existing autosave-on-screen-
transition (Phase 4), the player can save right before an ambush, lose,
and reload to retry. Permadeath here means "no in-world respawn", not
"no second chances".
**Save-anywhere wiring:**
- Pause menu adds `Save Game` button → calls `SaveCodec.Write(currentSlot)`.
- New autosave point: combat-start writes to slot `autosave_combat`,
separate from the rolling autosave slot, so the player can always
retry the most recent fight even if their manual save is older.
- `EncounterState` already round-trips per §4.7 — saving mid-combat
works as a side effect of save-anywhere.
### 4.5 NPC + AI
Templates are JSON. Behaviors are code (small switch statement, not a
behavior-tree library — keep dependencies down):
```csharp
public interface INpcBehavior {
void TakeTurn(NpcActor self, Encounter enc, AiContext ctx);
}
```
Three behaviors ship in Phase 5:
| Behavior | Logic |
|---|---|
| `Brigand` | If has ranged weapon and target > 4 tiles → ranged attack. Else move toward target along walkable tactical tiles, attack when in reach. |
| `WildAnimal` | Identical to `Brigand` but flees (move directly away from target) when at < 25% HP. |
| `PoiGuard` | Patrol around POI center while no target in sight. Engage like `Brigand` once target enters sight. |
`AiContext` provides the read-only view: tactical tiles in current chunk
window, other actors, line of sight via `ReachAndCover.LineOfSight`. AI
**must not** mutate the world directly; it returns intent and the
`Encounter` resolves it through `Resolver`.
### 4.6 Difficulty scaling — danger-zone template selection
Phase 3 already produces an `EncounterDensity` map (Stage 20). Phase 5
layers a **DangerZone** index on top, computed once per chunk at
instantiation time:
```csharp
int DangerZone(WorldTile homeTile, ChunkCoord c) {
int distFromStartTiles = ChebyshevDist(c.WorldTileCenter, world.PlayerStartTile);
int distFromRoadTiles = NearestPolylineDistance(c.WorldTileCenter, world.Roads);
int distFromSettlementTiles = NearestSettlementDistance(c.WorldTileCenter);
int zone = 0;
zone += distFromStartTiles / C.DANGER_DIST_FROM_START_PER_ZONE; // +1 per 50 tiles
zone += (distFromRoadTiles > C.DANGER_DIST_FROM_ROAD_THRESHOLD) ? 1 : 0;
zone += (distFromSettlementTiles > C.DANGER_DIST_FROM_SETTLE_THRESHOLD) ? 1 : 0;
zone += BiomeDangerBonus(homeTile.Biome); // grassland 0, forest +0, mountain +1, marsh +1, badlands +2
return Math.Clamp(zone, C.DANGER_ZONE_MIN, C.DANGER_ZONE_MAX);
}
```
Five zones (0 = safest, 4 = deepest wilds). Each `SpawnKind` consults a
**zone → template id** lookup table living in `npc_templates.json`:
```jsonc
{
"spawn_kind_to_template_by_zone": {
"Brigand": [
"brigand_footpad", // zone 0: weakest
"brigand_footpad",
"brigand_marauder",
"brigand_marauder",
"brigand_captain" // zone 4: deepest wilds
],
"WildAnimal": [
"wolf_pup",
"wolf",
"wolf",
"dire_wolf",
"bear_brown" // zone 4: bears in the deep wilds
]
}
}
```
This means **NPC stats themselves are fixed per template** (no
per-instance scaling) — what varies is *which template* spawns where.
Easier to balance, easier to test, easier to communicate to the player
("the deep woods have bears" rather than "bears here have +20% HP").
`DangerZone` is computed once per chunk at instantiation and stored on
`TacticalChunk.DangerZone` (new byte field). The chunk's hash includes
it, so determinism tests catch any change to the zone formula.
`combat-duel` Tools command grows a `--zone N` flag for testing the
template lookup at a specific danger level.
### 4.7 Save schema
Two new fields on `SaveBody`:
```csharp
public PlayerCharacterState PlayerCharacter { get; set; } = new();
public List<NpcDelta> ChunkNpcs { get; set; } = new();
public EncounterState? ActiveEncounter { get; set; } // null when not in combat
```
New `SaveCodec` tags (≥100 per the Phase 4 forward-compat rule):
```
TAG_CHARACTER = 100 // PlayerCharacterState
TAG_NPC_ROSTER = 101 // List<NpcDelta>
TAG_ENCOUNTER = 102 // EncounterState? (writes only if present)
```
Bump `SAVE_SCHEMA_VERSION` to **5**. Add a no-op migration `V4ToV5`:
empty character → game refuses to load and prompts player to start a
new game from the same seed (so existing Phase-4 saves don't crash, but
they also don't auto-promote to Phase-5 saves with no character).
`PlayerCharacterState` is a flat record of primitives + arrays — same
discipline as `PlayerActorState`. `Inventory` serializes as a list of
`(itemId, qty, equipSlot?)` triples.
`ChunkNpcs` is per-chunk override list: any chunk whose NPCs have
diverged from the deterministic `Pass5_Spawns` baseline (one or more
killed; HP changed mid-chunk) shows up here. Empty list = baseline
roster intact. Flushed to save at chunk eviction or autosave.
`ActiveEncounter` is the rare case: player saved during combat. Loading
restores the encounter mid-state and pushes `CombatHUDScreen` directly
from the load path.
### 4.8 Content schemas
```jsonc
// clades.json
{
"id": "canidae",
"name": "Canidae",
"ability_mods": { "CON": 1, "WIS": 1 },
"size": null, // size lives on species, not clade
"traits": [
{ "id": "pack_instinct", "name": "Pack Instinct", "description": "..." },
{ "id": "superior_scent", "name": "Superior Scent", "description": "..." }
],
"detriments": [
{ "id": "pack_dependent", "name": "Pack-Dependent", "description": "..." }
],
"languages": ["common", "canid"]
}
// species.json
{
"id": "wolf",
"clade_id": "canidae",
"name": "Wolf-Folk",
"size": "medium_large",
"ability_mods": { "STR": 1 },
"base_speed_ft": 30,
"traits": [
{ "id": "jaws_of_the_alpha", "natural_weapon": { "name": "Bite", "damage": "1d8", "type": "piercing", "scaling_levels": [5, 11] } },
{ "id": "tireless_pursuit", "description": "..." },
{ "id": "howl", "description": "...", "uses_per_long_rest": 1 }
],
"detriments": [...]
}
// classes.json
{
"id": "fangsworn",
"name": "Fangsworn",
"hit_die": 10,
"primary_ability": ["STR", "DEX"],
"saves": ["STR", "CON"],
"armor_proficiencies": ["light", "medium", "heavy", "shields"],
"weapon_proficiencies": ["simple", "martial", "natural"],
"skills_choose": 2,
"skill_options": ["athletics", "intimidation", "perception", "survival", "animal_handling"],
"level_table": [
{ "level": 1, "prof": 2, "features": ["fighting_style", "claw_and_steel"] },
{ "level": 2, "prof": 2, "features": ["action_surge_1"] },
/* ...through level 20... */
],
"feature_definitions": {
"fighting_style": { "kind": "choice", "options": ["fang_and_blade", "shieldwall", "duelist", "great_weapon", "natural_predator"] },
"claw_and_steel": { "kind": "passive", "effect": "combine_natural_and_weapon_attacks" },
"action_surge_1": { "kind": "active", "uses_per_short_rest": 1, "effect": "extra_action" }
}
}
// items.json — Phase 5 v1 set (~30 items)
{
"id": "rend_sword",
"kind": "weapon",
"name": "Rend-sword",
"cost_fang": 25,
"weight_lb": 3,
"sizes": ["medium", "large"],
"proficiency": "martial",
"damage": "1d8",
"damage_versatile": "1d10",
"damage_type": "slashing",
"properties": ["versatile"]
}
// npc_templates.json
{
"id": "brigand_footpad",
"name": "Brigand Footpad",
"size": "medium",
"ability_scores": { "STR": 11, "DEX": 14, "CON": 12, "INT": 10, "WIS": 10, "CHA": 9 },
"hp": 11,
"ac": 12,
"speed_ft": 30,
"attacks": [
{ "name": "Scruff-knife", "to_hit": 4, "damage": "1d4+2", "type": "slashing", "reach_tiles": 1 }
],
"behavior": "brigand",
"loot_table": "loot_brigand_low",
"xp_award": 25
}
```
`ContentValidate` Tools command verifies referential integrity at build
time and fails CI on any broken reference.
---
## 5. Determinism & RNG
Every dice roll's source must be reproducible.
| RNG sub-stream | Used by |
|---|---|
| `RNG_CHARACTER` | Character-creation 4d6-drop-lowest rolls; starting equipment variation |
| `RNG_NPC_SPAWN` | Per-NPC variation when a chunk's spawn list is instantiated (e.g. brigand's stat-block rolls) |
| `RNG_COMBAT` | All in-combat dice (attack rolls, damage, saves, condition durations) |
| `RNG_LOOT` | Post-encounter drops (loot table evaluation) |
Per-encounter sub-seed:
`encounterSeed = worldSeed ^ RNG_COMBAT ^ encounterId`.
The encounter's `SeededRng` advances monotonically. `EncounterState`
serializes `(encounterSeed, nextRollSequence)`; resume re-creates the
RNG and skips to that sequence. Verified by
`MidCombatSaveRoundTripTests`.
**Tests required:**
- `DamageDeterminismTests` — 1000 rolls from `(seedA, seq=0..999)`
produce the same outputs across 5 process runs.
- `MidCombatSaveRoundTripTests` — scripted encounter, save at turn 3,
load, complete; final HP/log identical to running it through to
completion in one go.
---
## 6. Constants going into `Constants.cs`
```csharp
// ── Phase 5: RNG sub-streams ──────────────────────────────────────────
public const ulong RNG_CHARACTER = 0xC4A2AC7EUL;
public const ulong RNG_STAT_ROLL = 0x57A7507UL; // 4d6-drop-lowest re-rolls in char creation
public const ulong RNG_COMBAT = 0xC0B47UL;
public const ulong RNG_NPC_SPAWN = 0xA7C2AUL;
public const ulong RNG_LOOT = 0x107EUL;
// ── Phase 5: Encounter triggering ────────────────────────────────────
public const int ENCOUNTER_TRIGGER_TILES = 8; // tactical tiles; LoS still required (hostiles)
public const int INTERACT_PROMPT_TILES = 2; // tactical tiles; LoS still required (friendly/neutral)
public const int ENCOUNTER_DISENGAGE_TILES = 16; // hostiles out of sight + this far → encounter ends
public const int ENCOUNTER_DISENGAGE_TURNS = 3; // ...for this many consecutive turns
// ── Phase 5: Combat ──────────────────────────────────────────────────
public const int AC_FLOOR = 5; // sanity clamp
public const int AC_CEILING = 30;
public const int HP_MAX = 999; // Phase 5 won't exceed this
public const int DEATH_SAVES_TO_DIE = 3;
public const int DEATH_SAVES_TO_STABLE = 3;
public const int CRIT_NATURAL = 20;
// ── Phase 5: Encumbrance ─────────────────────────────────────────────
public const float ENCUMBRANCE_SOFT_MULT = 1.0f; // ≥1.0× capacity → speed -10ft
public const float ENCUMBRANCE_HARD_MULT = 1.5f; // ≥1.5× capacity → speed halved + disadvantage
// ── Phase 5: Difficulty scaling (danger zones) ───────────────────────
public const int DANGER_DIST_FROM_START_PER_ZONE = 50; // tiles per +1 zone
public const int DANGER_DIST_FROM_ROAD_THRESHOLD = 8; // tiles; further than this = +1 zone
public const int DANGER_DIST_FROM_SETTLE_THRESHOLD = 16; // tiles; further than this = +1 zone
public const int DANGER_ZONE_MIN = 0;
public const int DANGER_ZONE_MAX = 4;
// ── Phase 5: Save ────────────────────────────────────────────────────
// SAVE_SCHEMA_VERSION bumped to 5 (was 4 in Phase 4)
public const string SAVE_SLOT_AUTOSAVE_COMBAT = "autosave_combat"; // separate slot for retry-last-fight
```
---
## 7. Milestones
Each is a ship point: a branch with a self-contained set of changes,
green tests, and a feature you can demonstrate.
**M1 — Stats core + content load.**
- `Rules/Stats/*` complete with tests.
- `ContentLoader.LoadClades / LoadSpecies / LoadClasses / LoadItems / LoadNpcTemplates`.
- Author all 7 clades.json, all 19 species.json, all 8 classes.json
(level tables included; only level-1 feature *defs* present), all 12
backgrounds.json, the Phase 5 items.json subset, the Phase 5
npc_templates.json set.
- `ContentValidate` Tools command green.
- **Ship point:** `dotnet test` passes new Rules/* suite. `dotnet run --
content-validate` exits 0.
**M2 — Character creation + character-as-Actor-attachment + save-anywhere.**
- `Character`, `CharacterBuilder`, `Inventory`, `ItemInstance`,
`EquipSlot`, `SizeMatch` in Core.
- `Actor.Character` field added; `PlayerActor.CaptureState` /
`RestoreState` extended.
- `CharacterCreationScreen` in Game (Myra-driven multi-step panel)
including Standard Array + 4d6-drop-lowest with `RNG_STAT_ROLL` ^
`msSinceGameStart` seeding (override path wired for tests).
- `character-roll` Tools command for headless verification, with
`--ms-override N` flag.
- Save schema bumped to v5; `V4ToV5` migration shipped (refuses Phase-4
saves with a clear message).
- **Pause-menu Save Game button** wired (save-anywhere). Cut-scene
exclusion is forward-compat (no cut scenes in Phase 5 → unconditional).
- **Ship point:** New Game → character creation → enter the world with
a real stat block. ESC → Save Game → quit → load → same character at
the same position.
**M3 — Inventory UI + equip mechanics.**
- `InventoryScreen` (TAB key) with drag-to-equip slots.
- `Equipped[*]` flows through to `DerivedStats.AC` and the future
attack profile.
- Encumbrance speed effect visible in tactical movement (slower if
overweight).
- **Ship point:** Equip a chain shirt → AC visibly changes on the
character sheet panel. Pick up rocks until encumbered → walk slower.
**M4 — Combat resolver + scripted-encounter Tools command.**
- `Encounter`, `Turn`, `Resolver`, `AttackProfile`, `DamageRoll`,
`LineOfSight`, `ReachAndCover`.
- `combat-duel` Tools command: `--a player_character.json --b
brigand_footpad --seed 42 --rounds 20` runs a scripted melee and
prints the log.
- All `Rules/Combat/*` tests green including
`DamageDeterminismTests`.
- **No game-side integration yet.** Pure Core + Tools.
- **Ship point:** combat resolver verifiable in isolation. Headless
scripted scenarios produce expected log lines.
**M5 — Combat in the live game + danger zones + interact prompt.**
- `NpcActor`, `INpcBehavior` + 3 behaviors (Brigand, WildAnimal,
PoiGuard).
- `ActorManager.SpawnNpc` called by a new `NpcInstantiationStage` that
runs when `ChunkStreamer` brings a chunk into the active window. The
stage consults the chunk's `DangerZone` to pick the actual template
from the `spawn_kind_to_template_by_zone` table — same `SpawnKind`,
different stat block per zone.
- `EncounterCheck` (hostiles auto-trigger) and `NeutralProximityCheck`
(friendly/neutral show `[F] Talk to ...` prompt) per
`PlayScreen.Update` tick.
- `CombatHUDScreen`, `CombatController`, `InitiativeStrip`,
`CombatActionBar`, `DamageNumberOverlay`.
- `InteractionScreen` shell — Phase 5 ships an empty placeholder body
("This NPC has nothing to say yet — Phase 6 attaches dialogue."), but
the input wiring is real so Phase 6 doesn't refactor.
- Mid-encounter `EncounterState` save/restore via new
`IPersistable<EncounterState>` on `Encounter`.
- Combat-start autosave to `SAVE_SLOT_AUTOSAVE_COMBAT` so a fresh
retry-this-fight slot is always available.
- `MidCombatSaveRoundTripTests` green.
- **Ship point:** walk into a brigand-spawn area in tactical mode →
encounter starts → fight to the death → world resumes. Walk up to a
merchant in town → `[F] Talk` prompt appears → press F → empty
dialogue placeholder. Save mid-fight, load, continue, identical
outcome. Walk further from the start to verify deeper-zone NPCs
(dire wolves, captain brigands) actually appear.
**M6 — Polish + first-pass class features.**
- Implement all level-1 *combat-touching* class features with real
effects:
- Fangsworn — `Fighting Style` (5 options), `Claw & Steel`.
- Bulwark — `Sentinel Stance` (bonus action stance), `Guardian's
Mark`.
- Feral — `Feral Rage` (2/encounter; uses-per-short-rest treated as
per-encounter in Phase 5), `Unarmored Defense`.
- Shadow-Pelt — `Sneak Attack` (1d6), `Scent Discipline` (passive,
no scent-system effect yet — flags only).
- Scent-Broker — `Scent Literacy` (UI surfaces NPC clade and current
HP%), `Nose for Lies` (stub: logs "lie detected" with no dialogue
system).
- Covenant-Keeper — `Mark of the Oath` (combat: marked targets give
+2 attack to allies — hookable into faction logic later).
- Muzzle-Speaker — `Voice of the Pack` (ally within 30ft gains
+1d4 to next attack; bonus action; per-encounter use).
- Claw-Wright — `Field Repair` (action: heal 1d8 + INT to ally or
self), `Adaptive Crafting` (out-of-combat: removes WrongSize
penalty for one item, 1-hour rest).
- Death-save loop (player only — d20 per turn, 3 successes ≥ 10 to
stabilise, 3 failures < 10 = permadeath).
- `DeathScreen` on permadeath: single `[Return to title]` button. No
in-world respawn. Recovery is via load (the autosave_combat slot
always has a freshly-pre-encounter checkpoint, so retry-last-fight
is one menu away).
- Loot drop on NPC death (single weighted roll into chunk's local
ground tiles; pickup via TAB inventory).
- **Ship point:** a playable Phase 5. Roll a Wolf-Folk Fangsworn,
walk into Millhaven, fight a brigand, win, loot the body, save,
level-1 features all visibly working.
---
## 8. Risks & mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Content-creation surface explodes (8 classes × 19 species × full level tables) | High | Med | Schema is forward-compatible from day one — author full level tables but only wire level-1 features. Phase 5.5 fills in higher levels without schema churn. |
| Combat resolver UX feels chunky after Phase 4's smooth tactical movement | Med | High | M4 + M5 split deliberately: prove the resolver in headless before integrating. Iterate UX in M6 with the live system in hand. |
| Mid-combat save/load determinism breaks subtly | Med | High | `MidCombatSaveRoundTripTests` is gating from M5 onward. Per-encounter SeededRng + monotonic sequence is the only way dice flow — no `Random.Shared`, no time-based seeds. |
| AI loops or stalls in chunk geometry the player rarely visits | Med | Med | Each NPC behavior has a per-turn budget (no recursion, no while-true). `INpcBehavior.TakeTurn` returns within bounded time; if no valid action, NPC ends turn. Tested via `AiBehaviorTests`. |
| Size + reach + cover interact in ways that break tactical clarity | Med | Med | Cover model is intentionally minimal in Phase 5: same-size deco between attacker and target = ½ cover (-2 attack); larger deco = ¾ cover (-5 attack). Anything fancier is Phase 6. |
| Scope of "level 1 class features" balloons because some level-1 features depend on subsystems Phase 5 doesn't have | Med | Med | M6's checklist explicitly lists which features are real-effect vs stubs. Anything that needs scent / faction / dialogue ships as a stub now and is upgraded by the phase that owns the missing system. |
| `SAVE_SCHEMA_VERSION=5` breaks all Phase-4 saves with no recovery path | Low | Low | By design — Phase-4 saves have no character. Migration prompts player to start a new game from the same seed, preserving worldgen identity. The world is reproducible from the seed; only the placeholder actor is lost. |
---
## 9. Resolved decisions
The seven open questions from the v1 draft were closed on 2026-04-24:
1. **Stat-roll method.** Standard Array default; 4d6-drop-lowest as an
opt-in `Reroll` button. Each roll is seeded by
`worldSeed ^ RNG_STAT_ROLL ^ msSinceGameStart`. Tests pass an
`ulong? msOverride` parameter to `CharacterBuilder.RollAbilityScores`
to pin the roll. → Wired in §4.2.
2. **Encounter trigger model.** Hostile NPCs auto-trigger combat on
LOS-within-`ENCOUNTER_TRIGGER_TILES`. Friendly / Neutral NPCs show
a `[F] Talk to ...` prompt within `INTERACT_PROMPT_TILES` and open
an `InteractionScreen` when F is pressed. The screen body is a
placeholder in Phase 5 (Phase 6 attaches dialogue). → Wired in §3.4.
3. **Player death.** Permadeath. The death-save loop runs (3 successes
≥ 10 to stabilise, 3 failures < 10 = dead). On death, `DeathScreen`
offers only `[Return to title]`. **Save-anywhere** is the player's
hedge: the pause menu's `Save Game` works at any moment except cut
scenes (forward-compat — no cut scenes exist in Phase 5). A
dedicated `SAVE_SLOT_AUTOSAVE_COMBAT` slot writes at every
encounter-start so retry-last-fight is one load away. → Wired in
§4.4 and §6.
4. **Difficulty scaling.** Per-chunk `DangerZone` (04) computed from
biome danger + distance from player start + distance from
roads/settlements. The chunk's `DangerZone` selects which template
each `SpawnKind` uses (table in `npc_templates.json`). NPC stats per
template stay fixed — what varies is *which template* spawns.
→ Wired in new §4.6.
5. **XP table.** D&D 5e standard table verbatim. Level 2 at 300 XP,
level 3 at 900 XP, etc. XP awarded and persisted in Phase 5; the
level-up screen ships in Phase 5.5 / 6.
6. **Loot generosity.** Each `NpcTemplateDef` references a `loot_table`
id; Phase 5 ships ~5 tables that lean stingy. Tune by playtest in M6
using a `combat-duel`-style loot-distribution dump.
7. **Natural-weapon enhancers + ability stat.** Natural-weapon attacks
default to STR. Fangsworn's *Natural Predator* fighting style
upgrades all natural-weapon attacks to "use the higher of STR or
DEX" (no new tag — handled inside the resolver when computing the
attack profile).
---
## 10. What Phase 5 does **not** finish, and why that's OK
Phase 5's exit criterion is: **a complete level-1 character can be created,
walks the world, fights and survives (or dies and loads), and the system
is ready to layer leveling, social/scent abilities, factions, and
quests on top without re-architecting any of it.**
Things deliberately deferred:
- **Levelling beyond level 1.** Phase 5.5: character sheet level-up
flow, hit die selection, ability score improvement, subclass at
level 3.
- **Subclass features (level 3+).** Same time as level-up.
- **Pheromone craft / scent abilities / Vocalization / Covenant
authority resolved.** Phase 6 builds the social/scent layer on top
of the level-1 stubs Phase 5 lays down.
- **Faction & reputation tracks driving NPC behavior.** Phase 6.
- **Friendly NPC dialogue.** Phase 6 — the merchant/patrol spawns
Phase 5 ignores become Phase 6's first dialogue subjects.
- **Quest engine + Act I content.** Phase 6.
- **PoI dungeon interiors.** Phase 7.
- **Long/short rest mechanics tied to the world clock.** Phase 8.
- **NPC schedules / day-night activity.** Phase 8.
- **AoE templates (cone, sphere, line).** No level-1 feature in any
of the 8 classes uses AoE; defer to whatever level introduces the
first one (mostly subclass features, so naturally Phase 5.5+).
- **Concentration / spell-like effect tracking.** Same — no level-1
feature concentrates. Defer with subclass implementation.
- **Non-melee/ranged combat:** Phase 5 ships melee + simple ranged
(single-target, range-band check, line-of-sight). Anything more
exotic (multi-target rays, area suppression, etc.) waits.
The payoff: Phase 6 starts on a foundation where character + combat are
real and tested, so the social/quest layer can focus on the social/quest
problem instead of co-developing combat at the same time.
---
*Theriapolis Phase 5 Implementation Plan — 2026-04-24*
*Author: Claude (Opus 4.7) for LO, in continuity with the Phase 04 plan series.*