Phase 13-9 design — combat outcome wiring
Phase 13-9 — combat outcome → quest/tree/rep deltas (design memo)
Date: 2026-05-20
Author: tech-architect
Status: Draft spec — implementation dispatched as separate work units.
Related canon: D-010 (combat resolution, non-punitive defeat), D-007 (faction-rep single writer), D-009 (offline 7-day cap), D-016 (progression — emergent level = treePointsSpent).
Related ADRs: ADR-0008 §2.2 (encounter persistence shape), ADR-0008 §3.3 (resolve flow + side-effects), ADR-0008 §6 (iOS port inheritance).
This memo is the spec phase. No code is written here. Implementation is dispatched in three following ticks per §6.
0. Sub-deliverable map
| # | Deliverable | Owner(s) | Artifact |
|---|---|---|---|
| 1 | walker.currentHp column + persistence | backend-engineer | Prisma migration, combat.service.resolveEncounter write, /walker/profile read field |
| 2 | Tree-modifier stat composition for walker snapshot | mechanics-designer (compose fn in data/) + backend-engineer (call site swap) | composeWalkerStats() in @walkrpg/data/sim; replaces hard-coded literals at combat.service.buildWalkerSnapshot |
| 3 | /resolve response enrichment for animation deltas | tech-architect (spec — this memo) + backend-engineer | new walkerBefore / walkerAfter / factionRepBefore shapes on ResolveEncounterResponseDto |
| 4 | Client-side xorshift32 for animation prediction | mobile-developer | Kotlin port of makePrng + simulateEncounter damage math + reconciliation layer |
1. walker.currentHp column + persistence
1.1 Current state
- Schema:
backend/prisma/schema.prisma:68-101—WalkerhastreePointsBanked,treePointsSpent,currentRegionId,classId,totalLifetimeSteps. NocurrentHpcolumn. Walker hp lives only insidecombat_encounters.walkerStatsSnapshotJSONB. - Compute site:
combat.service.ts:507-514:D-010 §3 clamp-to-1 is computed; write is a no-op.const finalWalkerHp =prismaOutcome === EncounterOutcome.defeat ? 1 : fullResult.finalState.walker_hp;// TODO (13-9 follow-up): persist finalWalkerHp to walker.currentHp when the column lands.void finalWalkerHp; - Read side:
GET /walker/profilereturnslevel,treePointsBanked,treePointsSpent— no hp. - Initial snapshot:
buildWalkerSnapshot(lines 673-707) returnshp = baseHp. The Walker is implicitly always at full hp at encounter init today.
1.2 Target state
A persisted currentHp integer on Walker. Max hp stays derived (from composeWalkerStats(...).hp_max, §2). Only currentHp is stored.
model Walker { // ... existing fields ... /// D-010 §3 — defeat clamps to 1, never below. Persisted at /resolve and /// /retreat. NULL = hp never initialized (new walker, or pre-13-9 backfill); /// read path resolves NULL to derived hp_max. currentHp Int?}- Column name:
currentHp(camelCase TS, snake-cased SQL via Prisma default). W-level locked — matches existingtreePointsSpentstyle. - Type:
Int?. Not@default(<N>): class baseHp varies once a second class lands;classIdis nullable until onboarding; class-aware backfill cannot run inside a Prisma migration. - Backfill: NULL for existing rows. Read path resolves
currentHp ?? composed.hp_max.
Write path — inside resolveEncounter’s $transaction (line 495), BEFORE quest advance: tx.walker.update({ where: {id: walkerId}, data: { currentHp: finalWalkerHp }}). finalWalkerHp is the already-clamped line 507 value. Retreat path persists last-turn walkerHpAfter — Walker walks away wounded if wounded (no reset, no penalty; D-010 §3).
Read path — buildWalkerSnapshot: const composed = composeWalkerStats(...); return { ...composed, hp: Math.min(walker.currentHp ?? composed.hp_max, composed.hp_max) }. The Math.min is the canonical clamp for the edge case where currentHp > hp_max (e.g. a node reduces max hp later); single-site, no double-write at allocation.
GET /walker/profile — adds currentHp + currentHpMax (both resolved, never null on wire). Mobile reads both for the HUD.
Out-of-combat hp recovery: none in 13-9. Walker hp persists across encounters. Future rest mechanics are post-13.
1.3 Contracts
| Owner | Deliverable |
|---|---|
| backend-engineer | Prisma migration add_walker_current_hp (Int NULL). |
| backend-engineer | Write currentHp inside resolveEncounter + retreatEncounter transactions. |
| backend-engineer | Update buildWalkerSnapshot per §1.2 (depends on §2 compose fn). |
| backend-engineer | Add currentHp + currentHpMax to GET /walker/profile; update wiki/.../api/walker-profile.mdx. |
| backend-engineer | Tests: (a) walker with currentHp = 12 → encounter walkerStatsSnapshot.hp = 12; (b) defeated walker → currentHp = 1 after /resolve; (c) retreated walker keeps last-turn hp. |
1.4 Open questions (B-level — recommendations stand)
- 1a — Rest endpoint? NO in 13-9. Future content-layer concern.
- 1b — Clamp-on-write at allocation time when
hp_maxshrinks? NO. Single canonical clamp at encounter init. - 1c — Expose
currentHpandhp_maxseparately on profile? YES (both). Mobile needs absolutes for HUD; ratio is mobile-side.
2. Tree-modifier stat composition for walker snapshot
2.1 Current state
- Snapshot site:
buildWalkerSnapshot(combat.service.ts:673-707) returns hard-coded 12-stat literals: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. Onlyhp+energyderive fromclasses.find(walker.classId).{baseHp,baseEnergy}. Thelevelvariable is computed and voided. - Schemas:
node.tscarriesmodifiers: z.array(ModifierSchema).min(1);ModifierSchema(common.ts:64) ={ stat: string, op: "add"|"mul"|"set", value: number, tags?: string[] }. No Phase 13 node touches a combat stat. All authored nodes (cluster.first-steps+ keystoneunshaken-step) modify step-economy stats (step_efficiency,streak_threshold). - Allocation table:
TreeAllocation(schema.prisma:228) — one row per allocated node, string FK todata/src/content/nodes/*, withrefundedAtfor the future refund path.
2.2 Target state
A pure compose function in @walkrpg/data/sim/compose.ts that mechanics-designer authors and back-end imports.
// data/src/sim/compose.ts (new file)import type { WalkerSnapshot } from "./combat.js";import type { Node, Class, Mastery } from "../schemas/index.js";
/** WalkerSnapshot extended with Walker-only tagged-resist stats. */export interface WalkerCombatSnapshot extends WalkerSnapshot { cold_resist: number; // future tag-resist stats attach here, not on WalkerSnapshot proper.}
/** * Pure: same input → same output, byte-identical. Order-independent over * allocated set (sorted-id iteration). Phase 13 is a pass-through for * combat stats (no authored node modifies a combat stat) but the function * MUST work — back-end stops hard-coding numbers and trusts the data layer. * * Composition order: * 1. Initialize stats from classBase (full 12-stat block per Q1). * 2. Apply node modifiers in (sorted-id, list-order-within-node) sequence. * Unknown stat names silently dropped (tree-only stats not on snapshot). * 3. Apply mastery effect modifiers in sorted-id order. Phase 13: empty array. * 4. Caps: pass-through (no per-stat balance caps in Phase 13). * 5. Clamp probability stats to [0,1] and crit_mult >= 1 (invariant-guard, * not balance logic; schema validator should catch upstream). * 6. Set hp_max = composed hp, energy_max = composed energy. Caller * substitutes Walker's current_hp before simulator call (§1.2). */export function composeWalkerStats( classBase: Class, allocatedNodes: ReadonlyArray<Node>, allocatedMasteries: ReadonlyArray<{ mastery: Mastery; effectIndex: number }>,): WalkerCombatSnapshot;Why this shape:
- Inputs are resolved objects, not IDs. Compose stays pure (no implicit registry access). Back-end does the lookup once and hands objects in. Test surface is trivial (mock nodes inline).
- No
levelparameter. D-016: level istreePointsSpent— a view, not a stat. Formulas.md §3 has no level term in the 12-stat dictionary. A future “+1 damage per 10 levels” effect embeds the math in the modifier or becomes a notable that ratchets a stat. WalkerCombatSnapshot extends WalkerSnapshot— the simulator’s frozenWalkerSnapshotdoesn’t carrycold_resist, but the back-end JSONB does today. The extension type formalizes the JSONB shape without changing the simulator interface.
Back-end integration — buildWalkerSnapshot rewritten: load classData, run prisma.treeAllocation.findMany({where: {walkerId, refundedAt: null}}), lookup nodes via mirror registry, call composeWalkerStats(classData, allocatedNodes, []), substitute currentHp per §1.2.
nodes adapter mirror — new entry in backend/src/common/game-content.ts (same pattern as existing opponents + quests mirrors from 13-7). KeystoneAllocation is NOT read — no authored keystone touches combat stats; signature grows a third array param when first one does.
2.3 Contracts
| Owner | Deliverable |
|---|---|
| mechanics-designer | Author data/src/sim/compose.ts + export from data/src/sim/index.ts. |
| mechanics-designer | Decide where “static fallback” combat stats live — extend ClassSchema (recommended, Q1) so Cartographer authors its full 12-stat block (currently only baseHp + baseEnergy). |
| mechanics-designer | Unit tests data/src/sim/compose.test.ts: (a) cartographer + empty allocations → static fallback snapshot matches current back-end literals; (b) synthetic node {stat: "damage", op: "add", value: 5} → damage = composed + 5; (c) two equivalent node sets in different array orders → byte-identical output. |
| backend-engineer | Rewrite buildWalkerSnapshot per §2.2. Drop the 12-stat literal. |
| backend-engineer | Mirror nodes in backend/src/common/game-content.ts (same shape as opponents mirror). |
| backend-engineer | Update existing combat tests that assert snapshot literals. |
2.4 Open questions (B-level)
- 2a — Static-fallback combat stats location? Recommend extend
ClassSchema(Q1 in §5). - 2b — Per-stat hard caps? NO in 13-9. Probability clamps are invariant-guards only.
- 2c — Combat-modifying keystones? Defer until first authored. Extend signature when needed.
- 2d — Add/mul ordering convention (sum-then-mul vs source-order)? Source-order in 13-9. Revisit if a real conflict surfaces.
3. /resolve response enrichment for animation feedback
3.1 Current state
ResolveEncounterResponseDto (combat.dto.ts:235): { outcome, questStateAfter: { questId, currentStepIndex, stepAdvancedDuringResolve }, factionRepDeltas: [{factionId, delta}], encounterSummary: { outcome, turns, walkerHpFinal, walkerEnergyFinal, opponentHpFinal }, treePointsDelta: 0 }.
Note on the dispatch brief: the brief asserts today’s response carries walkerXpDelta / walkerLevelAfter / loreUnlocked[]. These fields do not exist in code as of 2026-05-20. Per D-016 there is no XP concept, and level is the emergent view of treePointsSpent — it cannot move on encounter resolve. loreUnlocked[] is also absent; the quest schema’s loreUnlock field is not surfaced on the resolve wire today. This memo treats actual code as baseline. walkerXpDelta / walkerLevelAfter are NOT added in 13-9.
SubmitTurnResponseDto:162 carries per-turn deltas (hit/crit/damage/roll_* + post-turn hp/energy) — sufficient for per-turn animation (used in 13-8). No /turn enrichment needed.
The wire gap at /resolve: mobile knows the encounter’s FINAL state, but for outcome banners (+3 Cech rep toast, defeat thunk, tier-promotion celebration) it needs the BEFORE state too. Today mobile must remember “what did I show last” across screens.
3.2 Target state
Additive enrichment of ResolveEncounterResponseDto. Same wire path; no new endpoint.
// ResolveEncounterResponseDto v2 — additions marked NEW:
{ outcome: "victory" | "defeat", questStateAfter: { questId, currentStepIndex, stepAdvancedDuringResolve, previousStepIndex, // NEW — index BEFORE resolve advanced }, factionRepDeltas: [ { factionId, delta, reputationBefore, // NEW reputationAfter, // NEW (= before + delta) tierBefore, // NEW — tier index tierAfter, // NEW — animation hook for tier promotion }, ], encounterSummary: { outcome, turns, walkerHpFinal, walkerEnergyFinal, opponentHpFinal, walkerHpStart, // NEW — hp at /encounter init walkerHpDelta, // NEW — walkerHpFinal - walkerHpStart }, // NEW — walker progression block (always-zero for encounters per D-016): walkerProgression: { treePointsBankedBefore, treePointsBankedAfter, treePointsBankedDelta, // ALWAYS 0 for encounter — symmetry with /quest/complete levelBefore, // = treePointsSpent before (D-016 emergent view) levelAfter, // = treePointsSpent after (unchanged on encounter) }, // NEW — lore unlocks from encounter beat's loreUnlock field (victory only): loreUnlocked: [ { id: "lore.slug-here" } ], treePointsDelta: 0, // unchanged — D-016 invariant}Why this shape:
- Symmetry, not over-engineering. Each animated delta is a
(before, after, delta)triple. Mobile readsbeforefor animation start,afterfor end. No cross-screen client-state tracking. walkerProgressionis always-zero for encounters — a feature. Surfaces the D-016 contract on every encounter. A future regression that accidentally grants a point during combat surfaces astreePointsBankedDelta !== 0instantly. When/quest/completelands a parallelwalkerProgressionblock (future sub-phase), mobile reuses the same component for both.loreUnlockedreadsencounterBeat.loreUnlockon victory; empty on defeat/retreat. The id is the free-form slug; mobile-side lore-library dereference is a 13-5 follow-up (out of scope here).
Computation requirements — all reads are inside the existing $transaction, no new round trips:
treePointsBankedBefore/After,levelBefore/After→ pre/postwalker.findUnique(existing transaction read).factionReptriples → extendFactionRepService.applyDeltareturn shape to{ reputationBefore, reputationAfter, tierBefore, tierAfter }. Quest.service.ts:577-583 already callsapplyDelta; the signature change ripples cleanly (resolve uses the new return; quest-complete enrichment in a future sub-phase will too).walkerHpStart→combat_encounters.walkerStatsSnapshot.hp(already on the row).previousStepIndex→ captured at top ofresolveEncounterbefore quest advance.
Animation neutrality: the API stays animation-spec-free. Duration / easing / curve specs are ui-designer’s domain.
3.3 Contracts
| Owner | Deliverable |
|---|---|
| tech-architect (this memo) | Wire shape lock. |
| backend-engineer | Extend ResolveEncounterResponseDto (additive) + Swagger @ApiProperty. |
| backend-engineer | Extend FactionRepService.applyDelta return shape to { reputationBefore, reputationAfter, tierBefore, tierAfter }. Update both call sites (resolve + quest-complete). |
| backend-engineer | Compute walkerProgression + previousStepIndex + walkerHpStart inside the existing transaction. |
| backend-engineer | Wire loreUnlocked from encounterBeat.loreUnlock (victory only). |
| backend-engineer | Mirror enrichment on RetreatEncounterResponseDto for shape consistency — all deltas = 0, empty loreUnlocked and factionRepDeltas arrays. |
| backend-engineer | Tests: (a) defeat → walkerProgression.treePointsBankedDelta === 0; (b) victory on Quest 002 step 3 → factionRepDeltas[0] carries populated reputationBefore / tierAfter; (c) retreat → empty arrays + zero deltas. |
| ui-designer (out of scope) | Animation curves. NOT a 13-9 deliverable. |
3.4 Open questions (B-level)
- 3a — Retreat shape enrichment? YES — same shape, all-zeros / empty arrays. Mobile carries one vocabulary.
- 3b —
/turnbefore-snapshot enrichment? NO. Per-turn shape is already complete. - 3c —
walkerXpDelta/walkerLevelAfterraw fields? NO. D-016 invariant. The brief’s claim these exist today is incorrect.
4. Client-side xorshift32 for animation prediction
4.1 Current state
13-8 architecture: per-turn server roundtrip. Mobile shows a spinner during /turn flight (~50-200ms locally; more on VPS). No client-side simulator code. Seed is server-canonical and on-wire at POST /combat/encounter (ADR-0008 §3.1); walker + opponent snapshots also on-wire. Mobile has every input EXCEPT the simulator.
4.2 Target state
Kotlin port of xorshift32 PRNG + minimum-viable combat math, used for animation prediction. Server remains canonical; on mismatch, server wins.
Bit-identical PRNG contract — Kotlin must produce identical output to data/src/sim/combat.ts:77-92’s makePrng(seed) for any uint32 seed (including the seed-0-remapped-to-1 invariant).
class Xorshift32(seed: Int) { // Kotlin Int is signed 32-bit; use `ushr` for unsigned-right-shift. // Mask via `.toLong() and 0xFFFFFFFFL` before float conversion to avoid // signed-int negative values. private var state: Int = if (seed == 0) 1 else seed fun next(): Double { state = state xor (state shl 13) state = state xor (state.ushr(17)) state = state xor (state shl 5) val uint = state.toLong() and 0xFFFFFFFFL return uint.toDouble() / 4294967296.0 // 2^32 }}Contract test: read the seed-92 worked-example fixture (already used by CombatWorkedExampleTest in 13-8), produce N (>= 20) PRNG draws, assert double-identical match to JS simulator.
Math port — port hitChance (combat.ts:212) + computeDamage (combat.ts:226) verbatim — same constants (0.85, 0.30 variance window), same clamp (0.05/0.95 hit per formulas.md §7), Math.round half-up.
Reconciliation flow:
- User taps Attack on turn N.
- Mobile predicts turn N via
Xorshift32+ math port, animates immediately — no spinner. - Mobile fires
POST /combat/encounter/:id/turn. Server returns canonicalSubmitTurnResponseDto. - Mobile compares server vs predicted. MATCH (common): nothing. MISMATCH: let predicted animation finish, then play CORRECTION animation. Mismatch logged at debug level.
- Mobile advances local PRNG state to the server-expected post-turn state for the next prediction.
Server wins, always. Prediction is cosmetic. Anti-cheat unaffected: server never trusts client values; client uses prediction only to drop the spinner.
Edge cases:
- First-tap race — if server response arrives before predicted animation finishes, mobile lets the animation complete (no snap-update mid-tween) then reads server.
- Stale snapshot — always use the snapshot from
POST /combat/encounterfor the encounter session; don’t cache. - Retreat / D-010 defeat clamp — no prediction on retreat (no PRNG draws). On defeat: prediction may show hp going to 0;
/resolvereturnswalkerHpFinal === 1(clamped); mobile animates the clamp on top.
4.3 Contracts
| Owner | Deliverable |
|---|---|
| mobile-developer | Kotlin Xorshift32 class — bit-identical to TS makePrng. Test against seed-92 fixture. |
| mobile-developer | Kotlin port of hitChance + computeDamage. Test against seed-92 worked-example (extends CombatWorkedExampleTest). |
| mobile-developer | CombatViewModel prediction layer — predict → animate → fire turn → reconcile. |
| mobile-developer | Drop the per-turn spinner from 13-8. |
| mobile-developer | Test: prediction-server byte-identical for seed 92; correction animation plays on hand-tweaked mismatch fixture. |
| tech-architect (this memo) | Contract requirement: bit-identical PRNG, byte-identical math, server-wins reconciliation. |
| qa-engineer (post-13) | Prediction-mismatch rate metric (anti-cheat signal at production). Phase 14+ concern. |
4.4 Open questions (B-level)
- 4a — Predict opponent turns too? YES. Same flow, same reconciliation. Opponent draws follow walker draws sequentially in the PRNG.
- 4b — Non-
attackactions (Defend, Use-Energy-Burst)? Out of scope (not in Phase 13 action set per 13-8). When new actions land, prediction grows. - 4c — iOS port (Phase 15)? Same contract. Swift xorshift32 + math port. ADR-0008 §6.
- 4d — Numerical drift safety net? The seed-92 worked-example test catches
next()drift instantly. mobile-developer also authors a ~1000-draw stream comparison as additional defense.
5. Open questions for CEO (max 3, A/B/C)
Q1 — Where do “static fallback” combat stats live in data/?
Cartographer today defines only baseHp = 80 + baseEnergy = 120. The other 10 combat stats are hard-coded literals in combat.service.buildWalkerSnapshot.
- A. Extend
ClassSchemawith full 12-stat block. Cartographer authors complete numbers; future classes follow. - B. Keep
ClassSchemalean; other 10 stats are class-agnostic constants insidecomposeWalkerStats. Class differentiation from tree only. - C. Hybrid — class-resident offensive triple, constant defensive baseline.
Recommendation: A. baseHp + baseEnergy are already class-resident; making the rest class-resident gives mechanics-designer clean authoring surface when a second class lands. C adds cognitive load with no payoff.
Q2 — walker.currentHp nullability shape?
- A.
Int?nullable. NULL = “never initialized”; read path resolves to derivedhp_max. - B.
Int @default(1)non-null. Migration backfills existing rows to 1. - C.
Int?nullable +/walker/initialize-hpendpoint that one-shot sets on class pick.
Recommendation: A. Nullability honestly expresses “never been in combat”. B displays a fresh walker as 1/80 hp on first encounter — sub-canonical. C adds endpoint surface for no gain.
Q3 — /resolve enrichment scope?
- A. Full per §3.2 —
walkerProgressionblock (always-zero) + enrichedfactionRepDeltas[]triples +walkerHpStart/Delta+loreUnlocked[]. - B. Minimum — only
factionRepDeltas[]triples +loreUnlocked[]. SkipwalkerProgression. - C. Defer all wire enrichment; mobile tracks client-side. Revisit at
/quest/completeenrichment.
Recommendation: A. Mobile-side state tracking creates animation bugs. Wire cost is small (single tx read). The always-zero walkerProgression is a feature — surfaces D-016 invariant structurally; future regressions surface as treePointsBankedDelta !== 0 instantly.
6. Dispatch order (recommended)
- Tick 1 — mechanics-designer:
composeWalkerStatsindata/src/sim/compose.ts+ClassSchemaextension (per Q1 Option A) + unit tests. Pure data work; no back-end / mobile dependency. - Tick 2 — backend-engineer: in order within one tick:
- 2a. Prisma migration
add_walker_current_hp. - 2b. Adapter mirror
nodesinbackend/src/common/game-content.ts. - 2c. Rewrite
buildWalkerSnapshotto callcomposeWalkerStats+ readtreeAllocation+ readwalker.currentHp(§1 + §2). - 2d. Persist
walker.currentHpinresolveEncounter+retreatEncounter. - 2e. Extend
FactionRepService.applyDeltareturn shape. - 2f. Enrich
ResolveEncounterResponseDto+RetreatEncounterResponseDtoper §3.2. - 2g. Add
currentHp+currentHpMaxtoGET /walker/profile; update API mdx.
- 2a. Prisma migration
- Tick 3 — mobile-developer: Kotlin
Xorshift32+ math port + prediction layer + reconciliation + drop spinner (§4).
Tick 2 starts after Tick 1 commits. Tick 3 needs Tick 2’s wire-shape commits.
7. Deferred / NOT in 13-9 (appendix)
- Lore-library subsystem —
loreUnlockslug→content. Wire field added in 13-9; subsystem post-13. (13-5 follow-up #3.) - Animation curves spec — ui-designer; API stays animation-neutral.
/quest/completeparallel enrichment —applyDeltashape ext lands in 13-9; quest DTO plumbing is a separate sub-phase.- Per-stat hard caps — mechanics-designer call when first cap-relevant node lands.
- Mastery allocation persistence — no mastery touches combat stats in 13.
- Out-of-combat hp recovery / rest endpoints — content-layer mechanic post-13.
- Production-strict combat replay (
COMBAT_REPLAY_MODE=strict) — ADR-0008 §7, Phase 14+. - Combat-modifying keystones — extend compose signature when first one lands.
- Anti-cheat prediction-mismatch metric — qa-engineer post-13; Phase 14+ pipeline.
- Tree-allocation refund flow —
refundedAtalready filtered out by compose; refund path itself post-13.
8. Risks (mitigations inline)
nodesadapter mirror drift — mitigated by the existing 13-7 / 13-5 adapter-mirror pattern.composeWalkerStatsis pass-through in Phase 13 (no node touches combat stats) — synthetic add/mul/set test cases incompose.test.tsexercise the function with no real content.- Kotlin xorshift32 numerical drift across JVMs — seed-92 byte-for-byte fixture test catches drift instantly.
- Enrichment breaks old clients — all additions are additive; Kotlin DTOs are non-strict-by-default (ADR-0007 §1).
- Prediction-vs-server divergence rate — mobile defers to server; worst case is a brief correction animation.