b451f83174
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>
1199 lines
74 KiB
Markdown
1199 lines
74 KiB
Markdown
# Theriapolis — Phase 6 — Design & Implementation Plan
|
||
## Settlements, Residents, Dialogue, Factions, Reputation, and Act I
|
||
|
||
**Status:** Proposed. Targets the codebase state as of 2026-04-26
|
||
(Phase 5 complete; 256×256 world; `ENABLE_RAIL=false`;
|
||
SAVE_SCHEMA_VERSION=5; 349 tests green).
|
||
|
||
**Governing docs:**
|
||
- `theriapolis-rpg-implementation-plan.md` §§ 8.4, 8.5, 11 (binding)
|
||
- `theriapolis-rpg-reputation.md` (content authority for reputation tracks + bias profiles)
|
||
- `theriapolis-rpg-questline.md` (content authority for Act I)
|
||
- `theriapolis-rpg-procgen.md` Layer 4–5 (faction influence, settlement attributes)
|
||
- `theriapolis-rpg-implementation-plan-phase4.md` §3.4 step 3 (slipped settlement burn-in)
|
||
- `theriapolis-rpg-implementation-plan-phase5.md` (everything Phase 6 plugs into)
|
||
|
||
---
|
||
|
||
## 1. Goals & non-goals
|
||
|
||
### Goals
|
||
|
||
1. **Settlements you can walk into.** The cobble-plaza-and-wall-ring placeholder
|
||
from Phase 4 is replaced with **buildings** stamped from per-tier layout
|
||
templates. Buildings have walls, doors, floor interiors, and addressable
|
||
roles ("inn", "shop", "smithy", "magistrate"). Tier-1 capitals get a
|
||
hand-authored preset layout per the Phase 4 plan §3.4 step 3 promise that
|
||
slipped; Tier 2–5 get procedurally-arranged building footprints from a
|
||
biome-aware template library. This catches up the Phase-4 deliverable that
|
||
never landed.
|
||
2. **Residents who exist.** Friendly/neutral spawn records that Phase 5 already
|
||
loads (`SpawnKind.Merchant`, `Patrol`) — currently treated as **static
|
||
markers** — become live `NpcActor`s with a `Character`, a role, an
|
||
allegiance, a bias profile, and a dialogue tree. Walk into the inn at
|
||
Millhaven; the innkeeper is there with a name, a stat block, and lines
|
||
that respond to who you are.
|
||
3. **Dialogue that matters.** The `InteractionScreen` shell from Phase 5 M5
|
||
gets a real body: a Myra-driven branching dialogue UI, content authored as
|
||
JSON dialogue trees, and a runtime that evaluates conditions
|
||
(reputation, faction, clade bias, quest flags) to gate options.
|
||
4. **A quest engine.** Flag/condition graph per master plan §8.4. Quest scripts
|
||
reference NPCs and locations by **anchor id + role tag** — never by world
|
||
coordinates. The same quest plays on every seed because Millhaven is
|
||
`anchor:millhaven` regardless of where it generated. Act I ("Still
|
||
Earth") ships end-to-end as the proof.
|
||
5. **Reputation across three layers.** Clade Bias (pre-meeting prejudice),
|
||
Faction Standing (-100..+100 per faction with cross-faction propagation),
|
||
Individual Disposition (per-NPC personal record). Effective Disposition
|
||
blends them and decides whether dialogue options are available, prices
|
||
are inflated, or guards attack on sight. Information propagates by
|
||
distance + time + courier presence per `reputation.md` §I-2.
|
||
6. **Factions that act.** The three faction defs from Phase 4
|
||
([factions.json](Content/Data/factions.json)) gain runtime influence: the
|
||
`FactionInfluenceGen` worldgen layer (Stage 18 — already running) is
|
||
*consulted* by NPC instantiation so a chunk in Inheritor-strong territory
|
||
tints its merchants/patrols toward Inheritor-aligned bias profiles, and
|
||
guard behaviours respond to faction standing.
|
||
7. **Determinism preserved.** Same (worldSeed, encounterId, dialogueTurn) →
|
||
identical reputation deltas. Save mid-dialogue, load, continue —
|
||
byte-identical to the live session. Same as Phase 5's combat contract.
|
||
8. **Phase 5 invariants intact.** Polylines authoritative. Core stays
|
||
MonoGame-free. All RNG via `SeededRng` with new named sub-streams declared
|
||
in `Constants.cs`.
|
||
|
||
### Non-goals (explicit)
|
||
|
||
- **Levelling beyond level 1.** Phase 5 plan §10 already designates this as
|
||
Phase 5.5. Phase 6 ships as a parallel workstream that *can* be sequenced
|
||
before, after, or alongside; it is not a Phase 6 deliverable. Act I
|
||
content is authored against level 1–4, but Phase 6 wraps Act I gates at
|
||
level 1 if Phase 5.5 hasn't shipped — see §10.
|
||
- **Subclass features (level 3+).** Same. Phase 5.5.
|
||
- **Hybrid characters.** Optional rules from `clades.md`. Defer to Phase 6.5
|
||
or Phase 7 — touches the dialogue layer (passing detection) but adds a
|
||
schema branch on `Character` we don't want to bake in until the rest of
|
||
this phase has been playtested.
|
||
- **Pheromone Craft, Covenant Authority resolved.** Phase 5 ships level-1
|
||
*stubs* for Scent-Broker / Covenant-Keeper / Muzzle-Speaker / Claw-Wright.
|
||
Phase 6 wires the **dialogue-side hooks** (Scent-Broker reveals NPC bias
|
||
profile + recent factions; Muzzle-Speaker `Voice of the Pack` reads ally
|
||
morale; Covenant-Keeper marks targets), but the social/scent state model
|
||
itself (a per-NPC scent profile that propagates and decays) is **Phase
|
||
6.5 / 7** scope. Phase 6 treats scent as a derivable cache off the existing
|
||
reputation state, not a separate simulation.
|
||
- **PoI interiors / dungeons / Imperium Ruin.** Phase 7. Phase 6 settlements
|
||
have interiors but no procedural multi-room dungeons; the "minor dungeon"
|
||
in the *Old Howl* side quest (`questline.md` Act I) is a **single-room
|
||
combat encounter** with hand-authored loot, not a procedurally-generated
|
||
dungeon. The Phase 7 dungeon engine subsumes it later.
|
||
- **NPC schedules / day-night activity.** Phase 8. Residents stand in their
|
||
building during the day and during the night — no patrol routes, no
|
||
closing hours. Some quest steps (e.g. *Lacroix breaks in at night*) gate
|
||
on the world clock, but residents themselves are positionally static.
|
||
- **Long/short rest mechanics tied to the world clock.** Phase 8.
|
||
- **Acts II–V.** Phase 10. Act I is the proof; the engine is generic.
|
||
- **Trade economy as simulation.** `TradeRouteGen` (Stage 17) already runs
|
||
but its output is currently consumed only by encounter density. Phase 6
|
||
uses trade-route data to *colour* merchant inventories (a settlement on a
|
||
busy route stocks more variety) but does not simulate price drift,
|
||
caravan movement, or supply shocks. Those are Phase 8.
|
||
- **Dialogue voice acting / portraits.** Text-only.
|
||
- **Faction quest lines distinct from the main quest.** Side quests for
|
||
Inheritors / Thorn Council / Enforcers / Hybrid Underground / Unsheathed
|
||
/ Merchant Guilds are reserved for Phase 10. Phase 6 lets you
|
||
*accumulate* standing with all of them through Act I main and side
|
||
content; no faction-specific quest lines yet.
|
||
|
||
---
|
||
|
||
## 2. Current-state inventory (what we plug into)
|
||
|
||
Audited 2026-04-26:
|
||
|
||
| Piece | Where | Phase 6 use |
|
||
|---|---|---|
|
||
| `Settlement` (footprint + tier + name) | [WorldState.cs](Theriapolis.Core/World/WorldState.cs) | Source for which buildings to stamp at which tier |
|
||
| `TacticalChunkGen.Pass3_Settlements` | [TacticalChunkGen.cs:260](Theriapolis.Core/Tactical/TacticalChunkGen.cs:260) | **Replaced** with template-driven stamper. Currently produces only cobble plaza + outer wall ring; no buildings. |
|
||
| `TacticalSurface.Floor` / `Wall` | [TacticalTile.cs:24-25](Theriapolis.Core/Tactical/TacticalTile.cs) | Already in enum; `Floor` currently unused. Phase 6 stamps `Floor` for building interiors. |
|
||
| `TacticalDeco` enum | [TacticalTile.cs:31](Theriapolis.Core/Tactical/TacticalTile.cs) | Add `Door`, `Counter`, `Bed`, `Hearth`, `Sign` |
|
||
| `TacticalFlags.Settlement` | [TacticalTile.cs](Theriapolis.Core/Tactical/TacticalTile.cs) | Plus new `Building`, `Doorway`, `Interior` flags |
|
||
| `SpawnKind.Merchant` / `Patrol` | [TacticalChunk.cs:79](Theriapolis.Core/Tactical/TacticalChunk.cs:79) | Already produced by chunk gen and ignored by Phase 5. Phase 6 instantiates them as `NpcActor`s with role + dialogue tree id. |
|
||
| `NpcInstantiator` | [NpcInstantiator.cs](Theriapolis.Core/Rules/Combat/NpcInstantiator.cs) | Extend: friendly residents are placed *inside* their assigned building, not on the chunk's spawn-point coordinate |
|
||
| `InteractionScreen` (placeholder) | [InteractionScreen.cs](Theriapolis.Game/Screens/InteractionScreen.cs) | **Body filled in.** Becomes a Myra-driven branching dialogue panel reading from `dialogue.json`. |
|
||
| `EncounterTrigger.FindInteractCandidate` | [EncounterTrigger.cs:51](Theriapolis.Core/Rules/Combat/EncounterTrigger.cs) | Already locates closest friendly NPC within `INTERACT_PROMPT_TILES`. Phase 6 reuses unchanged. |
|
||
| `Allegiance` enum | [Allegiance.cs](Theriapolis.Core/Rules/Character/Allegiance.cs) | Existing Player/Allied/Neutral/Friendly/Hostile remain; Phase 6 derives **per-NPC effective allegiance** from reputation at runtime |
|
||
| Reserved save fields | [SaveBody.cs:46-50](Theriapolis.Core/Persistence/SaveBody.cs) | `Flags` (quest flags), `Factions` (faction standing), `QuestState` (quest progress), `Reputation` (per-NPC personal disposition), `DiscoveredPoiIds` — all empty containers waiting for Phase 6 |
|
||
| `SeededRng` | [SeededRng.cs](Theriapolis.Core/Util/SeededRng.cs) | New sub-streams: `RNG_DIALOGUE`, `RNG_QUEST`, `RNG_RESIDENT_GEN`, `RNG_BUILDING_LAYOUT`, `RNG_REP_PROPAGATION` |
|
||
| `ContentLoader` / `ContentResolver` | [ContentLoader.cs](Theriapolis.Core/Data/ContentLoader.cs) | Add `LoadBuildingTemplates`, `LoadSettlementLayouts`, `LoadDialogues`, `LoadQuests`, `LoadBiasProfiles`, `LoadResidentTemplates` |
|
||
| `factions.json` | [factions.json](Content/Data/factions.json) | 3 factions defined (Enforcers, Inheritors, Thorn). Phase 6 adds: Maw (hidden), Hybrid Underground, Unsheathed, Merchant Guilds — 7 total per `reputation.md` §I-2. |
|
||
| `FactionInfluenceGen` (Stage 18) | (worldgen) | Output already exists. Phase 6 *reads* it during resident instantiation to bias profile selection. |
|
||
| `WorldClock` | [WorldClock.cs](Theriapolis.Core/Time/WorldClock.cs) | Quest steps with time gates use this; Phase 6 does not introduce diurnal state. |
|
||
| `PlayScreen` interaction routing | [PlayScreen.cs:595-601](Theriapolis.Game/Screens/PlayScreen.cs:595) | Already pushes `InteractionScreen` on F-press. Phase 6 inflates `InteractionScreen` rather than replacing the routing. |
|
||
| `Constants.cs` | [Constants.cs](Theriapolis.Core/Constants.cs) | All Phase 6 numbers (rep propagation distances/decay, building min-size, dialogue-line cap, etc.) |
|
||
| `Theriapolis.Tools` | (project) | New commands: `dialogue-validate`, `quest-validate`, `rep-dump`, `settlement-render` (PNG export of stamped settlement) |
|
||
|
||
Three facts that materially shape Phase 6:
|
||
|
||
- **Buildings are tactical-tile stamps, not a separate scene.** Continuing
|
||
the Phase 4 contract: a chunk is a flat tactical-tile array. "Inside the
|
||
inn" is the same coordinate space as "outside the inn"; a wall is a
|
||
`TacticalSurface.Wall`, a doorway is a `TacticalDeco.Door` on a `Floor`
|
||
tile. Camera doesn't switch modes when the player crosses a doorway.
|
||
This avoids an "interior scene" subsystem until Phase 7 needs one for
|
||
dungeons.
|
||
- **`SaveBody` already reserves the right containers.** No save-schema
|
||
bump *should* be required if Phase 6 fits inside `Flags` /
|
||
`Factions` / `QuestState` / `Reputation` / `DiscoveredPoiIds`. We bump
|
||
to v6 anyway because (a) building-stamp persistence (a player-broken
|
||
door, a vandalised sign) needs a new delta type, and (b) per-NPC personal
|
||
disposition records exceed `Dictionary<string, int>` shape.
|
||
- **The reputation design doc is *much* larger than Phase 6 wants to
|
||
ship.** `reputation.md` runs ~2000 lines including bias profiles, scent
|
||
detection, propagation channels, and betrayal events. Phase 6 ships the
|
||
**load-bearing core** (3-layer score, propagation by distance/time, basic
|
||
bias-profile lookup, dialogue gating), and defers the elaborated subsystems
|
||
(scent-based faction detection, betrayal events with cascading
|
||
consequences, permanent memory tags) to Phase 6.5 / 7.
|
||
|
||
---
|
||
|
||
## 3. Phase 6 architecture
|
||
|
||
### 3.1 Module layout
|
||
|
||
```
|
||
Theriapolis.Core/
|
||
World/
|
||
Settlements/
|
||
BuildingTemplate.cs record — JSON-loaded; size, role, biome filters, deco placements
|
||
SettlementLayout.cs record — JSON-loaded; per-tier preset (capital) or rule-based (procedural) layout
|
||
SettlementStamper.cs static — replaces Pass3_Settlements; reads layout, stamps walls/floors/doors/decos
|
||
AnchorRegistry.cs class — runtime map of "anchor:millhaven" → SettlementId, "role:millhaven.innkeeper" → NpcId
|
||
ResidentInstantiator.cs class — given a stamped settlement + chunk, emits resident NpcActors at their roles
|
||
Rules/
|
||
Reputation/
|
||
EffectiveDisposition.cs static — combines Clade Bias + Faction Standing + Individual Disposition → final score
|
||
BiasProfile.cs record — JSON-loaded; modifiers per (NPC profile id × PC clade × PC species × size diff)
|
||
FactionStanding.cs class — per-faction integer with opposition matrix application
|
||
PersonalDisposition.cs class — per-NPC record with interaction log + trust level
|
||
RepPropagation.cs static — distance + decay model from reputation.md §I-2
|
||
RepEvent.cs record — typed event (DIALOGUE, QUEST, COMBAT, RESCUE, BETRAYAL, GIFT, TRADE)
|
||
RepLedger.cs class — append-only log of RepEvents; UI reads this for "why does so-and-so hate me"
|
||
Quests/
|
||
QuestDef.cs record — JSON: id, title, hidden, steps[], failureConditions[]
|
||
QuestStep.cs record — JSON: id, triggerConditions[], onEnter[], onComplete[], outcomes[]
|
||
QuestCondition.cs record — JSON: kind (flag/location/npc-state/time/rep), value
|
||
QuestEffect.cs record — JSON: kind (set-flag/give-item/rep-event/spawn-npc/start-quest/end-quest)
|
||
QuestEngine.cs class — evaluates conditions per tick, runs effects, manages active steps
|
||
QuestLog.cs class — player-visible journal; saved per-step state
|
||
Dialogue/
|
||
DialogueDef.cs record — JSON: id, root node id, nodes[]
|
||
DialogueNode.cs record — JSON: id, speaker, text, options[], onEnter[]
|
||
DialogueOption.cs record — JSON: text, next, conditions[], effects[]
|
||
DialogueRunner.cs class — walks a tree, evaluates option conditions, applies effects
|
||
DialogueContext.cs class — read/write window into player + npc state for condition/effect evaluation
|
||
Entities/
|
||
NpcActor.cs EXTEND — add `string? RoleTag`, `string? DialogueId`, `string? BiasProfileId`
|
||
Ai/
|
||
ResidentBehavior.cs — stand still during day; flee combat to nearest interior tile of own building
|
||
MerchantBehavior.cs — Resident + opens shop dialogue branch when interacted with
|
||
PatrolBehavior.cs — Resident + reacts to NEMESIS/HOSTILE faction standing in LoS by aggroing
|
||
Data/
|
||
BuildingTemplateDef.cs record — JSON-loaded
|
||
SettlementLayoutDef.cs record — JSON-loaded
|
||
DialogueDef.cs record — JSON-loaded
|
||
QuestDef.cs record — JSON-loaded
|
||
BiasProfileDef.cs record — JSON-loaded
|
||
ResidentTemplateDef.cs record — JSON-loaded
|
||
ContentLoader.cs EXTEND — add the five Load* methods
|
||
ContentResolver.cs EXTEND — anchor + role lookup helpers
|
||
Persistence/
|
||
SaveBody.cs EXTEND — bump to v6; promote `Flags` / `Factions` / `QuestState` / `Reputation`
|
||
containers from placeholder shape to typed structures
|
||
QuestStateSnapshot.cs class — per-quest active step + per-step flags
|
||
ReputationSnapshot.cs class — faction standing + per-npc personal records + ledger tail
|
||
AnchorRegistrySnapshot.cs class — anchor:* → SettlementId mapping (rebuilt on load if missing)
|
||
BuildingDelta.cs struct — chunk-local per-building damage/state (door broken, fire-damaged, etc.)
|
||
|
||
Theriapolis.Game/
|
||
Screens/
|
||
InteractionScreen.cs REPLACE placeholder body with branching dialogue UI
|
||
QuestLogScreen.cs NEW — J key, modal: active + completed quests, step descriptions, optional waypoint hints
|
||
ReputationScreen.cs NEW — modal: faction standings + recently-encountered NPC list with disposition
|
||
UI/
|
||
DialoguePanel.cs — speaker avatar (placeholder), text, options list, history scrollback
|
||
QuestJournalPanel.cs — left-rail quest list, right-pane step detail
|
||
ReputationStripPanel.cs — horizontal bar per faction with NEMESIS..CHAMPION colour gradient
|
||
NpcDispositionTooltip.cs — hover an NPC in tactical view → show effective disposition + reasons
|
||
Input/
|
||
PlayerController.cs EXTEND — J = quest log, R = reputation screen (R was free in Phase 5)
|
||
|
||
Theriapolis.Tools/Commands/
|
||
DialogueValidate.cs — every dialogue tree's nodes/options reachable; no broken refs
|
||
QuestValidate.cs — every quest's steps form a DAG; every condition refers to a real flag/anchor/role
|
||
RepDump.cs — given a (worldSeed, playerHistory.json), print effective disposition for each named NPC
|
||
SettlementRender.cs — given a (worldSeed, settlementId), export a PNG of the stamped settlement
|
||
|
||
Theriapolis.Tests/
|
||
Settlements/
|
||
BuildingStampTests.cs — Tier-1 capital stamps preset; Tier 2–4 stamp procedural; both deterministic per seed
|
||
SettlementCoverageTests.cs — every Tier 1–3 settlement contains: ≥1 inn, ≥1 shop, magistrate (Tier 1–2 only)
|
||
AnchorResolutionTests.cs — "anchor:millhaven" / "role:millhaven.innkeeper" resolve to live entities post-load
|
||
Reputation/
|
||
EffectiveDispositionTests.cs
|
||
FactionOppositionTests.cs — gain Inheritor +10 → propagation matrix yields expected losses
|
||
PropagationDecayTests.cs — within-settlement immediate; adjacent 80%; continental 40%
|
||
PersonalDispositionTests.cs
|
||
Dialogue/
|
||
DialogueRunnerTests.cs
|
||
OptionConditionTests.cs — option visibility gates fire correctly
|
||
Quests/
|
||
QuestEngineTests.cs — scripted Act I prologue runs through to first beat
|
||
QuestConditionTests.cs
|
||
ActIIntegrationTests.cs — full Act I deterministic walkthrough at fixed seed
|
||
Persistence/
|
||
QuestStateRoundTripTests.cs
|
||
ReputationRoundTripTests.cs
|
||
BuildingDeltaRoundTripTests.cs
|
||
V5ToV6MigrationTests.cs
|
||
|
||
Content/Data/
|
||
building_templates/
|
||
inn_small.json
|
||
inn_medium.json
|
||
shop_general.json
|
||
shop_smithy.json
|
||
shop_alchemist.json
|
||
house_small.json
|
||
house_medium.json
|
||
magistrate.json
|
||
granary.json
|
||
well.json
|
||
rail_station.json (used only when ENABLE_RAIL=true; ships dormant)
|
||
settlement_layouts/
|
||
millhaven.json — Tier-1 capital, hand-authored preset (Act I anchor)
|
||
thornfield.json — Tier-1 industrial city, hand-authored preset (Act I+II anchor)
|
||
procedural_tier2.json — rule-based: city with distinct districts
|
||
procedural_tier3.json — rule-based: town with central plaza
|
||
procedural_tier4.json — rule-based: village clustered along road
|
||
procedural_tier5.json — rule-based: hamlet (3-5 buildings)
|
||
dialogues/
|
||
millhaven_innkeeper.json
|
||
millhaven_constable.json (Aldous Fenn — the local lawman, plot-relevant)
|
||
millhaven_grandmother_asha.json
|
||
millhaven_magistrate.json
|
||
millhaven_lacroix.json (Act I antagonist arrival)
|
||
thornfield_dr_venn.json
|
||
generic_merchant.json
|
||
generic_villager.json
|
||
... (~30 dialogue trees total for Act I)
|
||
quests/
|
||
main_act_i_001_arrival.json
|
||
main_act_i_002_briarstead.json
|
||
main_act_i_003_following_dead.json
|
||
side_act_i_fence_lines.json
|
||
side_act_i_shedding_season.json
|
||
side_act_i_cellar_problem.json
|
||
side_act_i_old_howl.json
|
||
factions.json EXTEND — add Maw (hidden), Hybrid Underground, Unsheathed, Merchant Guilds
|
||
bias_profiles.json — 12 profiles per reputation.md §I-1 (CANID_TRADITIONALIST, CERVID_CAUTIOUS,
|
||
URBAN_PROGRESSIVE, HYBRID_SURVIVOR, MUSTELID_PRAGMATIST, BOVID_HERD_LOYALIST,
|
||
COVENANT_FAITHFUL, FRONTIER_NIHILIST, TANGLES_RESIDENT, INHERITOR_TRUE_BELIEVER,
|
||
THORN_COUNCIL_HARDLINER, MERCHANT_NEUTRAL)
|
||
resident_templates.json — generic shopkeeper, innkeeper, guard, constable, farmer, etc., to flesh out
|
||
procedurally-generated Tier 2–5 settlements
|
||
```
|
||
|
||
### 3.2 Settlements as in-tile interiors (no scene swap)
|
||
|
||
The Phase 4 plan promised template stamping but only the cobble-plaza-and-wall-
|
||
ring placeholder shipped. Phase 6 catches this up:
|
||
|
||
```
|
||
TacticalChunkGen.Pass3_Settlements
|
||
→ SettlementStamper.Stamp(chunk, world, content)
|
||
→ resolve SettlementLayout for this settlement (preset for Tier 1, procedural for 2–5)
|
||
→ for each BuildingPlacement in the layout:
|
||
→ resolve BuildingTemplate
|
||
→ if footprint intersects this chunk:
|
||
stamp Floor surface, Wall surface for perimeter, Door deco at entrance
|
||
emit a BuildingFootprint record on the chunk for Phase-6 lookup
|
||
emit a TacticalSpawn(SpawnKind.Resident, role, dialogueId) for each occupant
|
||
→ outer wall ring (if Tier 1–3) — already present, kept
|
||
```
|
||
|
||
Player walks across a `Door` deco → tile is walkable, no scene transition.
|
||
Inside is just `Floor` surface bounded by `Wall`s. Camera unchanged. Buildings
|
||
have a per-chunk **`BuildingFootprint`** record storing AABB + role tag + door
|
||
positions; the dialogue runtime uses this to answer "is the player still in
|
||
the inn?" for proximity-gated quest steps.
|
||
|
||
This is intentionally simpler than a "switch to interior scene" model. The
|
||
trade-off: very large buildings (Thornfield's pharmaceutical company) sprawl
|
||
across multiple chunks. The chunk-streamer already handles cross-chunk
|
||
features (polylines do this for rivers and roads); we add `BuildingFootprint`
|
||
to the same machinery.
|
||
|
||
### 3.3 The four-pass dialogue/quest evaluation order
|
||
|
||
Per tick (after Phase 5's `EncounterCheck` and `NeutralProximityCheck`):
|
||
|
||
```
|
||
1. World clock tick → WorldClock advances 1 game-minute per real second of travel
|
||
2. Quest engine tick:
|
||
for each ACTIVE quest step:
|
||
evaluate triggerConditions
|
||
if fired → run onEnter effects, advance currentStep
|
||
3. Reputation propagation tick (every game-day):
|
||
for each unpropagated RepEvent in ledger tail:
|
||
compute distance/decay, write standing deltas to listening NPCs
|
||
4. NPC AI tick (existing):
|
||
residents stand; merchants face player; patrols may aggro on faction-hostile
|
||
|
||
When InteractionScreen is on top:
|
||
DialogueRunner drives the active node:
|
||
player picks option → applies effects → advances to next node
|
||
effects may: set quest flag, give/take item, write RepEvent, start quest, give XP
|
||
on close → return to PlayScreen
|
||
any effect that mutates a save-relevant field marks the rolling autosave dirty
|
||
```
|
||
|
||
### 3.4 The reputation contract
|
||
|
||
Effective Disposition formula (per `reputation.md` §I-4):
|
||
|
||
```
|
||
EffectiveDisposition(npc, pc) =
|
||
CladeBiasFor(npc.BiasProfile, pc.Clade, pc.Species, sizeDiff(npc, pc))
|
||
+ FactionWeightedSum(npc.FactionAffiliation, pc.FactionStandings)
|
||
+ PersonalDisposition(npc.Id)
|
||
+ RecencyBonus(npc.LastInteractionAge)
|
||
```
|
||
|
||
Clamped to -100..+100. Dialogue option conditions can require thresholds
|
||
("disposition ≥ FAVORABLE"), individual flags ("knows pc is hybrid"), or
|
||
quest state ("act_i_briarstead_visited == true").
|
||
|
||
The propagation channel from `reputation.md` §I-2 is implemented as:
|
||
|
||
- `RepLedger.Append(event)` writes the event with origin coordinates + timestamp.
|
||
- `RepPropagation.Tick()` runs every in-game day. For each unpropagated event,
|
||
it walks settlements within a continent-scale radius and applies a decay
|
||
multiplier based on Chebyshev distance + biome traversability + faction
|
||
presence at destination.
|
||
- NEMESIS / CHAMPION events propagate at full magnitude continent-wide.
|
||
- Frontier settlements (low road connectivity) may simply never receive an
|
||
event, modelled as a coin-flip per propagation step — non-deterministic
|
||
per-NPC perception, but deterministic per (`worldSeed`, `eventId`,
|
||
`targetSettlementId`).
|
||
|
||
### 3.5 The dice contract (extended)
|
||
|
||
Phase 5 introduced encounter-seeded RNG. Phase 6 adds:
|
||
|
||
```
|
||
dialogueSeed = worldSeed ^ C.RNG_DIALOGUE ^ npcId ^ dialogueTurnIndex
|
||
questRollSeed = worldSeed ^ C.RNG_QUEST ^ questId ^ stepIndex
|
||
residentSeed = worldSeed ^ C.RNG_RESIDENT_GEN ^ settlementId ^ residentSlotIndex
|
||
buildingSeed = worldSeed ^ C.RNG_BUILDING_LAYOUT ^ settlementId ^ buildingSlotIndex
|
||
repPropSeed = worldSeed ^ C.RNG_REP_PROPAGATION ^ eventId ^ targetSettlementId
|
||
```
|
||
|
||
Same pattern as Phase 5: split per subsystem so two players at the same seed
|
||
making different dialogue choices both stay deterministic in their own
|
||
playthroughs but diverge after the first chosen option.
|
||
|
||
New constants:
|
||
|
||
```csharp
|
||
public const ulong RNG_DIALOGUE = 0xD1A106EUL;
|
||
public const ulong RNG_QUEST = 0x9E57E0UL;
|
||
public const ulong RNG_RESIDENT_GEN = 0x1E51DEU;
|
||
public const ulong RNG_BUILDING_LAYOUT = 0xB1D106UL;
|
||
public const ulong RNG_REP_PROPAGATION = 0x1EFA77UL;
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Subsystem detail
|
||
|
||
### 4.1 Settlement building stamp + interior chunks (catch-up)
|
||
|
||
`BuildingTemplateDef` JSON shape:
|
||
|
||
```jsonc
|
||
{
|
||
"id": "inn_small",
|
||
"name": "Small Inn",
|
||
"footprint_w_tiles": 8, // tactical tiles (= world pixels)
|
||
"footprint_h_tiles": 6,
|
||
"min_tier_eligible": 4, // can appear in Tier 4+ settlements
|
||
"roles": [
|
||
{ "tag": "innkeeper", "spawn_at": [4, 3] },
|
||
{ "tag": "barfly", "spawn_at": [6, 4], "optional": true }
|
||
],
|
||
"doors": [{ "x": 4, "y": 5, "facing": "S" }],
|
||
"decos": [
|
||
{ "x": 1, "y": 1, "deco": "hearth" },
|
||
{ "x": 6, "y": 1, "deco": "counter" },
|
||
{ "x": 2, "y": 4, "deco": "bed" },
|
||
{ "x": 3, "y": 4, "deco": "bed" }
|
||
],
|
||
"biome_filter": ["temperate_grassland", "temperate_deciduous", "boreal", "coastal"]
|
||
}
|
||
```
|
||
|
||
`SettlementLayoutDef` for hand-authored capitals (Millhaven, Thornfield):
|
||
|
||
```jsonc
|
||
{
|
||
"id": "millhaven",
|
||
"anchor_id": "anchor:millhaven",
|
||
"tier": 1,
|
||
"buildings": [
|
||
{ "template": "magistrate", "offset": [-30, -30], "role_overrides": { "magistrate": "millhaven.magistrate" } },
|
||
{ "template": "inn_medium", "offset": [-10, 0], "role_overrides": { "innkeeper": "millhaven.innkeeper" } },
|
||
{ "template": "shop_general", "offset": [ 10, 0], "role_overrides": { "shopkeeper": "millhaven.general_store" } },
|
||
{ "template": "house_medium", "offset": [ 25, 10], "role_overrides": { "resident": "millhaven.grandmother_asha" } },
|
||
/* ...total ~12 buildings for the capital */
|
||
]
|
||
}
|
||
```
|
||
|
||
Procedural layouts (Tier 2–5) are *rule-based* — they declare:
|
||
|
||
- A target building count by role weight (`{"shop": 0.3, "house": 0.6, "civic": 0.1}`)
|
||
- Spacing constraints (`min_distance_between_doors_tiles: 6`)
|
||
- Road-snap rules (buildings face the nearest road within N tiles of the
|
||
settlement centre)
|
||
|
||
The procedural generator runs in `SettlementStamper`, seeded by
|
||
`buildingSeed`. It produces the same building set for the same seed every
|
||
time. Tests in `BuildingStampTests` assert byte-identical chunk hashes
|
||
across two runs.
|
||
|
||
### 4.2 Residents
|
||
|
||
Resident generation runs at chunk-instantiation time, alongside the existing
|
||
Phase 5 NPC instantiator. The flow:
|
||
|
||
```
|
||
For each TacticalSpawn(SpawnKind.Resident, role, dialogueId) in chunk.Spawns:
|
||
→ if role has a role_override (named NPC, e.g. millhaven.innkeeper):
|
||
resolve a hand-authored ResidentTemplateDef
|
||
→ else:
|
||
generate from a generic ResidentTemplateDef (`shopkeeper`, `villager`, etc.)
|
||
roll species/clade compatible with the settlement's biome and faction influence
|
||
→ spawn NpcActor with:
|
||
Position = the building's role spawn point (tactical tile)
|
||
RoleTag, DialogueId, BiasProfileId set
|
||
Allegiance = Friendly (modified by faction logic at runtime)
|
||
→ register in AnchorRegistry under "role:millhaven.innkeeper"
|
||
```
|
||
|
||
Hand-authored named NPCs are persisted (their personal disposition record
|
||
ships with the save). Generic-rolled residents are not — they re-roll
|
||
identically on load from the same seed. If the player kills a generic
|
||
shopkeeper, the kill is persisted in `NpcRosterState` (Phase 5 mechanism)
|
||
and the slot stays empty.
|
||
|
||
Bias profile assignment for procedural residents:
|
||
|
||
```
|
||
profile = pickWeighted({
|
||
fromClade(resident.Clade), // weight 0.5
|
||
fromFaction(chunkFactionInfluence), // weight 0.3
|
||
fromBackground(settlementTier), // weight 0.2 — Tier 1 = URBAN_PROGRESSIVE-leaning
|
||
}, residentSeed)
|
||
```
|
||
|
||
Hand-authored named NPCs override this with an explicit profile id in their
|
||
template.
|
||
|
||
### 4.3 Dialogue engine
|
||
|
||
A dialogue tree is a directed graph of nodes. Each node has:
|
||
|
||
- `id` — unique within the tree
|
||
- `speaker` — `npc` or `pc` or `narration`
|
||
- `text` — display text, supports `{pc.name}`, `{npc.role}`,
|
||
`{disposition_label}` placeholders
|
||
- `options[]` — list of `DialogueOption`s
|
||
|
||
Each option has:
|
||
|
||
- `text` — what the PC says
|
||
- `next` — node id to jump to (or `<end>`)
|
||
- `conditions[]` — list of `DialogueCondition`s; option is hidden if any
|
||
fail. Examples: `{ kind: "rep_at_least", faction: "enforcers", value: 25 }`,
|
||
`{ kind: "has_item", id: "millhaven_journal" }`,
|
||
`{ kind: "skill_check", skill: "persuasion", dc: 12 }`.
|
||
- `effects[]` — applied on selection. Examples: `{ kind: "set_flag", id:
|
||
"told_constable_about_briarstead" }`, `{ kind: "rep_event", event: {...}
|
||
}`, `{ kind: "give_item", id: "fang", qty: 3 }`,
|
||
`{ kind: "start_quest", id: "main_act_i_002_briarstead" }`.
|
||
|
||
Skill checks roll d20 + ability mod + (proficient ? prof_bonus : 0) using the
|
||
`dialogueSeed`. Outcomes can branch:
|
||
|
||
```jsonc
|
||
{
|
||
"text": "[Persuasion DC 12] You don't seem like the type to give up easily.",
|
||
"conditions": [{ "kind": "skill_check", "skill": "persuasion", "dc": 12 }],
|
||
"effects_on_success": [{ "kind": "next", "node": "constable_relents" }],
|
||
"effects_on_failure": [
|
||
{ "kind": "rep_event", "event": { "type": "DIALOGUE", "magnitude": -3 } },
|
||
{ "kind": "next", "node": "constable_dismisses" }
|
||
]
|
||
}
|
||
```
|
||
|
||
Rolled-once-per-conversation: the same skill check on the same node, same
|
||
NPC, same conversation does not re-roll. The runner caches roll outcomes
|
||
keyed by `(npcId, dialogueTurnIndex, optionIndex)`. Save-mid-dialogue
|
||
captures `dialogueTurnIndex` and the cache; resume is byte-identical.
|
||
|
||
### 4.4 Quest engine
|
||
|
||
Per the master plan §8.4, quests are flag/condition graphs. The minimum
|
||
viable engine:
|
||
|
||
```csharp
|
||
public sealed class QuestEngine {
|
||
public void StartQuest(string questId);
|
||
public void CompleteStep(string questId, string stepId);
|
||
public void EndQuest(string questId, QuestOutcome outcome);
|
||
public void Tick(WorldClock clock, ActorManager actors, RepLedger rep);
|
||
// walks active quests; evaluates each step's triggers; runs effects
|
||
}
|
||
```
|
||
|
||
Triggers fire on:
|
||
|
||
- `flag_set` — a quest flag is now true
|
||
- `enter_anchor` — player entered a chunk overlapping an anchor's footprint
|
||
- `enter_role_proximity` — player within N tiles of a named NPC
|
||
- `npc_state` — named NPC's HP / alive / disposition meets a threshold
|
||
- `time_elapsed` — game-clock duration since step started or quest started
|
||
- `rep_threshold` — faction or personal rep crosses a value
|
||
- `combat_outcome` — most recent encounter ended with specific actors alive/dead
|
||
- `item_acquired` / `item_used` — inventory event
|
||
- `dialogue_choice` — last dialogue ended with a specific node id reached
|
||
|
||
Effects available on step entry/completion:
|
||
|
||
- `set_flag`, `clear_flag`
|
||
- `give_item`, `take_item`, `give_xp` (XP requires Phase 5.5 to actually act on)
|
||
- `rep_event` (typed event — propagates per §3.4)
|
||
- `spawn_npc` — instantiate a named NPC at an anchor
|
||
- `despawn_npc`
|
||
- `start_quest`, `end_quest`, `fail_quest`
|
||
- `set_npc_disposition` (direct write — used sparingly, e.g. parent's deaths
|
||
set the magistrate's disposition to known-supportive)
|
||
|
||
Every quest must validate at content-validate time:
|
||
|
||
- All steps form a DAG (no cycles in the explicit-progression edges).
|
||
- Every condition references a real flag / anchor / role.
|
||
- Every effect references a real item / quest / npc template.
|
||
- At least one path from quest start to a terminal (success or fail) state.
|
||
|
||
`quest-validate` Tools command runs these in CI.
|
||
|
||
### 4.5 Reputation system
|
||
|
||
Three layers, blended into one **Effective Disposition** number per (NPC, PC)
|
||
pair, computed on demand:
|
||
|
||
**Layer 1 — Clade Bias.** Per `reputation.md` §I-1. Bias profile is a
|
||
template (~12 of them) referencing PC clade × species × size differential.
|
||
Stored as data, not code. Lookup is a flat dictionary read.
|
||
|
||
**Layer 2 — Faction Standing.** `Dictionary<FactionId, int>` on the player,
|
||
range -100..+100. Updates apply the opposition matrix from `reputation.md`
|
||
§I-2 in a single pass — gain +10 with Inheritors → -5 Enforcers, -2 Thorn,
|
||
-3 Hybrid Underground, -3 Unsheathed.
|
||
|
||
**Layer 3 — Individual Disposition.** Per-NPC `PersonalDisposition` record:
|
||
|
||
```csharp
|
||
public sealed class PersonalDisposition {
|
||
public int BaseValue { get; set; }
|
||
public int CurrentValue { get; set; } // -100..+100
|
||
public TrustLevel Trust { get; set; } // STRANGER..BONDED
|
||
public List<RepEvent> Log { get; } = new();
|
||
public HashSet<string> MemoryFlags { get; } = new();
|
||
public bool BetrayalFlag { get; set; }
|
||
public ulong LastInteractionTick { get; set; }
|
||
}
|
||
```
|
||
|
||
Only NPCs the player has *interacted* with get a record. Generic shopkeepers
|
||
the player walks past don't accumulate state.
|
||
|
||
**Effective Disposition** is recomputed lazily in `EffectiveDisposition.For(npc, pc)`.
|
||
It's not cached — the inputs change too often (faction propagation, time
|
||
decay) to make caching worth the invalidation cost. Computation is O(1).
|
||
|
||
### 4.6 Faction influence on NPC behavior
|
||
|
||
Three runtime hooks:
|
||
|
||
- **Patrol aggression.** A patrol NPC checks effective disposition every
|
||
tick while the player is in LoS within their faction's perception range.
|
||
Disposition ≤ HOSTILE → `Allegiance` flips to `Hostile` for this encounter,
|
||
and `EncounterTrigger.FindHostileTrigger` picks them up. (Re-uses Phase 5
|
||
mechanism unchanged.)
|
||
- **Merchant pricing.** Shop UI multiplies all prices by a factor derived
|
||
from disposition: NEMESIS = refused service, HOSTILE = -no-trade,
|
||
ANTAGONISTIC..UNFRIENDLY = +25% prices, NEUTRAL = base, FAVORABLE =
|
||
-10%, FRIENDLY = -20%, ALLIED = -30%, CHAMPION = -40%. (Shop UI itself
|
||
is part of M3.)
|
||
- **Dialogue gating.** Already covered in §4.3.
|
||
|
||
Faction influence on procedurally-generated residents is decided once at
|
||
chunk-instantiation. A chunk in Inheritor-strong territory rolls residents
|
||
with a +0.3 weight toward `INHERITOR_TRUE_BELIEVER` profile, and so on.
|
||
This is locked at instantiation; it doesn't drift over time. (Drift is
|
||
Phase 8 alongside NPC schedules.)
|
||
|
||
### 4.7 Act I content
|
||
|
||
Act I in `questline.md` runs from arrival in Millhaven through the climax
|
||
encounter with Lacroix in Briarstead. It targets levels 1–4. Phase 6 ships
|
||
the **plot-load-bearing** quests:
|
||
|
||
| Quest | Source | Phase 6 status |
|
||
|---|---|---|
|
||
| `main_act_i_001_arrival` | Player arrives in Millhaven, gets the magistrate's letter | Required |
|
||
| `main_act_i_002_briarstead` | Investigate parents' farm; find journal + formula + names list | Required |
|
||
| `main_act_i_003_following_dead` | Lacroix arrives; defend Briarstead; receive Maw sigil | Required (climax) |
|
||
| `side_act_i_fence_lines` | Cervid/Canid property dispute | Required (worldbuilding) |
|
||
| `side_act_i_old_howl` | Grandmother Asha + the mine | Required (introduces dungeon-light combat + Howl-stone macguffin) |
|
||
| `side_act_i_shedding_season` | Hybrid scent-mask quest | **Stretch** — touches hybrid mechanics that defer to Phase 6.5 |
|
||
| `side_act_i_cellar_problem` | Targeted livestock attacks | **Stretch** — preview of Maw's scent technology |
|
||
|
||
Required quests ship with full dialogue, fail conditions, and the named
|
||
NPCs they reference (Aldous Fenn, Grandmother Asha, the Magistrate, Fen
|
||
Lacroix). Stretch quests ship if M5 lands ahead of schedule.
|
||
|
||
Act I exit: PC has the journal, the formula, the names list, the Maw sigil,
|
||
and Lacroix is dead, fled, or interrogated. World flags reflecting all
|
||
three are set; Thornfield (Act II) is unlocked but not authored — the road
|
||
out of Millhaven leads to a placeholder "[Act II content lands in Phase 7
|
||
or Phase 10]" dialogue at the city gate.
|
||
|
||
### 4.8 Save schema
|
||
|
||
`SaveBody` v6 promotes the placeholder containers to typed snapshots:
|
||
|
||
```csharp
|
||
// v5 (Phase 5):
|
||
public Dictionary<string, int> Flags { get; set; } = new();
|
||
public Dictionary<int, int> Factions { get; set; } = new();
|
||
public List<int> QuestState { get; set; } = new();
|
||
public Dictionary<string, int> Reputation { get; set; } = new();
|
||
public List<int> DiscoveredPoiIds { get; set; } = new();
|
||
|
||
// v6 (Phase 6):
|
||
public Dictionary<string, int> Flags { get; set; } = new();
|
||
public Dictionary<string, int> FactionStandings { get; set; } = new();
|
||
public List<QuestStateSnapshot> Quests { get; set; } = new();
|
||
public ReputationSnapshot Reputation { get; set; } = new();
|
||
public List<int> DiscoveredPoiIds { get; set; } = new();
|
||
public AnchorRegistrySnapshot Anchors { get; set; } = new();
|
||
public List<BuildingDelta> Buildings { get; set; } = new();
|
||
```
|
||
|
||
`Flags` keeps its v5 shape (string → int — small int values let us model
|
||
0/1/2/3 quest progress flags inside the same bag without a schema change
|
||
later). The v5→v6 migration is **non-destructive**: empty v5 containers
|
||
become empty v6 snapshots; non-empty v5 containers (which can't exist
|
||
because Phase 5 never wrote them) would map field-for-field. So unlike
|
||
the v4→v5 case, v6 *does* accept v5 saves.
|
||
|
||
New `SaveCodec` tags (≥110 — keep the Phase-5 100-block contiguous):
|
||
|
||
```
|
||
TAG_FACTION_STANDINGS = 110
|
||
TAG_QUESTS = 111
|
||
TAG_REPUTATION = 112
|
||
TAG_ANCHORS = 113
|
||
TAG_BUILDINGS = 114
|
||
```
|
||
|
||
Bump `SAVE_SCHEMA_VERSION` to **6**.
|
||
|
||
Add `V5ToV6` migration: zero-fill the new typed containers from the
|
||
placeholder dictionaries (which Phase 5 never populates). Tested by
|
||
`V5ToV6MigrationTests`.
|
||
|
||
---
|
||
|
||
## 5. Determinism & RNG
|
||
|
||
| RNG sub-stream | Used by |
|
||
|---|---|
|
||
| `RNG_DIALOGUE` | Skill-check rolls inside dialogue, randomised flavour-text picks |
|
||
| `RNG_QUEST` | Quest step random outcomes (e.g. Briarstead loot, Lacroix's hired-muscle composition) |
|
||
| `RNG_RESIDENT_GEN` | Procedural resident species/profile selection per settlement |
|
||
| `RNG_BUILDING_LAYOUT` | Tier-2-through-5 procedural settlement layouts |
|
||
| `RNG_REP_PROPAGATION` | Frontier-settlement coin-flips for whether a rep event arrives at all |
|
||
|
||
Per-conversation sub-seed:
|
||
`dialogueSeed = worldSeed ^ RNG_DIALOGUE ^ npcId ^ dialogueTurnIndex`.
|
||
|
||
The conversation's `SeededRng` advances monotonically as the player picks
|
||
options. `DialogueState` snapshot serializes `(dialogueSeed,
|
||
nextRollSequence)`; resume re-creates the RNG and skips to the sequence.
|
||
Verified by `DialogueRunnerTests` mid-conversation save round-trip.
|
||
|
||
**Tests required:**
|
||
|
||
- `BuildingStampTests` — same (worldSeed, settlementId) → byte-identical
|
||
stamp at the chunk level, across 5 process runs.
|
||
- `DialogueDeterminismTests` — 1000 rolls from `(seedA, seq=0..999)`
|
||
produce identical outputs across runs.
|
||
- `RepPropagationDeterminismTests` — same event chain at same seed produces
|
||
identical NPC standings.
|
||
- `ActIIntegrationTests` — scripted full Act I walkthrough at fixed seed
|
||
ends with identical save state every time.
|
||
|
||
---
|
||
|
||
## 6. Constants going into `Constants.cs`
|
||
|
||
```csharp
|
||
// ── Phase 6: RNG sub-streams ─────────────────────────────────────────
|
||
public const ulong RNG_DIALOGUE = 0xD1A106EUL;
|
||
public const ulong RNG_QUEST = 0x9E57E0UL;
|
||
public const ulong RNG_RESIDENT_GEN = 0x1E51DEUL;
|
||
public const ulong RNG_BUILDING_LAYOUT = 0xB1D106UL;
|
||
public const ulong RNG_REP_PROPAGATION = 0x1EFA77UL;
|
||
|
||
// ── Phase 6: Settlements ─────────────────────────────────────────────
|
||
public const int BUILDING_MIN_W_TILES = 4; // smallest stamp footprint
|
||
public const int BUILDING_MIN_H_TILES = 3;
|
||
public const int BUILDING_DOOR_HALO_TILES = 2; // no scatter / wall in this halo around any door
|
||
public const int SETTLEMENT_BUILDING_GAP_MIN = 2; // tiles between adjacent buildings
|
||
|
||
// ── Phase 6: Reputation ──────────────────────────────────────────────
|
||
public const int REP_MIN = -100;
|
||
public const int REP_MAX = 100;
|
||
public const int REP_NEMESIS_THRESHOLD = -76;
|
||
public const int REP_HOSTILE_THRESHOLD = -51;
|
||
public const int REP_ANTAGONISTIC_THRESHOLD= -26;
|
||
public const int REP_UNFRIENDLY_THRESHOLD = -1;
|
||
public const int REP_FAVORABLE_THRESHOLD = 1;
|
||
public const int REP_FRIENDLY_THRESHOLD = 26;
|
||
public const int REP_ALLIED_THRESHOLD = 51;
|
||
public const int REP_CHAMPION_THRESHOLD = 76;
|
||
|
||
// Propagation decay multipliers (* 100 to keep int math)
|
||
public const int REP_DECAY_AT_ORIGIN_PCT = 100;
|
||
public const int REP_DECAY_ADJACENT_PCT = 80;
|
||
public const int REP_DECAY_REGIONAL_PCT = 60;
|
||
public const int REP_DECAY_CONTINENTAL_PCT = 40;
|
||
public const int REP_DECAY_FRONTIER_PCT = 20;
|
||
public const int REP_FRONTIER_DELIVERY_PROB_PCT = 50; // coin-flip on whether news arrives at all
|
||
|
||
// Distance bands in world tiles (256-tile-wide world)
|
||
public const int REP_ADJACENT_DIST_TILES = 20;
|
||
public const int REP_REGIONAL_DIST_TILES = 60;
|
||
public const int REP_CONTINENTAL_DIST_TILES = 200;
|
||
|
||
// Time gates
|
||
public const int REP_PROPAGATION_TICKS_PER_DAY = 24; // 1/hour
|
||
public const int REP_NEMESIS_PROPAGATION_INSTANT = 1; // bool: nemesis/champion ignores distance/time
|
||
|
||
// ── Phase 6: Dialogue ────────────────────────────────────────────────
|
||
public const int DIALOGUE_MAX_OPTIONS_PER_NODE = 6;
|
||
public const int DIALOGUE_HISTORY_LINES = 50; // scrollback in InteractionScreen
|
||
|
||
// ── Phase 6: Quests ──────────────────────────────────────────────────
|
||
public const int QUEST_MAX_ACTIVE = 20; // sanity cap on active quests
|
||
public const int QUEST_LOG_COMPLETED_LIMIT = 100; // archived completed quests cap
|
||
|
||
// ── Phase 6: Save ────────────────────────────────────────────────────
|
||
// SAVE_SCHEMA_VERSION bumped to 6 (was 5 in Phase 5)
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Milestones
|
||
|
||
Each is a ship point: a branch with a self-contained set of changes,
|
||
green tests, and a feature you can demonstrate.
|
||
|
||
**M0 — Catch up Phase 4 settlement stamping.**
|
||
- `BuildingTemplateDef` + `SettlementLayoutDef` records.
|
||
- `SettlementStamper` replaces `Pass3_Settlements`; existing cobble-plaza
|
||
output is preserved as a fallback when no layout is registered.
|
||
- 11 building templates authored (inn small/medium, shop general/smithy/
|
||
alchemist, house small/medium, magistrate, granary, well, rail station).
|
||
- 6 settlement layouts authored: Millhaven preset, Thornfield preset, plus
|
||
procedural Tier 2/3/4/5 rule-based layouts.
|
||
- New `TacticalDeco` entries: Door, Counter, Bed, Hearth, Sign.
|
||
- New `TacticalFlags`: Building, Doorway, Interior.
|
||
- `BuildingFootprint` record on chunks; cross-chunk lookup helper.
|
||
- `settlement-render` Tools command exports a stamped settlement to PNG
|
||
for visual review.
|
||
- All chunk-determinism tests still green; new `BuildingStampTests`
|
||
added.
|
||
- **Ship point:** `dotnet run -- settlement-render --seed 12345
|
||
--settlement millhaven --out millhaven.png` produces a stamped Millhaven
|
||
with twelve buildings, doors, signs, and the existing wall ring.
|
||
In-game: walk into Millhaven, see actual buildings, walk through doors.
|
||
|
||
**M1 — Resident instantiation + bias profiles + dialogue shell expanded.**
|
||
- `BiasProfileDef` + 12 profiles authored.
|
||
- `ResidentTemplateDef` + ~15 generic resident templates (innkeeper,
|
||
shopkeeper, constable, guard, farmer, etc.).
|
||
- `ResidentInstantiator` — at chunk stream-in, produces friendly/neutral
|
||
`NpcActor`s placed inside their assigned building's role spawn point.
|
||
- `NpcActor.RoleTag` / `DialogueId` / `BiasProfileId` extensions.
|
||
- `AnchorRegistry` runtime + `AnchorRegistrySnapshot` save piece (build on
|
||
load if missing, persist after first build).
|
||
- `InteractionScreen` body filled in: Myra-driven dialogue panel reading
|
||
from `dialogues/*.json`, but *only* the structural runner — no Act I
|
||
content yet, just a generic "Hello, traveler. What can I do for you?"
|
||
/ "Goodbye." stub for every named role.
|
||
- **Ship point:** Walk into Millhaven inn → innkeeper is *there*, named,
|
||
with a stat block. Press F → InteractionScreen shows real dialogue UI
|
||
(still placeholder text). Quit → reload → innkeeper is in the same
|
||
spot with the same name.
|
||
|
||
**M2 — Reputation core (no propagation yet).**
|
||
- `EffectiveDisposition`, `BiasProfile`, `FactionStanding`,
|
||
`PersonalDisposition`.
|
||
- 7 factions authored (3 existing + Maw, Hybrid Underground, Unsheathed,
|
||
Merchant Guilds).
|
||
- Faction opposition matrix.
|
||
- `ReputationSnapshot` save schema; **bumps SAVE_SCHEMA_VERSION to 6**;
|
||
`V5ToV6` migration ships.
|
||
- `ReputationScreen` (R key) with a strip per faction + a recent-NPC list.
|
||
- Hover an NPC in tactical view → `NpcDispositionTooltip` shows effective
|
||
disposition + reasons.
|
||
- **No propagation yet** — actions that produce rep events apply at origin
|
||
only.
|
||
- **Ship point:** ESC → R → Reputation screen with all 7 factions visible.
|
||
In a debug build, a `dev-rep-event` console adds events; UI reflects
|
||
immediately. Save → load → standings preserved.
|
||
|
||
**M3 — Dialogue conditions + skill checks + shop UI.**
|
||
- `DialogueRunner` with full condition + effect evaluation.
|
||
- Skill checks roll the `dialogueSeed` deterministically; UI shows the
|
||
d20 result.
|
||
- Shop dialogue branch — `ShopMode` opens a buy/sell modal layered on the
|
||
dialogue panel; prices apply disposition-based modifier.
|
||
- Inventory transactions through the existing Phase 5 inventory.
|
||
- `dialogue-validate` Tools command.
|
||
- 3 fully-authored generic dialogue trees: `generic_merchant`,
|
||
`generic_villager`, `generic_guard`.
|
||
- **Ship point:** Buy a chain shirt from the Millhaven general store. Try
|
||
to bully the shopkeeper into a discount with an Intimidation check.
|
||
Pass → -10% prices for the rest of the conversation. Fail → -3
|
||
personal disposition, locked out of bullying.
|
||
|
||
**M4 — Quest engine + quest log.**
|
||
- `QuestEngine`, `QuestLog`, `QuestStateSnapshot`.
|
||
- All trigger and effect kinds implemented.
|
||
- `QuestLogScreen` (J key) with active + completed quests, step
|
||
descriptions, optional waypoint hints.
|
||
- `quest-validate` Tools command (CI gate).
|
||
- `main_act_i_001_arrival` quest authored end-to-end as a proof.
|
||
- **Ship point:** New game in Millhaven → arrival quest fires
|
||
automatically → magistrate dialogue authored → quest progresses through
|
||
to Briarstead pointer → press J → see quest in log with step description.
|
||
|
||
**M5 — Reputation propagation + faction-driven NPC behaviour.**
|
||
- `RepPropagation` with the distance-band decay model.
|
||
- `RepLedger` event log surfaces in the reputation screen ("why does
|
||
so-and-so hate me?").
|
||
- Patrol behaviour reads effective disposition; aggros at HOSTILE.
|
||
- Merchant prices respond.
|
||
- Dialogue option visibility responds.
|
||
- **Ship point:** Anger the Inheritors in Briarstead. Walk back to
|
||
Millhaven (a few hours of game time). Confederate Inheritor sympathiser
|
||
in Millhaven now hostile in dialogue. Walk to Thornfield (cross-region,
|
||
long delay). Inheritor representative there is *still* slightly cool
|
||
toward you (40% decay).
|
||
|
||
**M6 — Act I content ships end-to-end.**
|
||
- All required quests authored: arrival, Briarstead, following the dead,
|
||
fence lines, old howl.
|
||
- All Act I named NPCs with full dialogue trees: Aldous Fenn (constable),
|
||
Grandmother Asha, the Magistrate, Fen Lacroix.
|
||
- Old Howl mine: a single-tile-grid combat encounter with hand-authored
|
||
loot (the Howl-stone) — proof that Phase 5's combat plugs into Phase 6's
|
||
quest steps cleanly.
|
||
- Lacroix climax: night-time Briarstead break-in encounter, with the
|
||
branching outcome (kill / chase / interrogate) fed back into quest state
|
||
+ faction standing.
|
||
- Stretch: shedding season + cellar problem if M5 lands ahead of schedule.
|
||
- **Ship point:** A complete Phase 6. Make a wolf-folk Fangsworn. Arrive
|
||
in Millhaven. Read the magistrate's letter. Investigate Briarstead.
|
||
Find the journal. Talk to Asha. Run the mine. Defend Briarstead from
|
||
Lacroix. The journal, formula, list, and Maw sigil are in your
|
||
inventory. The road out leads to Thornfield (Phase 10 placeholder).
|
||
|
||
---
|
||
|
||
## 8. Risks & mitigations
|
||
|
||
| Risk | Likelihood | Impact | Mitigation |
|
||
|---|---|---|---|
|
||
| Authoring volume balloons (~30 dialogue trees, ~7 quests, ~12 building templates, ~12 bias profiles, ~15 resident templates) | High | High | Schema is permissive — half the JSON shape is optional fields. Parallel authoring as soon as M0+M1 land; dialogue/quest schemas stable from M3+M4. Use a content-validate CI gate to catch broken refs *before* runtime so authoring doesn't block on engine bugs. |
|
||
| Procedural settlement layouts produce visually ugly results (buildings overlapping roads, blocked doors) | Med | High | M0 ships only the Tier-1 hand-authored presets (Millhaven, Thornfield) for the Act I path. Tier 2–5 procedural layouts ship with stricter constraints (door-halo radius, road-snap, footprint AABB checks). `settlement-render` Tools command renders any seed for visual QA before merging. |
|
||
| Reputation propagation feels surprising or unfair to the player | Med | High | Reputation screen surfaces a recent-events log per NPC ("she heard you killed an Enforcer in Briarstead 3 days ago"). The player always has a why-this-happened breadcrumb. NEMESIS / CHAMPION events propagate at full speed by design — these are story-significant events the player did intentionally. |
|
||
| Mid-dialogue save/load determinism breaks subtly | Med | High | Same shape as Phase 5's mid-combat save. `DialogueRunnerTests` round-trips; the runner caches roll outcomes by `(npcId, turnIndex, optionIndex)` tuple; resume re-creates the runner with the cached outcomes. |
|
||
| Quest engine evolves into a scripting-language project | Low | High | Hard rule: no Lua, no embedded interpreter, no dynamic code gen. Triggers and effects are *enums* (10 trigger kinds, 12 effect kinds in M4). Anything that needs imperative logic gets a new effect kind, not a script. If we're tempted to add a 13th effect kind to handle one quest, we redesign that quest first. |
|
||
| 3-layer rep system makes every NPC interaction feel computationally heavy in the UI | Med | Med | `EffectiveDisposition.For(npc, pc)` is O(1) given the precomputed bias profile lookup. UI reads it once per render. Tooltip is on-demand. |
|
||
| Faction influence on procgen residents conflicts with hand-authored named NPCs | Low | Low | Hand-authored NPCs always win — `ResidentTemplateDef` for named roles overrides any procgen profile choice. |
|
||
| Building stamps break Phase-5 chunk hashes used by determinism tests | Med | Med | Phase 5's chunk hash already folds in `DangerZone` (one byte). Phase 6 adds `BuildingFootprint` count + first-building-id-hash to the same FNV stream; tests get re-baselined once at M0 ship. |
|
||
| `SAVE_SCHEMA_VERSION=6` migration drops player progress | Low | Med | Unlike v4→v5 (rejected outright), v5→v6 is *additive* — every Phase 6 field has an empty default. Tested by `V5ToV6MigrationTests` round-trip. |
|
||
| Phase 5.5 (leveling) hasn't shipped, so Act I can't actually let the PC reach level 4 | Med | Med | Quest XP rewards are emitted regardless. If `Character.Level` stays at 1 throughout Act I, the encounters are tuned to be survivable at level 1 (Lacroix is a brigand_marauder, the mine is 3 brigand_footpads). The story still resolves; the *power fantasy* of leveling waits for Phase 5.5. |
|
||
|
||
---
|
||
|
||
## 9. Open decisions to resolve before M3
|
||
|
||
1. **Skill-check fairness model.** d20 + ability mod + (proficient ? prof
|
||
: 0). Should there be a "luck" floor / ceiling for narrative-critical
|
||
checks (e.g. you can never natural-1 the Lacroix interrogation), or
|
||
pure d20 always? Proposed: pure d20 for flavour rolls, but story-gating
|
||
skill checks have a "best of 2" rule (roll twice, take higher) when
|
||
the narrative would deadlock on a fail. Decision needed by M3.
|
||
|
||
2. **Shop pricing under HOSTILE.** A merchant at HOSTILE: refuses service
|
||
entirely (Phase 5-style "encounter triggers")? Or trades but at +200%
|
||
markup with a "stranger's price" flavour? Proposed: HOSTILE = refuses;
|
||
ANTAGONISTIC = stranger's price (+50%). Drives the player toward
|
||
reputation work without locking them out of trade entirely. Decision
|
||
needed by M3.
|
||
|
||
3. **Personal disposition decay.** Does an NPC forgive over time without
|
||
contact? Proposed: yes, slow drift toward 0 at 1 point per in-game
|
||
month, capped so disposition never decays past ±5 of base. Major
|
||
memory flags (`betrayed_me`, `saved_my_kit`) are immune. Decision
|
||
needed by M5.
|
||
|
||
4. **Quest hint visibility.** The `QuestLogScreen` can show the
|
||
exact-tile waypoint of the next step ("go to anchor:millhaven.briarstead
|
||
at world-tile 137,82") or only narrative pointers ("the magistrate
|
||
said the farm is south of town"). Proposed: narrative pointers by
|
||
default; an optional `--show-waypoints` toggle in the pause menu for
|
||
players who want a guided experience. Decision needed by M4.
|
||
|
||
5. **Bias profile for the player character.** PCs *also* have bias toward
|
||
NPCs (a wolf-folk PC walks into a herd-city and feels the prey-clade
|
||
nervousness). Does Phase 6 model this UI-side ("you feel uneasy here"
|
||
prose)? Proposed: out-of-scope for Phase 6 — the system is designed for
|
||
how NPCs perceive the PC, not vice versa. Phase 6.5 if needed.
|
||
|
||
6. **Hand-authored named NPC species/clade lock vs. seed-rolled.** Some
|
||
named NPCs in `questline.md` have specific species (Asha = wolf-folk,
|
||
Dr. Venn = leopard-folk). Are these species-locked, or do they roll
|
||
per seed within their declared clade? Proposed: locked, with the
|
||
species declared in the resident template. Lacroix is always coyote-
|
||
folk; Asha is always wolf-folk. This keeps the story stable across
|
||
seeds. Decision needed by M1.
|
||
|
||
7. **Faction standing visibility.** All 7 factions visible in the
|
||
reputation screen from game start, or only after first interaction?
|
||
The Maw is *hidden* per `questline.md` until Act I climax. Proposed:
|
||
visible-after-first-encounter for the 6 public factions; the Maw
|
||
appears in the screen only after Act I climax sets the
|
||
`act_i_maw_revealed` flag. Decision needed by M2.
|
||
|
||
---
|
||
|
||
## 10. What Phase 6 does **not** finish, and why that's OK
|
||
|
||
Phase 6's exit criterion is: **a player can play Act I end-to-end on a
|
||
procgen world with deterministic save/load. Settlements feel inhabited.
|
||
Dialogue gates on reputation. Factions react to your choices. The
|
||
foundations are in place for Acts II–V to layer on without re-architecting
|
||
any of it.**
|
||
|
||
Things deliberately deferred:
|
||
|
||
- **Levelling beyond level 1.** Phase 5.5 — independent workstream.
|
||
- **Subclass features.** Phase 5.5 — same.
|
||
- **Hybrid characters and passing detection.** Phase 6.5 — needs a scent
|
||
simulation layer.
|
||
- **Per-NPC scent profiles** as a propagating sim. Phase 6.5 / 7.
|
||
- **Betrayal events with cascading consequences.** Phase 6.5.
|
||
- **Friendly NPC dialogue voice acting / portraits.** Phase 9 polish or
|
||
later.
|
||
- **Procedural side quests.** Phase 7 hooks into the dungeon engine for
|
||
procedural delve-and-loot quest generation; Phase 6 ships hand-authored
|
||
side content only.
|
||
- **NPC schedules / day-night activity.** Phase 8.
|
||
- **Trade economy as simulation.** Phase 8.
|
||
- **Faction quest lines** (Inheritor questline, Thorn Council questline,
|
||
etc.). Phase 10.
|
||
- **Acts II–V.** Phase 10.
|
||
- **PoI dungeons / interiors as procedural multi-room generation.** Phase 7.
|
||
Phase 6 ships only the *Old Howl* mine, and only as a flat single-room
|
||
combat encounter — the dungeon engine subsumes it later.
|
||
- **Long/short rest mechanics.** Phase 8.
|
||
- **Quest editor / authoring tooling.** Phase 6 authors quests by hand-
|
||
editing JSON, validated by `quest-validate`. A graphical authoring tool
|
||
is not a goal until the content load is large enough to justify it
|
||
(post-Act-II).
|
||
|
||
The payoff: Phase 7 starts on a foundation where character + combat +
|
||
settlements + dialogue + quests + factions are real and tested, so the
|
||
dungeon layer can focus on the dungeon problem instead of co-developing
|
||
the social layer at the same time.
|
||
|
||
---
|
||
|
||
## 11. Implementation deviations
|
||
|
||
This section records *what actually shipped* versus what the plan specified.
|
||
The plan above is preserved as-written; this section is the source of truth
|
||
for current code state. Future agents touching Phase 6 systems should read
|
||
this before referencing the plan, since the plan's design intent occasionally
|
||
diverged at implementation time for the reasons listed below.
|
||
|
||
**Phase 6 final state — 2026-04-26:** SAVE_SCHEMA_VERSION=6, 434 tests
|
||
green, all six milestones (M0–M6) shipped, content-validate clean.
|
||
|
||
### M0 — Settlement stamping
|
||
|
||
| Plan said | Shipped | Why |
|
||
|---|---|---|
|
||
| 11 building templates including `rail_station.json` | 10 templates; rail station omitted | `ENABLE_RAIL=false` per current world settings; rail station stays dormant per plan §1 non-goals. Trivial to add when rail comes back. |
|
||
| 4 procedural layouts (Tier 2/3/4/5) | 5 procedural layouts (Tier 1/2/3/4/5) | Tier-1 anchors that *don't* have a hand-authored preset (Sanctum Fidelis, Heartstone) need a fallback. Plan implicitly assumed all Tier-1 anchors get presets — only Millhaven + Thornfield do in M0. |
|
||
| `BuildingFootprint` "on chunks; cross-chunk lookup helper" (§3.1) | `BuildingFootprint` list **on `Settlement`** | Settlement-keyed is naturally cross-chunk: AABB intersection at stamp time finds which chunk-windows overlap a building. No per-chunk lookup helper needed; chunks remain stateless about buildings. |
|
||
| `BuildingTemplateDef.MinTierEligible: 4 = village+` | Same data, but the predicate check is `s.Tier <= MinTierEligible` (NOT `>=`) | Tier numbering is inverted — Tier 1 = capital (largest), Tier 5 = hamlet (smallest). A magistrate with `min_tier_eligible: 2` belongs in Tier 1 capitals AND Tier 2 cities. Documented in [SettlementStamper.cs:119](Theriapolis.Core/World/Settlements/SettlementStamper.cs:119). |
|
||
|
||
### M1 — Residents + dialogue shell
|
||
|
||
| Plan said | Shipped | Why |
|
||
|---|---|---|
|
||
| ~15 generic resident templates | 21 total: 10 generics + 11 named (7 Millhaven + 4 Thornfield) | Over-delivered the named NPCs to support the Millhaven/Thornfield presets in M0 and ship M6's Act I cast in one author-pass. |
|
||
| 7 factions (3 existing + Maw + Hybrid Underground + Unsheathed + Merchant Guilds) **added in M2** | All 4 new factions added in M1 | Bias profiles + resident templates needed faction id references at content-validate time. Forward-loading the factions kept M1 self-contained; M2 just needed to extend `FactionDef` with the opposition matrix and `hidden` flag. |
|
||
| `Theriapolis.Core/Rules/Dialogue/` for all dialogue files (§3.1) | Def types in `Theriapolis.Core/Data/`, runtime in `Theriapolis.Core/Rules/Dialogue/` | Matches existing Phase 5 convention — `BiasProfileDef`, `BuildingTemplateDef`, etc. all live in `Data/`. |
|
||
| `AnchorRegistrySnapshot` save piece | Built lazily on load, no save piece | Plan §3.1 says "M1 rebuilds it on every load from the live settlement list and active NpcActors." M1 stuck with rebuild-on-load. M2 didn't end up adding a snapshot — registry rebuilds in <1ms from worldgen + chunk stream-in, so the schema-bump payload was unnecessary. |
|
||
|
||
### M2 — Reputation core
|
||
|
||
| Plan said | Shipped | Why |
|
||
|---|---|---|
|
||
| `REP_NEMESIS_THRESHOLD = -76`, `REP_HOSTILE_THRESHOLD = -51`, `REP_ANTAGONISTIC_THRESHOLD = -26`, `REP_UNFRIENDLY_THRESHOLD = -1` (§6) | `REP_HOSTILE_THRESHOLD = -75`, `REP_ANTAGONISTIC = -50`, `REP_UNFRIENDLY = -25`. `REP_NEMESIS_THRESHOLD` removed entirely. | Plan values were upper bounds for negative tiers; the `For(score)` cascade compares `score >= threshold` ascending, which wants *inclusive lower bounds*. The original constants would have misclassified score = -10 as `Antagonistic` instead of `Unfriendly`. Caught by [DispositionLabel tests](Theriapolis.Tests/Reputation/EffectiveDispositionTests.cs); fixed with code comment in [Constants.cs:297](Theriapolis.Core/Constants.cs:297). |
|
||
| `SaveBody.Quests`, `SaveBody.Reputation`, `SaveBody.Anchors`, `SaveBody.Buildings` fields (§4.8) | Only `ReputationState` (M2) and `QuestEngineState` (M4) added. `Buildings` deferred (no per-tile damage in Phase 6). `Anchors` deferred (rebuilt on load). | Plan listed forward-compat scaffolding for all of M2–M5. Reality: only the active milestones bumped the schema; deferred ones rely on the codec's tagged-section forward compatibility (unknown tags skip on read). New tags reserved: `TAG_FACTION_STANDINGS=110`, `TAG_QUESTS=111`, `TAG_REPUTATION=112` emitted; `TAG_ANCHORS=113` and `TAG_BUILDINGS=114` reserved but not yet emitted. |
|
||
| Plan §4.8 listed `SaveBody.QuestState` as the new typed quest container | Renamed to `SaveBody.QuestEngineState`; `SaveBody.QuestState` (the v5 placeholder `List<int>`) kept for back-compat | Name collision with the existing v5 placeholder. Renaming the new field is cheaper than touching the v5 contract. |
|
||
| `V5ToV6` migration registered explicitly | `Migrations.EnsureSeeded()` self-seeds known migrations on first use | The plan-implied registration site (a `Game1.cs` hook or similar) wasn't ergonomic. Self-seeding means callers don't need to remember; tests can call `MigrateUp` directly. See [Migrations.cs](Theriapolis.Core/Persistence/SaveMigrations/Migrations.cs). |
|
||
| `FactionDef` extended via separate config | `FactionDef.Opposition` and `FactionDef.Hidden` added inline to the same record | Self-contained faction definition is easier to author + validate than a parallel opposition table. |
|
||
|
||
### M3 — Dialogue + skill checks + shop
|
||
|
||
| Plan said | Shipped | Why |
|
||
|---|---|---|
|
||
| Plan §4.3 example: `"conditions": [{ "kind": "skill_check", "skill": "persuasion", "dc": 12 }]` (skill check as a *condition*) | Separate top-level `skill_check` field on `DialogueOptionDef` with explicit `EffectsOnSuccess` / `EffectsOnFailure` / `NextOnSuccess` / `NextOnFailure` | Cleaner than overloading the conditions array. Authors can reason about visibility predicates and skill resolution as separate concerns. Dialogue runner caches roll outcomes by `(npcId, turnIndex, optionIndex)` — re-renders don't re-roll. |
|
||
| Plan didn't mention `Character.CurrencyFang` | Added `Character.CurrencyFang = 25` (default starting stipend) + EOS-pattern round-trip in `PlayerCharacterState` codec | Required for buying anything in the shop modal. Defaults to 25 fangs so M6's Act I has something to spend on a poultice or chain shirt. |
|
||
| Plan §4.6 disposition pricing: NEMESIS refused, HOSTILE refused, ANTAGONISTIC..UNFRIENDLY +25%, NEUTRAL base, FAVORABLE -10%, FRIENDLY -20%, ALLIED -30%, CHAMPION -40% | Buy multipliers exactly as plan; sell multipliers added: ANTAGONISTIC 0.35, UNFRIENDLY 0.40, NEUTRAL 0.50, FAVORABLE 0.55, FRIENDLY 0.60, ALLIED 0.65, CHAMPION 0.70, refused at HOSTILE/NEMESIS | Plan only specified buy-side modifiers; M3 needed a sell-side. Calibrated to give the player ~50% return at NEUTRAL with a small bonus per friendliness tier. |
|
||
|
||
### M4 — Quest engine + log
|
||
|
||
| Plan said | Shipped | Why |
|
||
|---|---|---|
|
||
| (M4 plan did not list NPC rendering) | **NpcSprite added as M4 step 0** before quest engine work | NPCs had no visual representation in the codebase — Phase 5 M5 introduced `NpcActor` with positions but no draw call. User flagged this directly during M4 planning. New [NpcSprite.cs](Theriapolis.Game/Rendering/NpcSprite.cs) mirrors `PlayerSprite` shape with allegiance-coloured body (red Hostile / grey Neutral / green Friendly / cyan Allied). Counter-scaled by 1/Zoom so it stays constant-on-screen at any zoom level. |
|
||
| `QuestEngine.cs` and `QuestLog.cs` (§3.1) | Single `QuestEngine.cs` exposing `Active` / `Completed` / `Journal` accessors | The split was an organisational hint, not a real responsibility split. `QuestLogScreen` reads from the engine directly. |
|
||
| `spawn_npc` and `despawn_npc` quest effects | Stub/log-only — they write to `engine.Journal` as `"(quest) spawn_npc <role> ← <template>"` | Plan §4.4 lists them as effects but plan §4.7 acknowledges Phase 6 M4 has no in-world spawn machinery. The journal entry makes it visible in the Quest Log so authors can see the *intent* during testing; the actual spawning lands when Phase 7 / 8 builds the residency mutation pipeline. |
|
||
| `dialogue_choice` quest condition | Implemented; reads from `QuestContext.LastDialogueNodeReached` | The DialogueRunner doesn't yet write `LastDialogueNodeReached` — the plumbing is on the QuestContext side and ready for cross-system hookup when M6/Phase 7 authors need it. |
|
||
|
||
### M5 — Reputation propagation + faction-driven NPC behaviour
|
||
|
||
| Plan said | Shipped | Why |
|
||
|---|---|---|
|
||
| `RepPropagation.Tick()` runs every in-game day, walks unpropagated events, **pushes** decayed deltas into per-settlement state (§3.4) | **Pull model**: per-NPC disposition queries call `RepPropagation.LocalStandingFor(...)` which walks the ledger on demand. No tick. No "propagated" flag. | Bounded ledger (256 entries) × bounded factions (~7) makes per-query compute O(events × factions) — cheap. Eliminates the unpropagated-state machine entirely. Matches M2's "lazy, no caching" design for `EffectiveDisposition`. The plan's push model is forward-compat-friendly: when time-lag arrives in Phase 8, the on-demand walk just adds a `clock.Seconds - ev.TimestampSeconds < lag` filter. |
|
||
| Time-gated propagation: adjacent gets news in 1–3 days, continental in 1–2 weeks (§I-2) | Instant delivery once event hits ledger. Distance decay applies; time decay does not. | Deferred to Phase 8 alongside NPC schedules / day-night activity. The constants (`REP_PROPAGATION_TICKS_PER_DAY`) listed in plan §6 are not in `Constants.cs` (would be unused); will be added when Phase 8 builds the lag model. |
|
||
| Plan §4.6: "Patrol NPC checks **effective disposition** every tick" | Patrol-aggro reads **direct local faction standing**, NOT effective disposition | Effective disposition formula's 0.5× faction-mod weight means a -100 faction standing only contributes -50 to disposition, which never crosses HOSTILE (-75). Direct read matches the design intent ("the patrol's faction radio said this player is wanted") and decouples patrol-aggro from the dispositioning lens. The 0.5× weight stays in place for *general* dispositioning. Documented in [FactionAggression.cs:11](Theriapolis.Core/Rules/Reputation/FactionAggression.cs:11). |
|
||
| Plan §4.5: bias profiles have `faction_affinity` hints; "M5 alongside propagation" wires them | Wired in M5 at 0.25× weight (so bias profile colours rather than dominates) | Required updating M2's `Faction_Standing_ContributesToHalfWeight` test to allow 20–23 instead of exactly 20 — bias profile adds a small additional contribution. |
|
||
|
||
### M6 — Act I content
|
||
|
||
| Plan said | Shipped | Why |
|
||
|---|---|---|
|
||
| Old Howl mine: "single-tile-grid combat encounter with hand-authored loot — proof that Phase 5's combat plugs into Phase 6's quest steps cleanly." | Quest gives the Howl-stone via step `on_enter`; combat is narrative ("the brigands camping at the entrance won't let you pass without a fight") | Real per-tile mine encounter requires a quest-driven NPC-spawn pipeline that wasn't built in M4 (`spawn_npc` is stub-only). Deferred to Phase 7's PoI/dungeon engine, which will subsume this whole encounter type. M6 ships the *quest state* + *narrative resolution* + *correct end-state inventory*, which is what the ship-point demo verifies. |
|
||
| Lacroix climax: "night-time Briarstead break-in encounter, with the branching outcome (kill / chase / interrogate)." | Resolved narratively inside the dialogue tree (`fight` node has a single "decisive blow" effect chain). All three outcomes (kill / interrogate / let-go) work and feed back into faction standing + quest state correctly. | Same reason — no quest-driven hostile-NPC spawn yet. The dialogue's three branches correctly set `lacroix_killed` / `lacroix_interrogated` / `lacroix_let_go` flags that the `following_dead` quest's outcome conditions read; final inventory/rep/quest-state matches the plan. The visual "tactical encounter" ships when Phase 7's PoI engine lands. |
|
||
| 7 Act I quests (arrival, briarstead, following_dead, fence_lines, old_howl, **shedding_season**, **cellar_problem**) | 4 quests shipped; `main_act_i_002_briarstead` **merged into** `_001_arrival` as the "investigate" step. 2 stretch side quests deferred. | `briarstead` merge: investigation is one logical beat with arrival, not a separate quest. Merging keeps the journal cleaner. `shedding_season` defers because it requires hybrid-passing mechanics (Phase 6.5); `cellar_problem` defers because it previews Maw scent technology that needs Phase 6.5/7's scent-simulation layer. Plan §10 explicitly listed these as M6 *stretch goals*. |
|
||
| `InteractionScreen` plays the dialogue tree end-to-end including dialogue-driven combat handoffs to `CombatHUDScreen` | Dialogue-driven combat handoff not implemented | The "settle it here" branch in Lacroix's dialogue resolves narratively. A real dialogue → combat encounter handoff (push CombatHUDScreen with hostile NPC pre-spawned) is also Phase 7 work. |
|
||
|
||
### Cross-cutting: things deferred to later phases
|
||
|
||
These were implicit in the Phase 6 plan but explicitly belong to subsequent phases. Listed here so future agents know they're *not* present in the current code, despite being design-doc references:
|
||
|
||
| Item | Where the plan placed it | Phase that picks it up |
|
||
|---|---|---|
|
||
| Quest-driven hostile-NPC spawning at world coordinates | Plan §4.4 effect kinds + M6 ship-point | Phase 7 (PoI / dungeon engine) |
|
||
| Time-lag propagation (news takes days to cross continent) | Plan §3.4 + §6 `REP_PROPAGATION_TICKS_PER_DAY` | Phase 8 (NPC schedules / day-night) |
|
||
| Per-NPC scent simulation as a propagating sim | Plan §1 non-goals → Phase 6.5/7 | Phase 6.5 / 7 |
|
||
| Hybrid-passing detection in dialogue | Plan §1 non-goals → Phase 6.5 | Phase 6.5 |
|
||
| Levelling beyond Level 1 + subclass features (Lvl 3+) | Plan §1 non-goals → Phase 5.5 | Phase 5.5 |
|
||
| Acts II–V questline | Plan §1 non-goals → Phase 10 | Phase 10 |
|
||
| Dungeons / PoI interiors | Plan §1 non-goals → Phase 7 | Phase 7 |
|
||
| Long/short rest mechanics | Plan §1 non-goals → Phase 8 | Phase 8 |
|
||
| Faction quest lines (Inheritor / Thorn / etc. dedicated arcs) | Plan §1 non-goals → Phase 10 | Phase 10 |
|
||
| `BuildingDelta` save schema (player-broken doors, vandalised signs) | Plan §3.1 listed it under v6 schema | Phase 7+ when destructible buildings matter |
|
||
|
||
### Constant + content totals at end of Phase 6
|
||
|
||
| Item | Count |
|
||
|---|---:|
|
||
| Save schema version | v6 (Phase 4 was v4, Phase 5 was v5, Phase 6 = v6) |
|
||
| Tests passing | 434 |
|
||
| Factions | 7 (6 visible + 1 hidden Maw) |
|
||
| Bias profiles | 12 |
|
||
| Resident templates | 22 (10 generic + 12 named) |
|
||
| Building templates | 10 |
|
||
| Settlement layouts | 7 (5 procedural Tier 1–5 + 2 anchor presets) |
|
||
| Items | 42 |
|
||
| Dialogue trees | 7 (3 generic + 4 named Millhaven NPCs) |
|
||
| Quest trees | 4 |
|
||
| RNG sub-streams added in Phase 6 | 5 (RNG_BUILDING_LAYOUT, RNG_DIALOGUE, RNG_QUEST, RNG_RESIDENT_GEN — used in M1 weighted picks, RNG_REP_PROPAGATION) |
|
||
| Save-codec tags added in Phase 6 | 2 emitted (TAG_FACTION_STANDINGS=110, TAG_REPUTATION=112) + 1 emitted at M4 (TAG_QUESTS=111) + 2 reserved (TAG_ANCHORS=113, TAG_BUILDINGS=114) |
|
||
|
||
### Vocabulary differences future authors should know
|
||
|
||
The dialogue and quest systems use **different keyword vocabularies** for the same logical predicate. Both work; mind the system you're authoring for:
|
||
|
||
| Logical predicate | Dialogue condition kind | Quest condition kind |
|
||
|---|---|---|
|
||
| "is this flag truthy?" | `has_flag` | `flag_set` |
|
||
| "is this flag falsy/missing?" | `not_has_flag` | `flag_clear` |
|
||
| "is the integer ≥ N?" | (n/a) | `flag_at_least` |
|
||
| "does PC have this item?" | `has_item` | `has_item` (same) |
|
||
| "rep with faction ≥ N?" | `rep_at_least` | `rep_at_least` (same) |
|
||
| "PC ability score ≥ N?" | `ability_min` | (n/a) |
|
||
| "PC at this anchor?" | (n/a) | `enter_anchor` |
|
||
| "PC near this NPC?" | (n/a) | `enter_role_proximity` |
|
||
|
||
This was an inconsistency the plan didn't anticipate. Phase 7's content-authoring tooling will likely unify the vocabularies; until then, both systems' loaders independently validate their own kind list at content-validate time.
|
||
|
||
### Where future agents should look first
|
||
|
||
When picking up a Phase 7+ task:
|
||
1. Read **§10 (deferred)** + this **§11 (deviations)** to see what's *actually* in the code.
|
||
2. Read [CLAUDE.md](CLAUDE.md) for build/test commands and hard rules.
|
||
3. Run `dotnet test` (~30s) to confirm baseline before changing anything.
|
||
4. Run `dotnet run --project Theriapolis.Tools -- content-validate` to confirm content integrity.
|
||
|
||
When fixing a propagation/aggression bug:
|
||
- Local faction standing is computed *on demand* by [RepPropagation.LocalStandingFor](Theriapolis.Core/Rules/Reputation/RepPropagation.cs). No cache to invalidate.
|
||
- Patrol aggression bypasses the disposition formula; reads local standing directly. See [FactionAggression.cs](Theriapolis.Core/Rules/Reputation/FactionAggression.cs).
|
||
|
||
When extending dialogue/quest content:
|
||
- See the **Vocabulary differences** table above before authoring conditions.
|
||
- Run `dotnet run --project Theriapolis.Tools -- dialogue-validate` and `... quest-validate` after changes — both run reachability + reference checks.
|
||
|
||
---
|
||
|
||
*Theriapolis Phase 6 Implementation Plan — 2026-04-26*
|
||
*Author: Claude (Opus 4.7) for LO, in continuity with the Phase 0–5 plan series.*
|
||
*Implementation deviations section appended 2026-04-26 after M0–M6 completion.*
|