ADR-0008 — Combat encounter API
ADR-0008 — Combat encounter API
Accepted: 2026-05-20 — sub-phase 13-7 dispatch ratifies.
Status: Accepted
Date: 2026-05-20
Owner: tech-architect
Paired implementation: backend-engineer (sub-phase 13-7 dispatch)
Related canon: D-010 (combat resolution canon — Walker-first alternation, seeded PRNG, non-punitive defeat), D-007 §3 (anti-cheat — restrictive posture; server-side reconciliation requires replayability), D-016 (progression — tree-point grants flow through /quest/complete, NOT through encounter resolution), D-015 (Android-first then iOS port at Phase 15), ADR-0006 (mock-auth backend posture), ADR-0007 (Android network layer — error envelope, Bearer auth, X-Request-Id)
1. Context
Phase 13 must deliver a server-side combat encounter surface. Today (start of sub-phase 13-7 prep) the layers exist asymmetrically:
- Math layer (canonical):
wiki/src/content/docs/combat/formulas.md§1–§12 freezes the 19-stat dictionary (12 combat-resident + 7 tree-resident-only), the damage / defense / hit / crit equations, the energy cost shape, the encounter shape, the cold-resist tagged-damage handling, and the §10 worked example. - PRNG (canonical): xorshift32 with constants (13, 17, 5), seed 0 remapped to 1, three draws per attacker turn (
roll_h,roll_v,roll_c), miss consumes one float. Frozen atdata/src/sim/combat.ts. - Simulator (canonical):
data/src/sim/combat.tsimplementssimulateEncounter(input, seed)as a pure function. Determinism is byte-for-byte across(input, seed). - D-010 ratified: Walker-first strict alternation, non-punitive defeat (hp clamps to 1 — no XP / point / rep / streak loss), seeded PRNG model.
- UI hint:
wiki/src/content/docs/ui/combat-screen.md(11e-1 wireframe) describes Android-side surface needs.
Missing (per phase-13-plan §3.1 Combat):
- Backend
/combat/encounterendpoint family — none exist. - Android combat UI implementation — wireframe only.
- Encounter trigger plumbing — no quest beat → encounter spin-up path.
- Encounter outcome wiring — no encounter result → quest state delta + faction-rep delta path.
Why now: Sub-phase 13-7 (Combat encounter API + Quest 002 trigger plumbing) needs the backend encounter family. This ADR clears the design lock so 13-7 implementation can dispatch unbroken. Sub-phases 13-8 (Combat UI on Android) and 13-9 (Combat outcome → quest/tree/rep deltas) depend on the same contract.
ADR numbering: ADR-0008. ADR-0007 was re-allocated to the Android network layer in sub-phase 13-1 (ADR-0006 originally reserved 0007 for VPS migration; VPS migration ADR defers to ADR-0009+ at Phase 14 trigger per D-015).
2. Decision — encounter lifecycle
An encounter is a server-side persisted entity with an explicit lifecycle. The client never owns canonical encounter state; the server is the source of truth and the simulator’s replay anchor.
2.1 Lifecycle states
pending -> active -> resolved \-> retreated \-> abandoned| State | Entry trigger | Exit |
|---|---|---|
pending | (Reserved.) Optional intermediate state if an encounter is queued by a quest beat but not yet shown to the player. Phase 13 collapses this into immediate active — endpoint goes straight to active on POST /combat/encounter. | |
active | POST /combat/encounter returns. Walker can submit turns. | Walker hp = 0 or enemy hp = 0 at top-of-turn → resolve. Walker submits retreat → retreated. Energy = 0 at top-of-turn → forced retreat → retreated. |
resolved | POST /combat/encounter/:id/resolve after victory / defeat. Outcome recorded. | Terminal. |
retreated | POST /combat/encounter/:id/retreat (voluntary) or server-side forced retreat on energy = 0. | Terminal. |
abandoned | Server-side cleanup. Encounter active for > 7 days without further turn submission → server marks abandoned. Walker cannot resume. | Terminal. |
The abandoned state is the design-in escape valve for orphaned encounters (Walker uninstalls mid-encounter, device wipe, etc.). It does NOT punish the Walker — quest state stays where it was at last turn submission. Encounter is dead; quest beat does not advance.
2.2 Persistence model
Two tables. The shape below is the spec; Prisma migration is OUT of scope for this ADR (the migration is authored by backend-engineer at 13-7 dispatch).
combat_encounters — one row per encounter.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key. |
walkerId | UUID | FK → walkers.id. |
questId | TEXT | Quest id (e.g. quest.002-frostlands-opening). Encounters always originate from a quest beat in Phase 13. (Region-event encounters defer to post-Phase-13.) |
questStepIndex | INT | 1-indexed step within the quest that triggered this encounter. Matches the step-advance pattern from 13-5. |
seed | INT (uint32 on the wire; stored as int4) | xorshift32 seed. Server-generated. Determines every roll in the encounter. |
walkerStatsSnapshot | JSONB | Full 12-stat snapshot at encounter init: hp, hp_max, energy, energy_max, damage, defense, accuracy, evasion, crit_chance, crit_mult, energy_cost_per_action, cold_resist — resolved values (modifier composition applied at snapshot time, per formulas.md §3 last paragraph). |
opponentStatsSnapshot | JSONB | 8-stat snapshot for the opponent: hp, hp_max, damage, defense, accuracy, evasion, crit_chance, crit_mult. (Opponents skip energy fields.) |
opponentTemplateId | TEXT | Reference to the opponent template (e.g. opponent.frost-thing-slow-vent). Authoring location for opponent templates is open question §8.1. |
state | ENUM | pending | active | resolved | retreated | abandoned. |
outcome | ENUM nullable | victory | defeat | voluntary_retreat | forced_retreat | null while in-progress. Mirrors simulator’s Outcome type. |
replayLog | JSONB | Array of turn records. See §2.3 below. |
createdAt | TIMESTAMPTZ | Encounter init. |
resolvedAt | TIMESTAMPTZ nullable | Set when state transitions to a terminal state. |
combat_turns — alternative shape: one row per turn. The Phase 13 default is JSONB-inline replayLog on the encounter row, not a separate table. Reasoning: typical encounter = 5–15 turns (energy budget bounds the upper bound per formulas.md §6); JSONB append is fast in Postgres; query patterns are encounter-bound, not turn-bound; a combat_turns table only earns its complexity if cross-encounter turn analytics emerge. Open follow-up §8 reserves the migration path if turn-level analytics become a Phase 14+ requirement.
If the combat_turns table is adopted (deferred), its shape is:
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key. |
encounterId | UUID | FK → combat_encounters.id. |
turnIndex | INT | 0-based index in the encounter. Both Walker and enemy actions on the same turn share turnIndex. |
actor | ENUM | walker | opponent. Matches simulator’s Side type. |
action | TEXT | Action type (attack only in Phase 13; expansion in §8.3). |
actionParams | JSONB | Action-specific parameters (empty for Phase 13’s attack action). |
roll_h | DOUBLE PRECISION | Hit roll. |
roll_v | DOUBLE PRECISION nullable | Variance roll (null on miss). |
roll_c | DOUBLE PRECISION nullable | Crit roll (null on miss). |
hit | BOOLEAN | |
crit | BOOLEAN | |
damage | INT | hp_delta. |
walkerHpAfter | INT | |
walkerEnergyAfter | INT | |
opponentHpAfter | INT | |
simulatorRngState | INT (uint32) | xorshift32 state after this turn’s draws are consumed — supports mid-encounter replay validation without rewinding from seed. |
createdAt | TIMESTAMPTZ |
2.3 Inline JSONB replayLog shape (Phase 13 default)
[ { "turnIndex": 0, "walker": { "action": "attack", "roll_h": 0.0058, "roll_v": 0.4474, "roll_c": 0.0381, "hit": true, "crit": true, "damage": 12, "walkerHpAfter": 50, "walkerEnergyAfter": 11, "opponentHpAfter": 6 }, "opponent": { "action": "attack", "roll_h": 0.4060, "roll_v": 0.7647, "roll_c": 0.1792, "hit": true, "crit": false, "damage": 3, "walkerHpAfter": 47, "walkerEnergyAfter": 11, "opponentHpAfter": 6 }, "rngStateAfterTurn": 3084751294 } // ... append per turn]The rngStateAfterTurn field is the load-bearing anti-cheat seam — server replay validates this against the simulator’s expected state after the turn. Mismatch in production posture = rejected turn; in mock-trust posture (ADR-0006) = warning log only.
3. Endpoint family
Four endpoints. All under /combat/encounter. All conform to ADR-0007 error envelope ({ error, message, details, requestId }) and Bearer auth.
3.1 POST /combat/encounter
Initialize an encounter from a quest beat.
Request:
{ "questId": "quest.002-frostlands-opening", "questStepIndex": 3, "opponentTemplateId": "opponent.frost-thing-slow-vent"}Alternative shape (deferred to §8.1): inline opponentTemplate payload for testing / authoring before opponent templates land in data/.
Server flow:
- Authenticate the Walker (Bearer JWT per ADR-0007).
- Validate quest is
activefor this Walker. - Validate
questStepIndexis the Walker’s current step (no future-step encounter trigger; no past-step retry). - Validate the quest’s step beat supports encounter trigger (Quest 002 step 3 does; arbitrary steps do not). Returns 422
INVALID_QUEST_BEATon mismatch. - Resolve opponent stats from
opponentTemplateId(data layer lookup). - Snapshot Walker’s 12 combat stats from current Walker state (level / tree allocations applied —
modifier compositionper formulas.md §3 last paragraph; backend imports from simulator). - Generate
seedserver-side (xorshift32-compatible uint32; Phase 13 implementation generates fromcrypto.randomInt(0, 0x100000000)or equivalent — the actual mechanism is a B-level call at 13-7 dispatch). - Persist
combat_encountersrow withstate = active,seed, both snapshots, emptyreplayLog. - Return:
{ "encounterId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "seed": 92, "walkerStats": { "hp": 50, "hp_max": 50, "energy": 12, "energy_max": 30, "damage": 10, "defense": 4, "accuracy": 0.80, "evasion": 0.10, "crit_chance": 0.05, "crit_mult": 1.5, "energy_cost_per_action": 1, "cold_resist": 0.0 }, "opponentStats": { "hp": 18, "hp_max": 18, "damage": 7, "defense": 2, "accuracy": 0.65, "evasion": 0.05, "crit_chance": 0.02, "crit_mult": 1.5 }, "opponentTemplateId": "opponent.frost-thing-slow-vent", "expectedTurnLimit": 30}expectedTurnLimit is informational — floor(walker.energy / walker.energy_cost_per_action) per formulas.md §6. The Walker cannot exceed this turn count before forced retreat.
Mobile usage of seed + snapshots: the Android client uses the seed and snapshots to run the simulator client-side for animation prediction (smooth UI feedback, no wait-for-server hop per action). The client still submits each Walker action server-side for canonical resolution. Anti-cheat design-in: the server is authoritative; the client’s prediction is purely cosmetic. If the client predicts wrong (because Walker abilities differ from what the client believes), the server’s /turn response corrects the local state.
3.2 POST /combat/encounter/:id/turn
Submit a Walker action.
Request:
{ "turnIndex": 0, "actionType": "attack", "actionParams": {}}turnIndex is strict-order required — the next expected turn. Out-of-order submissions return 422 TURN_OUT_OF_ORDER. This is essential for replay determinism: the simulator’s PRNG state is sequential; skipping a turn would desync the replay.
Server flow:
- Authenticate the Walker.
- Validate encounter exists and belongs to this Walker (404
ENCOUNTER_NOT_FOUNDotherwise). - Validate encounter
state = active(409ENCOUNTER_ALREADY_RESOLVEDif terminal). - Validate
turnIndex == replayLog.length(next expected). Otherwise 422TURN_OUT_OF_ORDER. - Load encounter snapshot + replayLog from DB.
- Replay simulator from
seedup to current turn using stored actions (deterministic by xorshift32). Ifcombat_turnstable adopted, usesimulatorRngStatefrom the last turn row to skip the replay overhead. - Apply submitted Walker action (
attackin Phase 13). Simulator computes:roll_h(next PRNG draw).- Hit/miss against
enemyEvasionper formulas.md §7. - If hit:
roll_v,roll_c, damage per formulas.md §4. - Walker energy decremented by
energy_cost_per_action.
- If opponent still alive, compute opponent counter-turn (Walker-first alternation per D-010 §2) using next three (or one, on miss) PRNG draws.
- Append turn record to
replayLog. UpdaterngStateAfterTurn. - Check exit conditions at top-of-next-turn (formulas.md §8, ordered): Walker hp ≤ 0 → mark for
defeat; energy = 0 → mark forforced_retreat; opponent hp ≤ 0 → mark forvictory. The encounter is NOT auto-resolved here. State staysactive; the client must call/resolveexplicitly to trigger downstream side-effects. The response indicates the terminal condition viaencounterState. - Return:
{ "turnIndex": 0, "walkerAction": { "action": "attack", "hit": true, "crit": true, "damage": 12, "roll_h": 0.0058, "roll_v": 0.4474, "roll_c": 0.0381 }, "opponentAction": { "action": "attack", "hit": true, "crit": false, "damage": 3, "roll_h": 0.4060, "roll_v": 0.7647, "roll_c": 0.1792 }, "walkerHpAfter": 47, "walkerEnergyAfter": 11, "opponentHpAfter": 6, "encounterState": "active", "nextRecommendation": null}encounterState values: active (loop continues), pending_resolve (terminal condition reached — client should call /resolve), pending_retreat (forced retreat on energy = 0 — client should call /retreat or equivalent). nextRecommendation is a forward-compat hint slot (defer to §8 — Phase 13 always returns null).
Anti-cheat note: Even under mock-trust ADR-0006 posture, the seed + replay design is the design-in foundation. The client cannot fabricate damage or hp deltas — the server replays from the canonical seed and overwrites whatever the client believes. This is the load-bearing piece: when ADR-0006’s AUTH_MODE flips from mock to firebase (post-cost-redesign migration), the same replay path activates strict validation. No code rewrite, no contract break.
3.3 POST /combat/encounter/:id/resolve
End the encounter explicitly. Emits downstream side-effects.
Request: empty body. (Outcome is determined by the server from the encounter’s terminal state; client cannot override.)
Server flow:
- Authenticate the Walker.
- Validate encounter exists + belongs to this Walker.
- Validate encounter is in a
pending_resolveshape (terminal condition reached but state stillactive) OR is already at a terminal state from a previous resolve (idempotent: 200 with prior outcome). - Determine outcome from final replay state:
- opponent hp ≤ 0 →
victory - Walker hp ≤ 0 →
defeat(Walker hp clamps to 1 per D-010 §3 — non-punitive)
- opponent hp ≤ 0 →
- Emit deltas:
- Quest progress delta. On
victory, the quest’s step beat advances per the quest’s step definition. Ondefeat, the step does NOT advance (quest stays at the same step; Walker can re-trigger). The actual step advancement is performed by calling the quest service internally (NOT by re-exposing/quest/step/advanceto the client at this point — server-internal call). - Tree-point delta — none from encounter. Per D-016, encounter outcome does NOT directly grant tree points. Tree points come from
POST /quest/complete(the +1pt per quest grant). Encounter is a step beat, not a standalone reward source. This is documented explicitly here to prevent ratchet drift: future implementers must not addtreePointsDeltato the encounter resolve response. - Faction-rep delta. Only if the quest beat specifies it. The encounter itself does not award rep; the quest’s step beat may (e.g. “defeating a Cech antagonist at this step costs Cech rep”). Resolved by the quest service internally.
- Quest progress delta. On
- Update
combat_encounters.state = resolved,outcome = victory|defeat,resolvedAt = now(). - Mark Walker’s hp to the final value from the encounter (clamped to 1 if defeat).
- Return:
{ "outcome": "victory", "questStateAfter": { "questId": "quest.002-frostlands-opening", "currentStepIndex": 4, "stepAdvancedDuringResolve": true }, "factionRepDeltas": [ { "factionId": "faction.unfinished-guild", "delta": 0 } ], "encounterSummary": { "outcome": "victory", "turns": 2, "walkerHpFinal": 47, "walkerEnergyFinal": 10, "opponentHpFinal": 0 }, "treePointsDelta": 0}treePointsDelta is explicitly present and explicitly 0 — making the D-016 separation visible on the wire. Tree points still come from /quest/complete, separately.
3.4 POST /combat/encounter/:id/retreat
Walker exits mid-encounter (D-010 §1 voluntary retreat — non-punitive).
Request: empty body.
Server flow:
- Authenticate the Walker.
- Validate encounter exists + belongs to this Walker.
- Validate encounter
state = active. - Mark encounter
state = retreated,outcome = voluntary_retreat,resolvedAt = now(). - Quest state does NOT advance. Step stays where it was.
- Tree-point grant: none (encounter does not grant tree points regardless of outcome per §3.3).
- Faction-rep penalty: none in Phase 13 per D-010 §3 (non-punitive). The endpoint signature reserves a
factionRepDeltasslot for future use — Phase 13 returns empty array. - Walker hp + energy keep their current values (no clamp, no penalty).
- Return:
{ "outcome": "voluntary_retreat", "questStateAfter": { "questId": "quest.002-frostlands-opening", "currentStepIndex": 3, "stepAdvancedDuringResolve": false }, "factionRepDeltas": [], "encounterSummary": { "outcome": "voluntary_retreat", "turns": 1, "walkerHpFinal": 47, "walkerEnergyFinal": 11, "opponentHpFinal": 6 }, "treePointsDelta": 0}Forced retreat (energy = 0) follows the same path internally but is triggered server-side at top-of-turn check in §3.2, not by client call. Outcome is forced_retreat. Same non-punitive consequences.
4. Simulator integration
The backend MUST IMPORT the simulator from the @walkrpg/data package (data/src/sim/combat.ts — already exposed; simulateEncounter, makePrng, WalkerSnapshot, EnemySnapshot, EncounterInput, EncounterResult, TurnRecord are public exports).
The backend MUST NOT re-implement combat math. This is a hard rule per phase-13-plan §9 risk mitigation (“Risk: Combat encounter API drift from simulator”).
The encounter endpoint is a thin wrapper over simulator + persistence:
- Load encounter row (seed + snapshots + replayLog) from DB.
- Construct an
EncounterInputfrom the stored snapshots. - Use the injected roll stream option (
input.rolls) of the simulator to replay deterministically: for the turns already inreplayLog, reconstruct the roll values from storedroll_h/roll_v/roll_cand feed them. For the new Walker action being submitted, advance the PRNG one step (or three, on hit) and capture the rolls. - Persist new turn state.
Alternative (cleaner): the simulator’s makePrng(seed) produces a stream that, given the same seed and the same draw sequence, is bit-identical. So the backend can re-construct the PRNG from seed, advance it by the number of floats already consumed by previous turns (tracked via rngStateAfterTurn if combat_turns table is adopted, OR by counting floats in the inline JSONB log), and then proceed. This avoids re-running the simulator’s main loop for already-resolved turns.
Determinism guarantee (per simulator file header): given (input, seed), simulateEncounter returns the same EncounterResult byte-for-byte. The backend’s replay path inherits this property. Anti-cheat reconciliation depends on it (D-007 §3 restrictive posture).
xorshift32 PRNG state is deterministic: simulator step N + state K = identical output regardless of when replayed. This is the anti-cheat foundation. Production-mode mismatch (client claims damage X, server-replayed simulator produces damage Y where Y ≠ X) = rejected turn, encounter aborted, anti-cheat log emitted.
5. Error envelope
Per ADR-0007 §7 error envelope ({ error, message, details, requestId }). Codes specific to this endpoint family:
| HTTP | error code | Trigger |
|---|---|---|
| 400 | VALIDATION_ERROR | Malformed request body (missing required fields, wrong types). |
| 401 | UNAUTHORIZED | Missing or invalid JWT (handled by ADR-0007 interceptor / NestJS guard). |
| 404 | ENCOUNTER_NOT_FOUND | Encounter id doesn’t exist OR doesn’t belong to the authenticated Walker. (Combined to prevent enumeration leak.) |
| 409 | ENCOUNTER_ALREADY_RESOLVED | Calling /turn or /retreat on an encounter in a terminal state. Calling /resolve on a terminal-state encounter is idempotent (returns 200 with prior outcome, NOT 409). |
| 422 | INVALID_QUEST_BEAT | Quest does not support encounter trigger at the requested step, OR Walker’s current quest step ≠ requested step. |
| 422 | TURN_OUT_OF_ORDER | turnIndex ≠ next expected turn. Details include the expected value: { "expectedTurnIndex": 5, "submittedTurnIndex": 3 }. |
| 422 | OPPONENT_TEMPLATE_NOT_FOUND | opponentTemplateId does not resolve in the data layer. Details include the submitted id. |
| 500 | SIMULATOR_REPLAY_MISMATCH | Server-internal: simulator replay produced a different result than the stored replayLog. This is a bug / data corruption signal, not a client error. Logged at error level. Should never reach the client in normal operation. |
All error responses carry requestId per ADR-0007 §7 for log correlation.
6. iOS port inheritance
Per phase-13-plan §9 risk register (“Risk: Android-iOS contract divergence”) and D-015 (iOS lands at Phase 15, inheriting validated Android contracts). The following items of this ADR are inherited verbatim by iOS:
| Item | Inherits verbatim |
|---|---|
| Wire contract (request bodies + response shapes) | Yes — same JSON keys, same value shapes. Swift Codable structs mirror Kotlin @Serializable data classes. |
| Endpoint paths | Yes — /combat/encounter, /combat/encounter/:id/turn, /combat/encounter/:id/resolve, /combat/encounter/:id/retreat. |
| Error envelope mapping | Yes — { error, message, details, requestId } per ADR-0007 §7, error codes from §5 above. |
| Seed + turn replay semantics | Yes — server is authoritative; client runs simulator (from @walkrpg/data JS via JavaScriptCore on iOS, OR re-implements xorshift32 + math natively in Swift — Phase 15 design call) for animation prediction; server canonical state corrects on each turn response. |
| Quest-beat → encounter trigger model | Yes — questId + questStepIndex + opponentTemplateId is the contract. |
| xorshift32 PRNG semantics | Yes — algorithm is platform-agnostic. Swift re-implementation must produce bit-identical output for the same seed (the same audit that the data layer’s simulator passes). |
| Turn-order rule (Walker-first alternation, D-010 §2) | Yes — server enforces. Client UI surfaces the rule. |
| Non-punitive defeat (D-010 §3) | Yes — clamp to 1, no XP/rep/streak loss. Same on iOS. |
treePointsDelta = 0 always (D-016 separation) | Yes — same wire value, same semantic. iOS UI must NOT show tree-point grants on encounter resolve. |
What CHANGES per platform:
| Item | Android | iOS (Phase 15) |
|---|---|---|
| Combat UI rendering | Kotlin + Jetpack Compose (11e-1 wireframe → Android implementation in 13-8) | Swift + SwiftUI (re-implements wireframe in Phase 15) |
| Network layer | Retrofit + OkHttp + kotlinx-serialization (ADR-0007 §1) | URLSession or Alamofire + Codable (Phase 15 ADR — TBD) |
| Simulator import for client-side prediction | @walkrpg/data via npm-published JS bundle consumed by Kotlin (if path exists) OR Kotlin re-implementation of xorshift32 + math | JavaScriptCore-hosted simulator OR Swift re-implementation |
| Bearer token storage | EncryptedSharedPreferences (ADR-0007 §3) | iOS Keychain (ADR-0007 §11) |
Inheritance count: 9 items inherit verbatim, 4 items change per platform.
7. Production migration path
When AUTH_MODE switches from mock to firebase (per ADR-0006 §Migration plan, post-VPS, post-cost-redesign):
- Encounter persistence: unchanged. Same tables, same JSONB shape, same lifecycle.
- Authentication header: the OkHttp / URLSession interceptor swaps Bearer source from “NestJS-minted HS256 JWT” to “Firebase ID token + App Check token attached as body fields on
/auth/callback, with the backend continuing to mint a session JWT to be carried in theAuthorizationheader” per ADR-0007 §12 (proxy model recommended). The encounter endpoints see no Authorization header change. - Anti-cheat layer activates. Mock-trust posture (ADR-0006) currently accepts client-submitted turns and replays them server-side without rejecting on mismatch (warning log only). Production posture: server replays from canonical seed, validates the simulator’s expected state matches the stored
rngStateAfterTurn, and rejects the turn (409ENCOUNTER_STATE_MISMATCH— new error code added at production migration) on any divergence. Client UI surfaces a “encounter resync” prompt — the encounter is closed withoutcome = abandoned, anti-cheat log emitted, Walker is NOT punished (per D-010 non-punitive principle). - Reconciliation worker integration: the production reconciliation worker (per ADR-0001 / ADR-0002) gains a “combat-replay-verify” job that runs async over recently-resolved encounters. If a replay produces a different outcome than was committed at
/resolvetime, the worker emits an anti-cheat metric (it does NOT retroactively void the encounter — the user-facing outcome stands; the signal feeds the manual review queue).
The design-in seed + replay model means this migration is additive, not a rewrite. The mock-trust path is the same code path; production adds strict validation around it.
8. Open questions deferred to 13-7 implementation
8.1 Opponent template authoring location
Where do opponent templates live in data/? Three candidates:
data/src/content/opponents/<id>.ts— parallels existing content patterns (classes, quests, NPCs).- Single file
data/src/content/combat-monsters.ts— flat registry. - Inline on quest steps — opponent stats embedded in the quest schema’s step beat.
Recommendation: (1). Parallels existing patterns; supports per-opponent narrative metadata (description, flavour text); allows opponents to be referenced across multiple quests / regions without duplication. Deferred to 13-7 dispatch where mechanics-designer + tech-architect resolve. Schema location: data/src/schemas/opponent.ts (new schema).
8.2 Multi-opponent encounters
Phase 13 ships solo enemies only. The endpoint shapes assume single opponent. Mass combat (encounter with 2+ opponents) is post-Phase-13. If added later, the contract evolves:
opponentStatsSnapshotbecomesopponentStatsSnapshots: Array<OpponentSnapshot>.- Turn shape adds opponent index for actions.
- Determinism still holds (PRNG draws are sequential across opponents on the same turn).
This is a forward-compatibility note; no breaking change required for Phase 13.
8.3 Player abilities / skill bar UI
Phase 13 ships a minimal action set: Attack, Defend, Use-Energy-Burst, Retreat. (Wireframe 11e-1 shows Attack + Retreat + a disabled “Use Item” — Phase 13 may collapse to those two; the spec leaves room for the four-action set.) Skill trees per class + ability binding + ability-tier progression = post-Phase-13. The endpoint’s actionType field is the extension point. New action types land as additive enum values; existing clients ignore unknown types via the nextRecommendation slot.
8.4 combat_turns table vs inline JSONB
Phase 13 default: inline JSONB on combat_encounters.replayLog. If turn-level analytics emerge (e.g. “what’s the win rate of node X’s mastery against frost-things?”), the combat_turns table earns its complexity. Migration is straightforward — JSONB log can be unpacked into rows in a single transactional script.
8.5 Encounter timeout / abandoned window
Phase 13: 7-day window of inactivity (matches D-009 §2 offline cap). After 7 days without further turn submission, the encounter transitions to abandoned. Configurable per environment. Reviewable at production migration.
8.6 Per-step encounter trigger metadata
How does the quest’s step beat declare it supports encounter trigger? Two options:
- A boolean field
step.triggersEncounter: bool+step.opponentTemplateId: stringon the quest step schema. - A discriminated union
step.beat: { kind: "encounter", opponentTemplateId } | { kind: "narrative" } | { kind: "factionRep", ... }.
Recommendation: (2) — aligns with how 13-5’s per-step beats field is shaping up. Deferred to 13-7 dispatch where mechanics-designer locks the schema.
9. Consequences
- Simulator becomes load-bearing for encounter resolution. The backend imports
@walkrpg/datasimulator and depends on its determinism. Any change to the simulator’s PRNG or math layer requires coordinated migration of in-flight encounters (or a hard cutover at deployment — Phase 13 default is hard cutover; in-flight encounters at deploy time get markedabandoned). - Anti-cheat design-in: seed + replay design works in both mock-trust (ADR-0006) and production-strict modes. The wire contract is the migration vehicle — same shapes, same endpoints, validation layer toggles via
AUTH_MODE(and a futureCOMBAT_REPLAY_MODEenv var at production migration time). - Encounter persistence adds 1 new table (with JSONB inline
replayLog) — small footprint.combat_turnstable is deferred. - D-016 separation surfaces on the wire.
treePointsDelta = 0is explicit; encounter does NOT grant tree points. Tree points flow through/quest/completeper D-016. Backend-engineer must NOT add tree-point side-effects to/combat/encounter/:id/resolve. - 13-7 implementation has a concrete contract to build against. Wire shapes, persistence model, lifecycle states, error codes, simulator integration rule are all locked here. 13-7 dispatch can ship without re-deriving design.
- 13-8 (Android combat UI) and 13-9 (encounter outcome → quest/tree/rep deltas) inherit the contract. Mobile-developer implements the wireframe against these exact endpoint shapes. The 13-9 wiring is the quest-service internal call described in §3.3 — no new endpoint surface, no contract addition.
- iOS port (Phase 15) inherits 9 items verbatim. §6 enumerates. Phase 15 does not redesign the encounter contract.
- Production migration cost stays bounded. §7 enumerates. The migration is additive (validation layer toggles), not a rewrite.
- Forward-compat slots reserved.
nextRecommendation,actionType, multi-opponent path,combat_turnstable — all enumerated in §8. Future evolution does not break Phase 13 clients.
10. Numbering note
ADR-0008 lands as the next free ADR slot after ADR-0007 (Android network layer, sub-phase 13-1). The VPS migration ADR (originally reserved at ADR-0006 §Migration plan as “ADR-0007”) defers to ADR-0009 or later at Phase 14 trigger per D-015. This re-allocation is logged in the decisions trail across ADR-0006 §Migration plan, ADR-0007 §9 Open questions, and here.
Open questions (CEO awareness, not blocking 13-7 dispatch)
- Opponent template authoring location (§8.1) — recommended
data/src/content/opponents/<id>.ts, defer to 13-7. - Per-step encounter trigger schema shape (§8.6) — recommended discriminated union on
step.beat, defer to 13-7. combat_turnstable adoption (§8.4) — recommended deferred (inline JSONB in Phase 13). Re-open if turn-level analytics emerge.COMBAT_REPLAY_MODEenv var design (§7) — production migration concern, not 13-7. Reserved.- Action set for Phase 13 (§8.3) — Attack + Retreat minimum; Defend + Use-Energy-Burst optional. mechanics-designer + ui-designer call at 13-8 dispatch.