Skip to content

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

#DeliverableOwner(s)Artifact
1walker.currentHp column + persistencebackend-engineerPrisma migration, combat.service.resolveEncounter write, /walker/profile read field
2Tree-modifier stat composition for walker snapshotmechanics-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 deltastech-architect (spec — this memo) + backend-engineernew walkerBefore / walkerAfter / factionRepBefore shapes on ResolveEncounterResponseDto
4Client-side xorshift32 for animation predictionmobile-developerKotlin port of makePrng + simulateEncounter damage math + reconciliation layer

1. walker.currentHp column + persistence

1.1 Current state

  • Schema: backend/prisma/schema.prisma:68-101Walker has treePointsBanked, treePointsSpent, currentRegionId, classId, totalLifetimeSteps. No currentHp column. Walker hp lives only inside combat_encounters.walkerStatsSnapshot JSONB.
  • Compute site: combat.service.ts:507-514:
    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;
    D-010 §3 clamp-to-1 is computed; write is a no-op.
  • Read side: GET /walker/profile returns level, treePointsBanked, treePointsSpent — no hp.
  • Initial snapshot: buildWalkerSnapshot (lines 673-707) returns hp = 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 existing treePointsSpent style.
  • Type: Int?. Not @default(<N>): class baseHp varies once a second class lands; classId is 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 pathbuildWalkerSnapshot: 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

OwnerDeliverable
backend-engineerPrisma migration add_walker_current_hp (Int NULL).
backend-engineerWrite currentHp inside resolveEncounter + retreatEncounter transactions.
backend-engineerUpdate buildWalkerSnapshot per §1.2 (depends on §2 compose fn).
backend-engineerAdd currentHp + currentHpMax to GET /walker/profile; update wiki/.../api/walker-profile.mdx.
backend-engineerTests: (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_max shrinks? NO. Single canonical clamp at encounter init.
  • 1c — Expose currentHp and hp_max separately 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. Only hp + energy derive from classes.find(walker.classId).{baseHp,baseEnergy}. The level variable is computed and voided.
  • Schemas: node.ts carries modifiers: 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 + keystone unshaken-step) modify step-economy stats (step_efficiency, streak_threshold).
  • Allocation table: TreeAllocation (schema.prisma:228) — one row per allocated node, string FK to data/src/content/nodes/*, with refundedAt for 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 level parameter. D-016: level is treePointsSpent — 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 frozen WalkerSnapshot doesn’t carry cold_resist, but the back-end JSONB does today. The extension type formalizes the JSONB shape without changing the simulator interface.

Back-end integrationbuildWalkerSnapshot 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

OwnerDeliverable
mechanics-designerAuthor data/src/sim/compose.ts + export from data/src/sim/index.ts.
mechanics-designerDecide where “static fallback” combat stats live — extend ClassSchema (recommended, Q1) so Cartographer authors its full 12-stat block (currently only baseHp + baseEnergy).
mechanics-designerUnit 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-engineerRewrite buildWalkerSnapshot per §2.2. Drop the 12-stat literal.
backend-engineerMirror nodes in backend/src/common/game-content.ts (same shape as opponents mirror).
backend-engineerUpdate 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 reads before for animation start, after for end. No cross-screen client-state tracking.
  • walkerProgression is 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 as treePointsBankedDelta !== 0 instantly. When /quest/complete lands a parallel walkerProgression block (future sub-phase), mobile reuses the same component for both.
  • loreUnlocked reads encounterBeat.loreUnlock on 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/post walker.findUnique (existing transaction read).
  • factionRep triples → extend FactionRepService.applyDelta return shape to { reputationBefore, reputationAfter, tierBefore, tierAfter }. Quest.service.ts:577-583 already calls applyDelta; the signature change ripples cleanly (resolve uses the new return; quest-complete enrichment in a future sub-phase will too).
  • walkerHpStartcombat_encounters.walkerStatsSnapshot.hp (already on the row).
  • previousStepIndex → captured at top of resolveEncounter before quest advance.

Animation neutrality: the API stays animation-spec-free. Duration / easing / curve specs are ui-designer’s domain.

3.3 Contracts

OwnerDeliverable
tech-architect (this memo)Wire shape lock.
backend-engineerExtend ResolveEncounterResponseDto (additive) + Swagger @ApiProperty.
backend-engineerExtend FactionRepService.applyDelta return shape to { reputationBefore, reputationAfter, tierBefore, tierAfter }. Update both call sites (resolve + quest-complete).
backend-engineerCompute walkerProgression + previousStepIndex + walkerHpStart inside the existing transaction.
backend-engineerWire loreUnlocked from encounterBeat.loreUnlock (victory only).
backend-engineerMirror enrichment on RetreatEncounterResponseDto for shape consistency — all deltas = 0, empty loreUnlocked and factionRepDeltas arrays.
backend-engineerTests: (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 — /turn before-snapshot enrichment? NO. Per-turn shape is already complete.
  • 3c — walkerXpDelta / walkerLevelAfter raw 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:

  1. User taps Attack on turn N.
  2. Mobile predicts turn N via Xorshift32 + math port, animates immediately — no spinner.
  3. Mobile fires POST /combat/encounter/:id/turn. Server returns canonical SubmitTurnResponseDto.
  4. Mobile compares server vs predicted. MATCH (common): nothing. MISMATCH: let predicted animation finish, then play CORRECTION animation. Mismatch logged at debug level.
  5. 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/encounter for 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; /resolve returns walkerHpFinal === 1 (clamped); mobile animates the clamp on top.

4.3 Contracts

OwnerDeliverable
mobile-developerKotlin Xorshift32 class — bit-identical to TS makePrng. Test against seed-92 fixture.
mobile-developerKotlin port of hitChance + computeDamage. Test against seed-92 worked-example (extends CombatWorkedExampleTest).
mobile-developerCombatViewModel prediction layer — predict → animate → fire turn → reconcile.
mobile-developerDrop the per-turn spinner from 13-8.
mobile-developerTest: 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-attack actions (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 ClassSchema with full 12-stat block. Cartographer authors complete numbers; future classes follow.
  • B. Keep ClassSchema lean; other 10 stats are class-agnostic constants inside composeWalkerStats. 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 derived hp_max.
  • B. Int @default(1) non-null. Migration backfills existing rows to 1.
  • C. Int? nullable + /walker/initialize-hp endpoint 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 — walkerProgression block (always-zero) + enriched factionRepDeltas[] triples + walkerHpStart/Delta + loreUnlocked[].
  • B. Minimum — only factionRepDeltas[] triples + loreUnlocked[]. Skip walkerProgression.
  • C. Defer all wire enrichment; mobile tracks client-side. Revisit at /quest/complete enrichment.

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.


  1. Tick 1 — mechanics-designer: composeWalkerStats in data/src/sim/compose.ts + ClassSchema extension (per Q1 Option A) + unit tests. Pure data work; no back-end / mobile dependency.
  2. Tick 2 — backend-engineer: in order within one tick:
    • 2a. Prisma migration add_walker_current_hp.
    • 2b. Adapter mirror nodes in backend/src/common/game-content.ts.
    • 2c. Rewrite buildWalkerSnapshot to call composeWalkerStats + read treeAllocation + read walker.currentHp (§1 + §2).
    • 2d. Persist walker.currentHp in resolveEncounter + retreatEncounter.
    • 2e. Extend FactionRepService.applyDelta return shape.
    • 2f. Enrich ResolveEncounterResponseDto + RetreatEncounterResponseDto per §3.2.
    • 2g. Add currentHp + currentHpMax to GET /walker/profile; update API mdx.
  3. 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 subsystemloreUnlock slug→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/complete parallel enrichmentapplyDelta shape 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 flowrefundedAt already filtered out by compose; refund path itself post-13.

8. Risks (mitigations inline)

  • nodes adapter mirror drift — mitigated by the existing 13-7 / 13-5 adapter-mirror pattern.
  • composeWalkerStats is pass-through in Phase 13 (no node touches combat stats) — synthetic add/mul/set test cases in compose.test.ts exercise 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.