sap2: Tier 2 reaches parity - cascades, summon cells, the faint queue - #9
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
BEFORE_DEATH, then every body leaves, then everyDEATHfires. Four pets killed by one blast give fourDeathEarlyevents and then fourDeathevents, 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 itsDEATHresolves.[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.{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'sEmptyFrontdoes.The harness was wrong three times, in the same way
All three suites were asking a question the shipped game does not answer deterministically:
--survivorsdemanded 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.--deterministichad the identical flaw — excluding Ant/Mosquito/Spider does not make a board deterministic.--distcounted 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_cellis now the one place that mirror lives, with a standing guard against a second one.Verification
debug_resolve_battleis 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 raiseTypeError/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,BoardModelcarries bothPackandOpponentPack. 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
Sloth(~1 roll in 9,091) are measured and unimplemented.