Resolve an action into everything that happens because of it #38

Merged
icub3d merged 1 commit from 7-action-resolution into main 2026-09-13 18:28:42 +00:00
Owner

CLAUDE.md § Separation of Simulation and Presentation has been a sentence rather than a
boundary anything could be on one side of. This builds it.

resolve(&Battle, Action, &mut CombatRng) -> Result<Vec<Outcome>, Rejected>

An action goes in, a list of outcomes comes out, and nothing in between touches the world.

Why Battle is not the World

Battle is a snapshot: plain data plus Entity, which is an identifier rather than a
world. Resolution therefore runs in a test, on a server, or from a replay with no plugins
present — which is what the architectural bet was for. Taking it by reference also makes
"an illegal action leaves state untouched" a property of the signature rather than a
promise somebody has to keep.

The pieces

  • Action is a closed enum — Move, Attack, Wait. M3's effect primitives need the
    same discipline; this is the small version of that decision.
  • Outcome carries the whole path walked and the target's health afterwards, so
    playback (#9) replays rather than re-derives.
  • Rejected carries the numbers — how far out of range, how many points short —
    because greying out a control without saying why is only half of pillar 4.
  • forecast and resolve share one private check. A preview cannot promise what the
    act refuses; a test asserts they refuse the same things for the same reasons.
  • Battle::apply is the only statement of what an outcome does to state. The plugin
    resolves each pending action against the snapshot in submission order, applying outcomes
    as it goes — so two actions in one frame cannot both spend the same point — then mirrors
    the result onto components. It decides nothing.

The generator

CombatRng is threaded in rather than reached for, per § Determinism. It is a SplitMix64
written out in full rather than a dependency: rand does not promise the same stream
between major versions, and a replay that silently stops agreeing because a dependency
moved is a bug nobody would think to look for. This also avoids a new dependency, per
§ Working Agreements.

Nothing draws from it yet. Damage is a fixed number, which is what "no hidden dice the
player cannot reason about" reads as. It exists so M3's first roll has one tested place to
come from. Its own tests pin the stream with known answers, so if the algorithm ever
changes, a test says so.

Acceptance criteria

  • Pure function of (state, action, seed) — the replay test runs a four-action
    script twice and asserts the same outcomes, the same end state, and the same amount
    of stream spent. It also asserts at least one action succeeded, so a replay of
    nothing but refusals cannot pass vacuously.
  • Runs with no rendering plugins — every resolution test builds a Map and a
    Battle directly. No App.
  • Illegal actions rejected with a reason, state untouched — nine refusal cases,
    plus a test that resolves three different refusals and asserts the battle is byte-
    for-byte unchanged.
  • Pillar 4 preview exists — forecast returns the damage, what would be left, and
    whether it is lethal, and a test asserts those are the numbers resolve delivers.

Verification

cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test —
163 passing, up from 146. Seventeen on resolution, six on the generator.

ADR 0008

Records the boundary and its honest cost: two representations of unit state now exist.
They are built and written back inside one system so they cannot drift across frames, but
the write-back is mechanical code that must stay correct as unit state grows. That is the
thing to watch.

Nothing submits an action yet — that is #8.

Closes #7

🤖 Generated with Claude Code

https://claude.ai/code/session_01TA4hJHkRSU3XBxZYtMKXdh

`CLAUDE.md` § Separation of Simulation and Presentation has been a sentence rather than a boundary anything could be on one side of. This builds it. ``` resolve(&Battle, Action, &mut CombatRng) -> Result<Vec<Outcome>, Rejected> ``` An action goes in, a list of outcomes comes out, and nothing in between touches the world. ## Why `Battle` is not the `World` `Battle` is a snapshot: plain data plus `Entity`, which is an identifier rather than a world. Resolution therefore runs in a test, on a server, or from a replay with no plugins present — which is what the architectural bet was *for*. Taking it by reference also makes "an illegal action leaves state untouched" a property of the signature rather than a promise somebody has to keep. ## The pieces - **`Action`** is a closed enum — `Move`, `Attack`, `Wait`. M3's effect primitives need the same discipline; this is the small version of that decision. - **`Outcome`** carries the whole path walked and the target's health afterwards, so playback (#9) replays rather than re-derives. - **`Rejected`** carries the numbers — how far out of range, how many points short — because greying out a control without saying why is only half of pillar 4. - **`forecast` and `resolve` share one private `check`.** A preview cannot promise what the act refuses; a test asserts they refuse the same things for the same reasons. - **`Battle::apply`** is the only statement of what an outcome does to state. The plugin resolves each pending action against the snapshot in submission order, applying outcomes as it goes — so two actions in one frame cannot both spend the same point — then mirrors the result onto components. It decides nothing. ## The generator `CombatRng` is threaded in rather than reached for, per § Determinism. It is a SplitMix64 written out in full rather than a dependency: `rand` does not promise the same stream between major versions, and a replay that silently stops agreeing because a dependency moved is a bug nobody would think to look for. This also avoids a new dependency, per § Working Agreements. **Nothing draws from it yet.** Damage is a fixed number, which is what "no hidden dice the player cannot reason about" reads as. It exists so M3's first roll has one tested place to come from. Its own tests pin the stream with known answers, so if the algorithm ever changes, a test says so. ## Acceptance criteria - [x] **Pure function of (state, action, seed)** — the replay test runs a four-action script twice and asserts the same outcomes, the same end state, *and* the same amount of stream spent. It also asserts at least one action succeeded, so a replay of nothing but refusals cannot pass vacuously. - [x] **Runs with no rendering plugins** — every resolution test builds a `Map` and a `Battle` directly. No `App`. - [x] **Illegal actions rejected with a reason, state untouched** — nine refusal cases, plus a test that resolves three different refusals and asserts the battle is byte- for-byte unchanged. - [x] **Pillar 4 preview exists** — `forecast` returns the damage, what would be left, and whether it is lethal, and a test asserts those are the numbers `resolve` delivers. ## Verification `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` — **163 passing**, up from 146. Seventeen on resolution, six on the generator. ## ADR 0008 Records the boundary and its honest cost: two representations of unit state now exist. They are built and written back inside one system so they cannot drift across frames, but the write-back is mechanical code that must stay correct as unit state grows. That is the thing to watch. Nothing submits an action yet — that is #8. Closes #7 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01TA4hJHkRSU3XBxZYtMKXdh
`CLAUDE.md` § Separation of Simulation and Presentation has been a sentence
rather than a boundary anything could be on one side of. This builds it.

An action goes in, a list of outcomes comes out, and nothing in between touches
the world:

    resolve(&Battle, Action, &mut CombatRng) -> Result<Vec<Outcome>, Rejected>

`Battle` is a snapshot rather than the `World` — plain data plus `Entity`, which
is an identifier and not a world. Resolution therefore runs in a test, on a
server, or from a replay with no plugins present, which is what the
architectural bet was for. Taking the battle by reference also means "an illegal
action leaves state untouched" is a property of the signature rather than a
promise somebody has to keep.

`Action` is a closed enum — `Move`, `Attack`, `Wait`. M3's effect primitives
need the same discipline and this is the small version of that decision.

`Outcome` describes exactly what happened, including the whole path walked and
the target's health afterwards, so playback (#9) replays rather than re-derives.
`Rejected` carries the numbers — how far out of range, how many points short —
because greying out a control without saying why is only half of pillar 4.

`forecast` and `resolve` go through one private `check`, so a preview cannot
promise something the act refuses. Splitting them is exactly how an interface
comes to offer a move that bounces, and a test asserts they refuse the same
things for the same reasons.

`Battle::apply` is the only statement of what an outcome does to state. The
plugin builds a snapshot, resolves every pending action against it in submission
order — applying each outcome before the next action sees it, so two actions in
one frame cannot both spend the same point — and then mirrors the result onto
components. It decides nothing.

`CombatRng` is threaded in rather than reached for, per § Determinism. It is a
SplitMix64 written out in full rather than a dependency: `rand` does not promise
the same stream between major versions, and a replay that silently stops
agreeing because a dependency moved is a bug nobody would think to look for.
Nothing draws from it yet — damage is a fixed number, which is what "no hidden
dice the player cannot reason about" reads as — so it exists to give M3's first
roll one tested place to come from.

Seventeen tests on resolution and six on the generator, none of them needing an
`App`. The replay test asserts that the same battle, actions and seed give the
same outcomes, the same end state, and the same amount of stream spent.

ADR 0008 records the boundary. Its honest cost: two representations of unit
state now exist, built and written back inside one system so they cannot drift
across frames, but the write-back is mechanical code that has to stay correct as
unit state grows.

Nothing submits an action yet. That is #8.

Closes #7

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA4hJHkRSU3XBxZYtMKXdh
icub3d merged commit af3538355c into main 2026-09-13 18:28:42 +00:00
icub3d deleted branch 7-action-resolution 2026-09-13 18:28:43 +00:00
Sign in to join this conversation.
No description provided.