Skip to content

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 at data/src/sim/combat.ts.
  • Simulator (canonical): data/src/sim/combat.ts implements simulateEncounter(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/encounter endpoint 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
StateEntry triggerExit
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.
activePOST /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.
resolvedPOST /combat/encounter/:id/resolve after victory / defeat. Outcome recorded.Terminal.
retreatedPOST /combat/encounter/:id/retreat (voluntary) or server-side forced retreat on energy = 0.Terminal.
abandonedServer-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.

ColumnTypeNotes
idUUIDPrimary key.
walkerIdUUIDFK → walkers.id.
questIdTEXTQuest 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.)
questStepIndexINT1-indexed step within the quest that triggered this encounter. Matches the step-advance pattern from 13-5.
seedINT (uint32 on the wire; stored as int4)xorshift32 seed. Server-generated. Determines every roll in the encounter.
walkerStatsSnapshotJSONBFull 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).
opponentStatsSnapshotJSONB8-stat snapshot for the opponent: hp, hp_max, damage, defense, accuracy, evasion, crit_chance, crit_mult. (Opponents skip energy fields.)
opponentTemplateIdTEXTReference to the opponent template (e.g. opponent.frost-thing-slow-vent). Authoring location for opponent templates is open question §8.1.
stateENUMpending | active | resolved | retreated | abandoned.
outcomeENUM nullablevictory | defeat | voluntary_retreat | forced_retreat | null while in-progress. Mirrors simulator’s Outcome type.
replayLogJSONBArray of turn records. See §2.3 below.
createdAtTIMESTAMPTZEncounter init.
resolvedAtTIMESTAMPTZ nullableSet 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:

ColumnTypeNotes
idUUIDPrimary key.
encounterIdUUIDFK → combat_encounters.id.
turnIndexINT0-based index in the encounter. Both Walker and enemy actions on the same turn share turnIndex.
actorENUMwalker | opponent. Matches simulator’s Side type.
actionTEXTAction type (attack only in Phase 13; expansion in §8.3).
actionParamsJSONBAction-specific parameters (empty for Phase 13’s attack action).
roll_hDOUBLE PRECISIONHit roll.
roll_vDOUBLE PRECISION nullableVariance roll (null on miss).
roll_cDOUBLE PRECISION nullableCrit roll (null on miss).
hitBOOLEAN
critBOOLEAN
damageINThp_delta.
walkerHpAfterINT
walkerEnergyAfterINT
opponentHpAfterINT
simulatorRngStateINT (uint32)xorshift32 state after this turn’s draws are consumed — supports mid-encounter replay validation without rewinding from seed.
createdAtTIMESTAMPTZ

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:

  1. Authenticate the Walker (Bearer JWT per ADR-0007).
  2. Validate quest is active for this Walker.
  3. Validate questStepIndex is the Walker’s current step (no future-step encounter trigger; no past-step retry).
  4. Validate the quest’s step beat supports encounter trigger (Quest 002 step 3 does; arbitrary steps do not). Returns 422 INVALID_QUEST_BEAT on mismatch.
  5. Resolve opponent stats from opponentTemplateId (data layer lookup).
  6. Snapshot Walker’s 12 combat stats from current Walker state (level / tree allocations applied — modifier composition per formulas.md §3 last paragraph; backend imports from simulator).
  7. Generate seed server-side (xorshift32-compatible uint32; Phase 13 implementation generates from crypto.randomInt(0, 0x100000000) or equivalent — the actual mechanism is a B-level call at 13-7 dispatch).
  8. Persist combat_encounters row with state = active, seed, both snapshots, empty replayLog.
  9. 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:

  1. Authenticate the Walker.
  2. Validate encounter exists and belongs to this Walker (404 ENCOUNTER_NOT_FOUND otherwise).
  3. Validate encounter state = active (409 ENCOUNTER_ALREADY_RESOLVED if terminal).
  4. Validate turnIndex == replayLog.length (next expected). Otherwise 422 TURN_OUT_OF_ORDER.
  5. Load encounter snapshot + replayLog from DB.
  6. Replay simulator from seed up to current turn using stored actions (deterministic by xorshift32). If combat_turns table adopted, use simulatorRngState from the last turn row to skip the replay overhead.
  7. Apply submitted Walker action (attack in Phase 13). Simulator computes:
    • roll_h (next PRNG draw).
    • Hit/miss against enemyEvasion per formulas.md §7.
    • If hit: roll_v, roll_c, damage per formulas.md §4.
    • Walker energy decremented by energy_cost_per_action.
  8. If opponent still alive, compute opponent counter-turn (Walker-first alternation per D-010 §2) using next three (or one, on miss) PRNG draws.
  9. Append turn record to replayLog. Update rngStateAfterTurn.
  10. Check exit conditions at top-of-next-turn (formulas.md §8, ordered): Walker hp ≤ 0 → mark for defeat; energy = 0 → mark for forced_retreat; opponent hp ≤ 0 → mark for victory. The encounter is NOT auto-resolved here. State stays active; the client must call /resolve explicitly to trigger downstream side-effects. The response indicates the terminal condition via encounterState.
  11. 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:

  1. Authenticate the Walker.
  2. Validate encounter exists + belongs to this Walker.
  3. Validate encounter is in a pending_resolve shape (terminal condition reached but state still active) OR is already at a terminal state from a previous resolve (idempotent: 200 with prior outcome).
  4. 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)
  5. Emit deltas:
    • Quest progress delta. On victory, the quest’s step beat advances per the quest’s step definition. On defeat, 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/advance to 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 add treePointsDelta to 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.
  6. Update combat_encounters.state = resolved, outcome = victory|defeat, resolvedAt = now().
  7. Mark Walker’s hp to the final value from the encounter (clamped to 1 if defeat).
  8. 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:

  1. Authenticate the Walker.
  2. Validate encounter exists + belongs to this Walker.
  3. Validate encounter state = active.
  4. Mark encounter state = retreated, outcome = voluntary_retreat, resolvedAt = now().
  5. Quest state does NOT advance. Step stays where it was.
  6. Tree-point grant: none (encounter does not grant tree points regardless of outcome per §3.3).
  7. Faction-rep penalty: none in Phase 13 per D-010 §3 (non-punitive). The endpoint signature reserves a factionRepDeltas slot for future use — Phase 13 returns empty array.
  8. Walker hp + energy keep their current values (no clamp, no penalty).
  9. 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:

  1. Load encounter row (seed + snapshots + replayLog) from DB.
  2. Construct an EncounterInput from the stored snapshots.
  3. Use the injected roll stream option (input.rolls) of the simulator to replay deterministically: for the turns already in replayLog, reconstruct the roll values from stored roll_h/roll_v/roll_c and feed them. For the new Walker action being submitted, advance the PRNG one step (or three, on hit) and capture the rolls.
  4. 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:

HTTPerror codeTrigger
400VALIDATION_ERRORMalformed request body (missing required fields, wrong types).
401UNAUTHORIZEDMissing or invalid JWT (handled by ADR-0007 interceptor / NestJS guard).
404ENCOUNTER_NOT_FOUNDEncounter id doesn’t exist OR doesn’t belong to the authenticated Walker. (Combined to prevent enumeration leak.)
409ENCOUNTER_ALREADY_RESOLVEDCalling /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).
422INVALID_QUEST_BEATQuest does not support encounter trigger at the requested step, OR Walker’s current quest step ≠ requested step.
422TURN_OUT_OF_ORDERturnIndex ≠ next expected turn. Details include the expected value: { "expectedTurnIndex": 5, "submittedTurnIndex": 3 }.
422OPPONENT_TEMPLATE_NOT_FOUNDopponentTemplateId does not resolve in the data layer. Details include the submitted id.
500SIMULATOR_REPLAY_MISMATCHServer-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:

ItemInherits verbatim
Wire contract (request bodies + response shapes)Yes — same JSON keys, same value shapes. Swift Codable structs mirror Kotlin @Serializable data classes.
Endpoint pathsYes — /combat/encounter, /combat/encounter/:id/turn, /combat/encounter/:id/resolve, /combat/encounter/:id/retreat.
Error envelope mappingYes — { error, message, details, requestId } per ADR-0007 §7, error codes from §5 above.
Seed + turn replay semanticsYes — 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 modelYes — questId + questStepIndex + opponentTemplateId is the contract.
xorshift32 PRNG semanticsYes — 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:

ItemAndroidiOS (Phase 15)
Combat UI renderingKotlin + Jetpack Compose (11e-1 wireframe → Android implementation in 13-8)Swift + SwiftUI (re-implements wireframe in Phase 15)
Network layerRetrofit + 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 + mathJavaScriptCore-hosted simulator OR Swift re-implementation
Bearer token storageEncryptedSharedPreferences (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 the Authorization header” 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 (409 ENCOUNTER_STATE_MISMATCH — new error code added at production migration) on any divergence. Client UI surfaces a “encounter resync” prompt — the encounter is closed with outcome = 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 /resolve time, 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:

  1. data/src/content/opponents/<id>.ts — parallels existing content patterns (classes, quests, NPCs).
  2. Single file data/src/content/combat-monsters.ts — flat registry.
  3. 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:

  • opponentStatsSnapshot becomes opponentStatsSnapshots: 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:

  1. A boolean field step.triggersEncounter: bool + step.opponentTemplateId: string on the quest step schema.
  2. 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/data simulator 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 marked abandoned).
  • 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 future COMBAT_REPLAY_MODE env var at production migration time).
  • Encounter persistence adds 1 new table (with JSONB inline replayLog) — small footprint. combat_turns table is deferred.
  • D-016 separation surfaces on the wire. treePointsDelta = 0 is explicit; encounter does NOT grant tree points. Tree points flow through /quest/complete per 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_turns table — 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)

  1. Opponent template authoring location (§8.1) — recommended data/src/content/opponents/<id>.ts, defer to 13-7.
  2. Per-step encounter trigger schema shape (§8.6) — recommended discriminated union on step.beat, defer to 13-7.
  3. combat_turns table adoption (§8.4) — recommended deferred (inline JSONB in Phase 13). Re-open if turn-level analytics emerge.
  4. COMBAT_REPLAY_MODE env var design (§7) — production migration concern, not 13-7. Reserved.
  5. Action set for Phase 13 (§8.3) — Attack + Retreat minimum; Defend + Use-Energy-Burst optional. mechanics-designer + ui-designer call at 13-8 dispatch.