1017 lines
50 KiB
Markdown
1017 lines
50 KiB
Markdown
|
|
# 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 1–30; 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` (0–4) 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 0–4 plan series.*
|