- Status: draft
- Author: Marco (drafted by Claude)
- Created: 2026-07-29 (split from Combat Data Model per Codex review blocker #1)
- Design refs:
design/04 §2 - Depends on: Combat Shared Data & Arithmetic (parent — C1–C5 are normative here)
- Planning:
planning/combat-duel-engine/(once implementing)
The turn-based engine: 3v3, simultaneous commitment, no switching, stamina economy. Pure function (seed, teams, choice_lists) → battle_log in both runtimes; no I/O, no clock, no identity — the integration RFC wraps it.
Phases per round: commit → reveal → resolve → upkeep. Battle state: {round, sides: [{active_idx, pets: [{species_id, hp, stamina, status}]}]}. Status is a closed union {none, guarding, exhausted} (grows by RFC).
- Commit: each side commits one action for its active pet: closed union
action = {move: move_id} | {rest} | {forfeit}. Legality: move must be one of the species' four andstamina ≥ stamina_cost; an illegal commitment is a terminal engine error (illegal_action— callers validate first; the engine never repairs input). Zero-legal-moves ⇒ only{rest}is legal (Temtem overexertion: rest restoresstamina_max/2, floor). - Reveal/resolve: both actions resolve in
spdorder (tie → slot 0 first — deterministic, published). Damage per parent C3; crit chance from care-derived crit table, drawn on the"crit"substream; Obedience check (parent C5) on the"obedience"substream — a failed check converts the move to{rest}and logsdisobeyed. - Upkeep: faint check (hp 0 → next pet in team order auto-enters; no switching verb exists), stamina +1 to both actives, +1 more to chart-advantaged active (parent C1), round++.
- Terminal: all pets of a side fainted,
{forfeit}, or round = 100 (hard cap → winner = side with more surviving pets, then more total hp, then draw — draws are a real result).
round_started, committed(hash only until reveal), revealed, moved, disobeyed, damaged, fainted, entered, stamina_changed, guarded, rested, battle_ended{winner: 0|1|draw, reason} — closed; the log's state_hash per parent C4.
An async duel runs the same pure function; the opponent's choice_list is produced by a bot policy (bots RFC) over the defender snapshot: {team (species ids only), care_derived: {crit_table, obedience_inputs}} — no hidden state exists in a duel beyond uncommitted choices, so the snapshot is exactly the defender's team sheet. Snapshot schema versioned {v: 1, ...}, captured at challenge time.
- Golden replays: fixed
(seed, teams, choices)fixtures (≥ 6, covering crit, disobey, faint-cascade, rest-lock, round-cap draw, forfeit) byte-identical Go↔TS. - Coverage-move law reproduced: the parent's fixture roster shows no 100% matchup at the team level (property test over all 3v3 pairings).
- Illegal actions produce
illegal_actionterminally; no repair path exists (negative tests per union arm). - Round-cap tiebreak and draw path exercised by fixture.
- Fuzz: 10k random battles, zero invariant violations (hp/stamina bounds, monotonic round, terminal reached).
- The commitment hash (D2's
committed(hash only until reveal)):fnv1aover the C4 canonical serialization of the committed action — both constructions already exist in the parent; nothing new. Engine-level commitment is LOG-SHAPE ONLY: the pure function receives both choice lists as inputs, so there is no hidden information at this layer — commitment secrecy (challenge flows, live play) is the integration RFC's wrapper concern, stated here so nobody "fixes" the engine into holding secrets. - Choice-list exhaustion (RULED): the pure function consumes one action per side per round;
a
choice_listexhausted before the battle reaches a terminal is a terminal engine error (illegal_action), exactly like an illegal move — callers (bots, the wrapper) must supply enough actions; the engine never repairs input. A list longer than the battle is fine (excess ignored). Negative test per AC3. - Stamina saturation: upkeep gains and rest restoration saturate at the species'
stamina_max(catalog, parent C2) — stamina is bounded[0, stamina_max]and the AC5 fuzz invariant covers both bounds. - Snapshot v1 exact keys (D3, pinned):
{v: 1, team: [species_id × 3], care_derived: {crit_table: <parent C2 crit-table shape>, obedience_inputs: <parent C5 input shape>}}— byte-sorted keys, canonical serialization per C4; captured at challenge time; the shapes are the PARENT's catalog objects by reference, never re-declared here.
- Status-effect vocabulary beyond
guarding— content pass, grows the closed union by RFC.
- 2026-07-29: created from the four-way split; answers parent-review blocker #5 (duel half).
- 2026-08-06: non-normative reference cleanup for publication; no spec change.
- 2026-08-08: D4 pre-acceptance hardening — commitment-hash binding (log-shape only), choice-list exhaustion ruled terminal, stamina saturation bounds, snapshot v1 exact keys.
D4 closes several edges, but the duel cannot yet be implemented without inventing the parent catalog and most transition bytes.
The shipped Combat foundation owns arithmetic/RNG only. There is no balance/combat artifact,
strict loader, 12-species/24-move fixture, move-effect union, or Trust/Soul table. The parent RFC
and canonical docs explicitly say those pieces remain open DESIGN-GAPs.
Proposed contract: block this child on completing Combat Shared Data C2/C5 first. The parent must acceptance-review and land the strict Go/TS catalog, full fixture, exact effect union, input tables, artifact registration, and content hash before duel code starts.
(seed, teams, choice_lists) omits the pinned catalog bytes/hash/version and does not define team
instance state versus species definitions. D3's species-ID-only snapshot cannot by itself supply
HP, moves, stats, or the per-pet care inputs used during resolution.
Proposed contract: enumerate duel_input.v1 with exact keys for match seed, catalog identity,
two ordered teams of three instance rows, frozen care-derived inputs, and two choice lists. The
engine receives an already-validated immutable catalog object as a separate argument and asserts
the input catalog hash; no deploy-current lookup is permitted.
The list container, round association, canonical action field names, move-ID validation order, hash width/encoding, and whether rejected choices emit a partial committed event are unspecified. “fnv1a” is not enough to distinguish FNV-1a32/64 or text/binary output.
Proposed contract: define choice lists as exact arrays indexed by zero-based round, each action using the D1 closed exact-key union. Commitment is lowercase 16-hex FNV-1a64 over the parent's canonical action JSON bytes. Both commitments are appended slot 0 then slot 1 before either reveal; an invalid/exhausted choice returns a typed engine error and no battle log.
guard/utility effects are not declared by the parent, and D1 does not say when stamina is
debited, how guarding modifies damage, whether a slower action executes after its pet faints,
which target a move hits, or how simultaneous forfeits resolve. These choices change every golden
log.
Proposed contract: after the parent effect union is ruled, append one ordered resolution table covering validation, obedience, debit/rest conversion, priority/speed, target selection, guard, damage, faint cancellation, entry, dual forfeit, and upkeep. Every branch names its emitted event sequence; there is no implicit “usual turn-based behavior.”
A failed obedience check “converts the move to rest,” but it is unclear whether the original move
spends stamina, whether rest restores half stamina, and whether rested accompanies disobeyed.
Proposed contract: rule one literal arm. Recommended: obedience is checked before debit; failure
spends no move stamina, performs the normal saturating half-rest, and emits disobeyed then
rested then any stamina_changed. This preserves the stated conversion rather than creating a
third action type.
No event has exact keys, round/slot coordinates, before/after values, state-hash placement, or
ordering rules. battle_ended{winner,reason} leaves the reason union open. Byte-identical replay is
therefore untestable.
Proposed contract: enumerate every event payload and the terminal reason union, with one shared JSON fixture consumed by both loaders. Pin total ordering for commit/reveal, speed ties, multi-faint cascades, stamina changes, and terminal state hash. The outer C4 battle log exact keys remain unchanged.
crit_table and obedience_inputs are described as parent catalog objects “by reference,” but C5
defines tables over integer inputs and Pet Care currently exports only {pet_trust_ppm,soul}. The
snapshot also cannot carry only species IDs if it freezes care per pet.
Proposed contract: snapshot rows carry each pet instance ID/species ID plus the exact frozen two-integer Pet Care output; catalog policy tables remain solely in the pinned combat artifact. Delete duplicated tables from the snapshot. Bind identity/ownership and privacy to the later integration RFC, while the pure engine consumes this exact immutable shape.
“Terminal engine error” could mean a returned error, a terminal battle result, or a partial log. The 10k fuzz gate has no seed, generator domain, shrink corpus, or invariant list beyond three examples.
Proposed contract: return the closed union {ok:{log}} | {error:{category:"illegal_action", detail,round,slot}}, with errors producing no log. Pin one fuzz seed and exact generation bounds,
then enumerate HP/stamina bounds, active-index validity, one terminal event, event/state agreement,
round monotonicity, catalog immutability, and termination within 100 rounds.
- DU-C1 — accepted as proposed, and it GOVERNS the set: this child is BLOCKED on Combat Shared Data C2/C5 completion (the strict Go/TS catalog, 12-species/24-move fixture, exact effect union, input tables, artifact registration, content hash). No duel code before the parent lands and is designated-reviewed.
- DU-C2–DU-C8 — DEFERRED, not ruled. The proposals read sound, but ruling exact wire/byte contracts against a catalog that does not yet exist invites reconciliation churn; they will be ruled in one round when the parent's catalog bytes are real. Codex should carry them unchanged in the RFC as the standing blocker record. (Combat is post-playability; nothing on the critical path waits on this.)
- 2026-08-10: DU-C1 accepted (blocked on parent C2/C5); DU-C2–C8 deferred to a single ruling round once the parent catalog exists.