Skip to content

sap2: Tier 2 reaches parity - cascades, summon cells, the faint queue - #9

Merged
pjsny merged 4 commits into
mainfrom
sap2-faint-cascade
Sep 18, 2026
Merged

pjsny merged 4 commits into
mainfrom
sap2-faint-cascade

Conversation

@pjsny

@pjsny pjsny commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Tier 2 is now behaviourally identical to the shipped build on every differential this project has, Spider excepted. Six measured rules, three harness bugs that were reporting green on the wrong question, and the first test here that crosses a phase boundary.

The rules

  1. A faint cascade is one batch, not a sequence. Everyone at ≤0 fires BEFORE_DEATH, then every body leaves, then every DEATH fires. Four pets killed by one blast give four DeathEarly events and then four Death events, never interleaved. This is what a summon count turns on: a level‑3 Rat lands three Dirty Rats because the corpses are gone by the time its DEATH resolves.
  2. The pending faints are a priority queue on attack, not a batch sorted once. A 5/1 Hedgehog against [Hedgehog 3/1, Flamingo 1/1, Otter 1/4] kills the Otter in 120 of 120 seeds; swap those two attacks and it lives in 120 of 120, because the 3‑attack Flamingo — not pending when the queue started — jumps the 1‑attack Hedgehog. Equal attacks: 0.518/0.482 over 600 seeds, i.e. the coin flip.
  3. A summon takes the cell the body vacated, and pushes what filled it. Three Dirty Rats each take the front cell and push; a Cricket's token then claims cell 3 — its own — and pushes the two behind it again: {1: RatToken, 2: RatToken, 3: CricketToken, 4: RatToken}. No rank arithmetic reproduces that, which is why the battle line now carries real cells and compacts once per exchange the way the engine's EmptyFront does.
  4. A body at ≤0 health is on the line but is not a legal target. A splash skips it, a positional finder steps over it without spending a target, and it cannot be healed back — a Crab taken to exactly 0 never casts.
  5. Insert slides forward, toward the front, and only slides back when the front is full.
  6. Faints resolve by identity, not by array index — resolving one can shift the other side's line (Rat summons onto the opponent's front), which had been leaving corpses fighting on at negative health.

The harness was wrong three times, in the same way

All three suites were asking a question the shipped game does not answer deterministically:

  • --survivors demanded an identical line-up from boards that are a coin flip in the real engine too (two simultaneous faints with equal attack). It now re‑resolves a mismatch over 200 fresh seeds in both engines and compares distributions.
  • --deterministic had the identical flaw — excluding Ant/Mosquito/Spider does not make a board deterministic.
  • --dist counted Spider's unimplemented summon as a divergence; it now names it under UNIMPLEMENTED so the worst delta is quoted over implemented rules only.

Plus: the differential probe had been replicating the battle driver rather than asking the env for the surviving line, so the suite spent a while comparing the shipped engine against a loop this env does not run; and the shop drivers mapped real grid cell i to sap2 slot i where the verified battle mapping mirrors, which is what hid the insert direction. battle.team_cell is now the one place that mirror lives, with a standing guard against a second one.

Verification

Check Result
Tier‑2 exact surviving line‑ups 6000/6000 (8 coin‑flip boards, compared as distributions)
Tier‑1 exact surviving line‑ups 300/300
Tier‑2 winners 1500 boards, 0 disagreements, 1 coin flip
Holed boards 200/200 Tier 1, 600/600 Tier 2
Scripted shop checks 26/26
Shop fuzz, turn 1 / turn 3 2997 + 1795 actions, no divergence
Shop→battle bridge 8 scripted + 55 random matches, no divergence
Random‑ability distributions worst delta 0.053, 4 boards named Spider
Env tests 72

debug_resolve_battle is new: the match API cannot express a named five‑pet board, and the battle half was the most‑measured and least‑tested part of the env. It is documented as not agent‑facing and range‑checks every field — 14 malformed input shapes raise TypeError/ValueError/OverflowError, none crashes.

Pack scope, measured

A pack is a table swap, not a rules change: nothing in the build keys a constant on Pack, and ten playable packs generate boards identical in every scalar field, differing only in the roll pool. Pack1 is not disjoint — four species and seven foods are shared, and a shared item is the same row. And the real ladder is cross‑pack: GetPossibleOpponents(Pack1) returns six packs, BoardModel carries both Pack and OpponentPack. This env is a symmetric two‑seat match where both seats roll one pool, so a cross‑pack pairing is not representable — recorded as a deliberate scope difference, alongside matchmaking itself.

Left

  • Spider summons nothing until Tier 3's roster exists. It is the only known gap in tiers 1–2.
  • The level‑up reward (two higher‑tier pets offered at 3 gold) and Sloth (~1 roll in 9,091) are measured and unimplemented.
  • RNG identity remains deliberate divergence: distributional equality, not seed‑identical replay.

Philip Snyder added 4 commits September 15, 2026 21:39
Two rules, both measured out of the shipped build's own RunBattle event
log and its PlayMinion grid (policy-clash-re-tools' sap/order_probe.py,
sap/diag.py and the new sap/difftest_match.py).

FAINT CASCADES. The engine does not resolve faints one at a time. It
resolves a BATCH:

  1. everyone currently at <=0 health fires BEFORE_DEATH, highest attack
     first with a coin flip on equal attack. Those effects can drop more
     pets - a Hedgehog's splash, or a chain into a second Hedgehog - and
     the newly dying join THIS batch rather than starting another;
  2. then every body in the batch leaves, all at once;
  3. then every one of them fires DEATH, in the order they were killed.

Four pets killed by one blast produce four DeathEarly events and then
four Death events, never interleaved. Step 3 after step 2 is what a
summon count turns on: a level-3 Rat lands three Dirty Rats because by
the time its DEATH resolves the corpses in that line are gone, where
resolving each faint end to end had the tokens arrive while a body still
held the cell and silently dropped one. That, plus the id-based removal
from the previous commit, took Tier-2 exact-survivor divergence from
86/200 boards to 1/300.

Two traps in it, both now commented where they bite: a before-death
effect that deals damage must not start a SECOND cascade over the same
pending bodies (SapBattle2.cascading), and the pass that runs after the
batch closes must be gated on the batch having taken something, or an
empty board recurses until the stack gives out.

INSERT DIRECTION. Dropping a pet onto an occupied position slides the
block FORWARD - toward the front of the line - and only slides it back
when the front is full. sap2 had it the other way round. This was
genuinely invisible until now: it is the only build-phase rule with a
direction, the harness's shop drivers mapped real grid cell i to sap2
slot i where the verified battle mapping is MIRRORED (the real grid fronts
at its highest cell, sap2 at slot 0), and with both sides wrong the
scripted insert checks agreed. It took a differential that carries a whole
build phase into a battle to catch it - the first test in this project
that crosses a phase boundary.

Verified together: 67 env tests, 26/26 scripted shop checks, 200 fuzz
episodes at turn 1 and 120 at turn 3 with no divergence, 300/300 Tier-1
exact surviving line-ups, 200/200 holed boards, 300/300 winners, the new
shop-to-battle bridge clean, distributions within 0.060.

Remaining: one Tier-2 board in 300 (a Hedgehog/Rat/Cricket cascade) and
Spider, which needs Tier 3's roster to summon from.
It asserted the sitting pet slid BACK, which is what sap2 used to do and
what the mirrored harness mapping made look right. Measured, the block
slides FORWARD unless the front is full, so the test now pins that and
also pins that nothing moved back when the front had room.

My fault for letting the previous commit through with this red: the
pytest exit code was swallowed by a pipe in the command I ran.
Measured via policy-clash-re-tools (order_probe's summon-slot section, and
the same result four ways in a battle log): killing a mid-line Cricket
outright leaves {5: Pig, 6: CricketToken, 7: Pig} - the token in cell 6,
exactly where the Cricket stood, with the survivors in front of and
behind it both untouched.

Tier 1 could not tell this apart from "the front": nothing there kills a
pet that is not already the front, so the vacated cell always WAS the
front. Hedgehog's splash is the first thing in this roster that separates
them, and this was the last Tier-2 battle divergence.

The cell has to be remembered before the batch is removed, because a
packed line has forgotten it by the time a deferred DEATH fires. So each
dying body records the nearest SURVIVOR ahead of it, by id rather than by
index - anything inserted ahead of that survivor in the meantime (Rat's
Dirty Rats land on the enemy front) carries the summon back with it - and
a token dropped by an earlier DEATH in the same batch shifts the later
ones one cell further back, which is what keeps two Crickets killed by
one blast coming home as {5: Token, 6: Token, 7: Pig}.

Tier-2 exact surviving line-ups: 300/300. With that, every battle and
shop differential this project has is clean:

  67 env tests
  26/26 scripted shop checks
  200 fuzz episodes at turn 1, 120 at turn 3 - no divergence
  300/300 Tier-1 exact surviving line-ups, 300/300 Tier-2
  300/300 battle winners, 200/200 holed boards
  shop-to-battle bridge: 8 scripted + 55 random matches, no divergence
  random-ability distributions within 0.060

Measured by Tier2Tail and ShopBattleBridge; committed here because the
env's rules file is mine to land.
Measured by Tier2Tail through policy-clash-re-tools, each with its
discriminating fixture quoted in the code:

1. A SUMMON TAKES THE VACATED CELL AND PUSHES whatever filled it. The
   previous commit got the cell but treated it as a rank; the push needs a
   real cell, so SapBattle2 now carries one per body and compacts once per
   exchange the way the engine's EmptyFront does. Proof board: three Dirty
   Rats each take the front cell and push, then a Cricket's token claims
   cell 3 - its own - and pushes the two behind it again, ending
   {1: RatToken, 2: RatToken, 3: CricketToken, 4: RatToken}. No rank
   arithmetic reproduces that.
2. THE PENDING FAINTS ARE A PRIORITY QUEUE ON ATTACK, not a batch sorted
   once. Discriminator: a 5/1 Hedgehog against [Hedgehog 3/1, Flamingo
   1/1, Otter 1/4] kills the Otter in 120 of 120 seeds because the
   3-attack Hedgehog splashes again before the newly-dropped 1-attack
   Flamingo gets its turn; swap those two attacks and the Otter lives in
   120 of 120, because the 3-attack Flamingo - not pending when the queue
   started - jumps the 1-attack Hedgehog. Equal attacks give 0.518/0.482
   over 600 seeds, which is the coin flip.
3. A BODY AT <=0 HEALTH IS ON THE LINE BUT IS NOT A LEGAL TARGET. Three
   fixtures: a splash skips it, a positional finder steps over it without
   spending one of its targets, and it cannot be healed back - a Crab
   taken to exactly 0 never casts, so the board wipes in all 120 seeds.
   This is the rule that will surprise the next reader, so it is spelled
   out where it lives.

Also: `debug_resolve_battle`, a module-level entry point that resolves one
battle from two explicit line-ups. The match API genuinely cannot express
a named five-pet board, and the battle half is the most-measured and
least-tested part of this env, so it gets an entry point rather than no
in-repo coverage. It is documented as not agent-facing and range-checks
every field - species, level, stats and perk all raise rather than read
past an array.

PACK SCOPE, measured via sap/pack_probe.py and recorded in sap2.h and
docs/envs/sap-v2.md rather than modelled:

- A pack is a TABLE SWAP, not a rules change: nothing in the build keys a
  constant on Pack, and ten playable packs generate boards identical in
  every scalar field, differing only in the roll pool. So Pack1-only is
  not hiding a constant.
- Pack1 is not a disjoint slice - four species and seven foods are shared
  with other packs, and a shared item is the same row in each.
- The real LADDER is cross-pack: GetPossibleOpponents(Pack1) returns six
  packs, and BoardModel carries both Pack and OpponentPack. This env is a
  symmetric two-seat match where both seats roll one pool, so a cross-pack
  pairing is not representable - the same deliberate scope difference as
  matchmaking itself.

Every suite green together: 72 env tests, 26/26 shop checks, 3000/3000
Tier-2 exact surviving line-ups (3 boards the shipped build itself decides
by coin flip, compared as distributions, worst gap 0.020), 300/300 Tier-1,
300/300 winners, 200/200 holed boards both tiers, 320 fuzz episodes across
turns 1 and 3, the shop-to-battle bridge clean, distributions within
0.053. Spider is the only known remaining gap.
@pjsny pjsny changed the title sap2: faint cascades are one batch, and insert slides forward sap2: Tier 2 reaches parity - cascades, summon cells, the faint queue Sep 16, 2026
@pjsny
pjsny merged commit d7aacfd into main Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant