preview_play (src/jackhammer/playground/exact_score.py) sets every key the engine's score_hand
reads directly, and protocol v2.1 repaired the four it had wrong. What it does not do is mirror
the live state faithfully enough for the other engine code that runs against it — specifically
_press_play, which fires Blind:press_play and, for The Hook, runs discard handlers against the
synthetic dict.
Three reproducible divergences, all on Hook boards, all at the current pin:
| board |
wrong scorer input |
preview total |
engine total |
| Hook + Mail-In Rebate + Bull |
money 4, not 14 |
24 |
44 |
| Hook + Castle |
no discard-time suit target |
16 |
19 |
| Hook destroys a negative Ramen + Stencil |
joker_slots 6, not 5 |
96 |
80 |
Two distinct causes:
- The synthetic dict is too thin for the discard path.
preview_play builds synth with four
keys (hand, discard_pile, dollars, jokers) before calling _press_play. The Hook's
discard triggers joker handlers that read state that dict does not carry, so effects that pay out
or retarget in the engine silently do nothing in the preview.
joker_slots is read from the pre-press_play live state. It is assigned after
_press_play but sourced from gs, so a joker destroyed during the press does not shrink it,
where in the engine the destruction handler decrements the real gs["joker_slots"] before
score_hand reads it.
Scope. Neither is new — both predate the published v1 numbers. Neither is reached by any
position the frozen 240-seed battery visits, which is why the differential gates in
tests/test_exact_score.py do not catch them and why no published number is affected. They matter
for an agent that meets a Hook board holding one of those jokers, and GreedyTactical ranks every
candidate play through this function, so the effect would be a silently mis-ranked play rather than
an error.
Suggested fix. Key-by-key reconstruction is the root cause; each new engine key that
_press_play or score_hand learns to read is a fresh instance of this bug. The durable shape is
to derive synth from the live state (a shallow copy with the objects scoring can mutate replaced
by defensive copies) rather than build it from an enumerated list, so an unmodelled key is inherited
rather than defaulted. That is a bigger change than v2.1 wanted to carry and wants its own
before/after differential over the battery.
Found by adversarial review (GPT-6-Astra) of #11.
preview_play(src/jackhammer/playground/exact_score.py) sets every key the engine'sscore_handreads directly, and protocol v2.1 repaired the four it had wrong. What it does not do is mirror
the live state faithfully enough for the other engine code that runs against it — specifically
_press_play, which firesBlind:press_playand, for The Hook, runs discard handlers against thesynthetic dict.
Three reproducible divergences, all on Hook boards, all at the current pin:
money4, not 14joker_slots6, not 5Two distinct causes:
preview_playbuildssynthwith fourkeys (
hand,discard_pile,dollars,jokers) before calling_press_play. The Hook'sdiscard triggers joker handlers that read state that dict does not carry, so effects that pay out
or retarget in the engine silently do nothing in the preview.
joker_slotsis read from the pre-press_playlive state. It is assigned after_press_playbut sourced fromgs, so a joker destroyed during the press does not shrink it,where in the engine the destruction handler decrements the real
gs["joker_slots"]beforescore_handreads it.Scope. Neither is new — both predate the published v1 numbers. Neither is reached by any
position the frozen 240-seed battery visits, which is why the differential gates in
tests/test_exact_score.pydo not catch them and why no published number is affected. They matterfor an agent that meets a Hook board holding one of those jokers, and
GreedyTacticalranks everycandidate play through this function, so the effect would be a silently mis-ranked play rather than
an error.
Suggested fix. Key-by-key reconstruction is the root cause; each new engine key that
_press_playorscore_handlearns to read is a fresh instance of this bug. The durable shape isto derive
synthfrom the live state (a shallow copy with the objects scoring can mutate replacedby defensive copies) rather than build it from an enumerated list, so an unmodelled key is inherited
rather than defaulted. That is a bigger change than v2.1 wanted to carry and wants its own
before/after differential over the battery.
Found by adversarial review (GPT-6-Astra) of #11.