Files
TheriapolisV3/theriapolis-rpg-implementation-plan-phase5.md
T
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

50 KiB
Raw Blame History

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 Add Character? ref + IsHostileTo(Actor) helper
PlayerActor PlayerActor.cs Extend CaptureState/RestoreState to include Character
ActorManager ActorManager.cs Add SpawnNpc, RemoveActor, Tick(dt)
TacticalChunk.Spawns TacticalChunk.cs:20 Source for NpcSpawnerStage to instantiate live NPCs
SpawnKind enum TacticalChunk.cs:69 Maps to NpcTemplateDef.Id (e.g. Brigandnpc.brigand_footpad)
Reserved save fields SaveBody.cs:23-30 Phase 5 leaves these empty (still Phase 6)
IPersistable<T> IPersistable.cs New persistables: EncounterState, NpcRosterState
ContentLoader ContentLoader.cs Add LoadClades, LoadSpecies, LoadClasses, LoadItems, LoadNpcTemplates
SeededRng SeededRng.cs New sub-streams: RNG_CHARACTER, RNG_COMBAT, RNG_NPC_SPAWN, RNG_LOOT
PlayScreen PlayScreen.cs Push/pop CombatHUDScreen when an encounter starts; route input to CombatController
Tactical step counter PlayerController.cs:103-104 Repurpose: distance-walked drives both clock and encounter trigger checks
WorldClock WorldClock.cs Combat suspends world-clock advancement; resumes after
Constants 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:

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 + nextRollSequenceEncounter.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:

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

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

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:

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:

{
  "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:

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

// 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

// ── 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.