Status: approved, with round-3 and round-4 corrections (below) applied
after approval. GS10 slice 6, the public
Template named throughout docs/example-gacha-simulator.md (GS's
scope-revision section) and docs/parameter-activator.md §PA0/§PA8 as the
reason @parameter activator support (loop-state/4, PR #187→#188→#189, all
MERGED) was built. This document fixes the three zones' odds / cost / pity /
pickup-guarantee rules and the Monte Carlo comparison metrics before any
implementation. Per explicit instruction, this PR stops at design
confirmation; the Template's graph, Template-catalogue entry, and tests are
their own separate, later PR — exactly the design → implementation split
already used for CSU (docs/conditional-state-update.md → PR #181),
label-timing (docs/label-timing-authoring.md → PR #182/#183), and
@parameter itself (docs/parameter-activator.md → PR #188/#189).
Draft 2 (Hanrim/Lumi, round 1) held draft 1's approval and required four fixes before re-review:
- "Free" was mis-modelled as zero-cost. Draft 1 reused GS4's paid-economy
numbers verbatim for General/Free without saying what "free" spends. Lumi's
correction: free means "no cash / no paid currency required", not "no
simulated consumption" — the zone still spends an in-game currency or a
free ticket, one per pull, tracked exactly like the paid zones (GZ2). The
fairness basis across all three zones is now the same pull count
N, not a currency amount (currencies aren't even the same unit across zones, so comparing raw spend was never meaningful — GZ2, GZ6). - Cross-zone RNG independence was asserted, not verified. Checked
directly against
src/engine/rng.ts(loop-rng/1, frozen,SEMANTICS-B1.md§B1.2): there is no mutable PRNG stream in this engine at all — every draw issample(seed, step, elementId, purpose, drawIndex), a pure function keyed by that element's OWN id (GZ3.2) — strengthened with a citation and a new acceptance test, rather than weakened.
Draft 3 (Hanrim/Lumi, round 2) approved draft 2's direction on points 1/3/4 and the GZ-D5 removal outright, and required two more precise fixes before final approval — both applied below, no new engine feature needed:
- "Independence" overclaimed what was actually verified. A keyed, domain-separated deterministic function guarantees that adding, removing, or reordering another zone never perturbs this zone's own draw sequence — that is stream isolation, not a mathematical proof of statistical independence between the zones' outcome distributions. GZ3.2 (and every other place draft 2 said "RNG independence") is reworded to this precise claim. The proposed regression test is unchanged — it was already testing the right thing (stream isolation), only the prose calling it "independence" overreached.
- The fixed horizon needed an exact, testable contract, not a "safety
margin." GZ3.5 now pins
steps = pulls_per_zone + 1exactly (1 funding step, verified againststep.ts'sactOf(n) === 'onStart' && prev.step === 0— the onStart push commits during step 1, sobuy_pull_<zone>cannot pull until step 2, and GS1's own "at most one paid pull per step" rule means pulls occupy steps2..pulls_per_zone+1— exactlypulls_per_zonepull-steps), not "+1 funding step + a small safety margin." GZ8 gains five concrete horizon acceptance tests (GZ8 item 3), and GZ3.5 now explicitly distinguishes interactive Timeline playback (idles with no "ended" indicator — this Template never setsSimState.ended) from Monte Carlo (always completes at the fixed horizon, sincerunMonteCarlo/runRangenever depend onendedto stop — they just run the configuredstepscount). - Comparison Registers were conflated with Monte Carlo output.
loop-mc/1tracks Pools only (resolveTracked, verified) with no derived/ratio aggregation; a Register can never appear in a Monte Carlo distribution. GZ7 is now split into distributional Pool metrics vs. single-run display Registers, with the distinction stated explicitly rather than implied. - Draft 1 never named a termination rule, and a naive per-zone
Endwould have broken the whole Template. Checked directly againstsrc/engine/step.ts/src/engine/montecarlo.ts:SimState.endedis one global boolean, not per-node —runRangestops callingstep()entirely, for every node in the graph, the momentendedis set. A per-zoneEnd(mirroring GS2.3) would freeze the other two zones the instant the fastest zone finished. Fixed: this Template has zeroEndnodes; each zone just idles when its own ticket Pool empties (GZ3.5).
Also, per explicit direction, GZ-D5 (the "pulls to first X" latch Pools) is dropped from v1 rather than resolved — the simplest, fastest path forward (GZ9).
Round 3 (implementation finding, surfaced and fixed before continuing
implementation, per explicit direction to correct the doc rather than fold
a silent deviation into the implementation PR): GZ5's relay-Pool shape
(ssr_landed_pickup feeding two mutually-exclusive Gates) has a real bug —
a Pool's newly-received resource is not visible to that SAME Pool's own
outgoing pulls until the FOLLOWING step (verified by tracing an actual run),
so the last pull's pickup/standard split would not be resolved at the exact
pulls_per_zone + 1 horizon, breaking GZ8's own conservation check right at
the boundary round 2 scrutinized. GZ5 is rewritten: ticket_pickup
connects directly to four mutually-exclusive paths (one per pity-state ×
guarantee-state combination, AND-combining the existing pity activator pair
with a new guarantee activator pair on the SAME nodes — no new engine
capability), eliminating the relay Pool and its lag entirely while keeping
pulls_per_zone + 1 exact. Extending the horizon instead of fixing the
wiring was considered and explicitly rejected (GZ5.0): it would hide the bug
and leave the very last pull's outcome feeling delayed by a step. GZ8 gains
eight new acceptance tests for the four-path structure (item 5).
Round 4 (production finding, after #195 shipped — Hanrim noticed the step
counter climbing forever, 400+, with every ticket long spent): GZ3.5's
"zero End nodes" decision was correct about the FAILURE MODE it was
avoiding (a per-zone End freezing the other two zones) but wrong about the
fix — the Template never signals completion at all, which reads as broken to
a first-time viewer, not "idle by design." GZ3.5 is rewritten: exactly
ONE global End, gated by all three zones having actually produced
pulls_per_zone results (pulls_made_<zone> >= @pulls_per_zone, AND-combined
directly on the End node — no per-zone End, so the "freezes the others"
failure mode GZ3.5 originally identified still cannot occur). Two real
engine-mechanics findings surfaced and were verified directly against
step.ts before this rewrite, in order:
- A first attempt gated the
Endonticket_<zone> <= 0(rather thanpulls_made) and relayed the pulse throughSource → Pool → End. Both are wrong. ASource's outgoing edge must target a Pool —step.tsdrops any push to a non-Pool outright ("pushes to a non-Pool; ignored in Engine A"), soSource → Enddirectly is not constructible at all. Routing through an intermediate Pool doesn't fix that and adds a NEW bug: Pool pull-availability readsS[], the previous step's already-committed value (availOf(id) = S[id] - taken), so a Pool's OWN this-step arrival is invisible to anything pulling from it until the FOLLOWING step — the exactssr_landed_pickuplag GZ5 round 3 already found and fixed, recurring in a new spot.Source → Pool → Endwould therefore fire the globalEnda full step later than intended. ticket_<zone> <= 0is ALSO true before the Template ever starts — every ticket Pool'sinitialis0;fund_<zone>(anonStartSource) only commits its funding at the END of step 1 (GZ3.5's own horizon derivation). An activator readsS[source], the state as of the START of the step it gates — so at step 1,ticket_<zone> <= 0for all three zones is ALREADY satisfied, and gating the globalEndon ticket-emptiness would open it immediately, ending the Template before a single pull. Fixed by gating onpulls_made_<zone> >= @pulls_per_zoneinstead —pulls_madestarts at0and can only reachpulls_per_zoneafterpulls_per_zonereal pulls have actually resolved, so the gate cannot open before steppulls_per_zone + 2under any seed. This is also a strictly more correct completion signal than ticket-emptiness: it certifies each zone actually PRODUCED itsNresults, rather than merely that its funding is spent (a zone whose result-production were somehow blocked would not be miscounted as complete).
The corrected shape has no live pulse to relay at all: the global End
pulls from a small STANDING Pool (initial: 1, capacity: 1, untouched by
anything else in the graph) whose balance has been committed since long
before the gate could ever open — so the moment the AND-gate is first
satisfied, availOf already sees it, with no relay step in between. GZ8
gains four new acceptance tests (item 3): no premature termination at step
1, ended still false at pulls_per_zone + 1, ended becomes true at
exactly pulls_per_zone + 2, and every zone's pulls_made == pulls_per_zone
at that point.
Prefix GZ. Sections: GZ0 why a fixed-pull-count economy, not "first
SSR" · GZ1 scope · GZ2 the three zones (fixed names/rules/currency) ·
GZ3 structural decisions (one graph, RNG stream isolation, ONE global
End gated by all-zones-complete) ·
GZ4 engine mapping — pity · GZ5 engine mapping — pickup guarantee ·
GZ6 Parameter tables · GZ7 Monte Carlo comparison metrics · GZ8
verification · GZ9 decisions · GZ10 slices / work order.
docs/example-gacha-simulator.md (GS0) asks a single-banner question: how
many pulls until the first SSR, or does the budget run out first? That
framing makes a great single-banner narrative, but it is the wrong question
for a three-way comparison. "Pulls to first SSR" is itself the metric in
GS0's story; here the question is "given the same number of pulls, how do
three different rule sets change what a player actually gets?" — which
needs every zone to actually run the same N pulls to completion, not
stop the moment a Pool first crosses a threshold.
Decision (GZ-D1): each zone runs the SAME fixed pull count N every run
(no early stop at first SSR). Monte Carlo then reports, per zone, over that
fixed N: how many SSRs landed, how many of Zone 3's SSRs were the pickup,
and how often the pity ceiling actually triggered. This is the only framing
under which "General/Free has no pity" vs. "Premium Standard has an 80-pull
ceiling" is visible as a distribution shift over the same trial count,
not just a different stopping time — and, per draft 2's fix 1, it also
sidesteps ever needing to compare raw currency amounts across zones that
don't share a unit (GZ2).
This is a deliberate divergence from GS0/GS2.3's End-node design (got_ssr /
budget_exhausted), not an oversight — flagged here for explicit review
since it changes the narrative GS0 established for the single-banner case,
and it structurally REQUIRES no PER-ZONE End node exist anywhere in this
Template (SimState.ended is one global boolean — a per-zone End would
freeze the other two zones the moment the fastest one finished). A single
GLOBAL End, gated on all three zones' own completion together, is fine and
is what GZ3.5 (round 4) actually specifies.
In:
- Three zones in one bundled Template (not three separate Templates — GZ3), each a self-contained sub-graph with its own ticket/currency Pool, its own probability table, and (Zones 2–3) its own tunable hard-pity ceiling.
- Zone 3 additionally has a pickup-guarantee mechanic: within an SSR result, a probabilistic pickup/standard split, with a guarantee that the SSR immediately after a non-pickup SSR is pickup.
- A shared, neutral comparison frame with cross-zone, single-run display
Registers (GZ7.2) computed from the three zones' own Pools via
@refexpressions (already-shippeddocs/register-expression-authoring.md). - A fixed Monte Carlo tracked-Pool set (GZ7.1) and comparison-Register set (GZ7.2), locked in this document.
- Generic placeholder numbers only (GS8's naming/IP boundary applies identically here — no real banner's rates, costs, or names).
Out (this PR): the actual graph JSON, the Template-catalogue entry, KO/JA
label overlays, engine fixtures, e2e coverage, and any new engine primitive.
No new loop-state/N or loop-revision/N is introduced — GZ4/GZ5 use
only loop-model/2 (@param resource flow), loop-state/3 (CSU afterPull
labels), loop-state/4 (@parameter activators), Engine B probabilistic
Gates + the keyed loop-rng/1 draw function, loop-mc/1 Monte Carlo,
loop-revision/5 frames, and RXA Register expressions — all shipped. Also
out: pity_carry_group (shared cross-zone pity, explicitly deferred by
docs/parameter-activator.md §PA8), soft pity, duplicate-to-dust, live
external data, and (per draft 2, GZ-D5) the "pulls to first X" per-zone latch
metrics.
Names verbatim from the 2026-09-10 product decision
(docs/example-gacha-simulator.md scope-revision section):
| EN UI name (canonical) | KO | currency | rules |
|---|---|---|---|
| General / Free | 일반(무료) | free_ticket — in-game/free currency, no cash |
base rates, no pity (GZ-D2) |
| Premium Standard | 프리미엄 상시 | premium_ticket_standard — own Pool (GZ-D3) |
standing SSR pool, own tunable pity |
| Premium Pickup | 프리미엄 픽업 | premium_ticket_pickup — own Pool (GZ-D3) |
SSR rate + pickup rate + pity + pickup guarantee |
"Premium Standard" is the primary UI name; "Permanent" may appear only as secondary prose, never "Standing", per the original product decision.
Draft-2 correction on "free" (Lumi): "General/Free" does not mean the
simulation spends nothing — it means the player spends no cash / no paid
currency. Mechanically the zone still spends a real, tracked quantity
(free_ticket, an in-game currency or free-ticket Pool), exactly one unit
per pull, exactly like the two paid zones spend their own ticket. All three
zones are structurally identical funding-and-spend shapes (GZ3.4); only the
flavour (which currency, whether it costs cash) differs, and that
flavour difference lives entirely in the label text, never in the engine
wiring.
The three zones do NOT share a currency unit, so their raw spend amounts
are never directly comparable (a free_ticket and a premium_ticket_pickup
are different Pools with no declared exchange rate). The fairness basis for
comparison is instead the same pull count N for every zone (GZ0/GZ6) —
each zone's own ticket Pool is funded with exactly enough to make exactly N
pulls, so "how many SSRs out of N pulls" is directly comparable across
zones even though "how much currency was spent" is not.
Decision (GZ-D2): General/Free has no pity at all — it is GS0's original model, re-based onto the ticket economy (GZ3.4) with zero pity/pickup mechanics layered on, kept as the comparison's baseline/control. The alternative (a very generous but present ceiling) was considered and rejected: it would still need a Parameter and a pair of activators for a mechanic that contributes nothing to the comparison's story (a baseline should be the simplest zone, not a degenerate case of the others).
The three zones are not three separate Template files. They are three
independent, non-interacting sub-graphs inside one GraphDoc, visually
separated with loop-revision/5 frames (shipped, LGR Slice 5):
- General/Free — neutral / green frame.
- Premium Standard — violet frame.
- Premium Pickup — accent frame.
- The cross-zone comparison Registers (GZ7.2) — a separate neutral frame, not colour-matched to any zone (it is shared output, not a zone).
This lets a single seeded run or Monte Carlo pass simulate all three
zones simultaneously and produce per-zone results whose own draw sequences
are isolated from one another — domain-separated by id, not merely observed
side by side (GZ3.2 proves this, not just asserts it) — no "Scenario
Compare" feature is needed (that feature was deferred after v0.7.0 and
remains out of scope; nothing here depends on it). Frames are cosmetic-only
(loop-revision/5, no engine meaning), so this costs nothing beyond visual
organisation.
Draft 1 called this "RNG independence" without checking the engine's actual
randomness mechanism, and draft 2's verification, while correct on the
mechanism, kept that same overreaching word. The precise claim (Hanrim/Lumi,
round 2): each zone's random draws are domain-separated by id and
deterministic — adding, removing, or reordering another zone never perturbs
this zone's own draw sequence. That is stream isolation, a guarantee of
non-interference — it is NOT a mathematical proof of statistical
independence between the zones' outcome distributions, and this document
does not claim the latter. Checked directly, src/engine/rng.ts:
There is no PRNG object threaded through a run. Every random value is a pure total function of a key (seed, step, elementId, purpose, drawIndex).
Concretely (src/engine/step.ts): a random resource-flow edge draws via
sample(seed, curStep, e.id, purpose, i); a probabilistic Gate's branch pick
draws via sample(seed, curStep, g.id, 'gate-route', 0). Both keys embed
that specific edge's or Gate's own graph-authored id — never a running
counter shared across the step, never anything about how many other draws
happen elsewhere, and never anything about node visitation order. This is
loop-rng/1 (frozen, SEMANTICS-B1.md §B1.2).
Consequence, provable rather than assumed: for any two zones with
disjoint id sets (guaranteed by GZ3.4 — zones share no nodes), one zone's
entire sequence of random draws over a run is a pure function of (seed, step, that zone's own ids) and is therefore exactly unaffected by:
- whether any other zone exists in the graph at all;
- how many draws any other zone makes, this step or any step;
- the canonical (by-id) order nodes are visited in during a step.
This is stronger than "the three zones are observed together in one run" — it means each zone's own distribution is bit-for-bit identical to what that same zone, alone in its own graph, would have produced for the same seed. GZ8 adds this as a direct, checkable regression test (delete a zone, prove the other two are unaffected) rather than leaving it as an unverified design claim.
Even though Premium Standard and Premium Pickup are both "paid" in the
product's flavour text, each zone gets its own ticket Pool
(premium_ticket_standard, premium_ticket_pickup) — they do NOT draw from
one shared Pool. Sharing would couple the two zones' outcomes (spending in
one changes what's left for the other), which would confound the very
comparison this Template exists to produce. This mirrors
docs/parameter-activator.md §PA8's already-reviewed "separate pity Pools
per zone" decision — same reasoning, extended to currency (and, per GZ3.2,
sharing state would ALSO be the only thing that could break the RNG
stream-isolation proof above — Pools are read/write simulation state, unlike
a Parameter, so a shared Pool genuinely would let one zone's activity change
what the other zone's own Gates see as their inputs, even though it would
still not affect their draw KEYS).
Every zone reuses the same funding-and-spend shape, simplified from GS2.1 now that "1 unit = 1 pull" replaces GS0's arbitrary currency-per-pull ratio (draft 2 fix 1 — see GZ6 for why this simplification is safe):
fund_<zone>— anonStartSource.ticket_<zone>— a Pool, initially0.fund_<zone> → ticket_<zone>,flow = @pulls_per_zone(ONE Parameter shared read-only by all three zones' funding edges — GZ6 explains why sharing a Parameter here does not reintroduce GZ3.3's coupling concern: a Parameter is config data, never simulation state, and never appears in anysample()key).buy_pull_<zone>— apullAllConverter:ticket_<zone> → buy_pull_<zone>,flow = 1(a literal — "1 ticket per pull" is definitional, not a tunable knob);buy_pull_<zone> → roll_gate_<zone>(or the pity-gated pair, GZ4),flow = 1;buy_pull_<zone> → pulls_made_<zone>,flow = 1.
Since a ticket costs exactly one ticket, pulls_made_<zone> already IS the
zone's own complete spend record — no separate spent_total_<zone> Pool is
needed (a real simplification from GS2.1's separate spend-accounting edge,
safe here only because the ticket abstraction makes cost and pull count the
same number by construction — GZ9).
buy_pull_<zone> naturally stops firing once ticket_<zone> reaches 0
(the Converter's own pullAll all-or-nothing rule, SEMANTICS.md §9 — no
activator needed to gate it off). Per GZ-D1, nothing else stops it early
— it runs until the ticket Pool is exhausted, always after exactly N pulls
by construction.
Checked directly, src/engine/step.ts (if (k === 'end') ended = true) and
src/engine/montecarlo.ts's runRange (if (!ended) { st = step(...); if (st.ended) ended = true }, then no further step() calls that run):
SimState.ended is one global boolean for the entire graph, not
per-node. The instant ANY End node anywhere receives inflow, the WHOLE
simulation stops advancing — every zone, not just the one whose End fired.
A per-zone End (mirroring GS2.3's got_ssr / budget_exhausted, the
obvious naive translation of "this zone is done") would therefore freeze the
other two zones the moment the fastest zone finished its N pulls — exactly
backwards from "all three zones complete N pulls for comparison." This
failure mode is still avoided — see round 4's changelog note above — by
using exactly ONE End, gated on ALL three zones' own completion together,
never a per-zone one.
Round 4 correction (production finding — the original "zero End nodes"
design left the Template with no completion signal at all, which a
first-time viewer reads as broken, not idle-by-design: the step counter
climbs forever past pulls_per_zone + 1 with every ticket long spent).
The global End's activation is gated by three activators, AND-combined
directly on the End node itself (the same "multiple activators on one
target AND together" mechanism GZ4/GZ5 already use — no new engine
capability):
pulls_made_free >= @pulls_per_zone ─┐
pulls_made_standard >= @pulls_per_zone ─┼─ AND → global End
pulls_made_pickup >= @pulls_per_zone ─┘
pulls_made_<zone> >= @pulls_per_zone (not ticket_<zone> <= 0) is the
correct completion signal, verified for two independent reasons (both found
and fixed before this was written — see the round 4 changelog note):
ticket-emptiness is ALSO true before step 1 even funds anything (an
immediate false-positive termination), and pulls_made more precisely means
"this zone actually produced N results" rather than merely "this zone's
funding is spent."
The End pulls from a small standing Pool — all_zones_done_fuel
(name illustrative), initial: 1, capacity: 1, fed by nothing and drained by
nothing else in the graph. Its balance has been committed (via S[]) since
step 1, long before the AND-gate can ever open, so there is no live pulse to
relay and no Pool-arrival-lag to worry about (see the round 4 changelog
note's engine-verification for why a Source → Pool → End relay was
rejected instead).
The pull horizon is unchanged: pulls_per_zone + 1. Checked directly,
src/engine/step.ts: actOf(n) === 'onStart' && prev.step === 0 — an
onStart Source fires during the FIRST step() call (which advances state
from step 0 to step 1), so fund_<zone>'s push into ticket_<zone> commits
at the end of step 1. buy_pull_<zone> reads the step-START snapshot, so it
sees ticket_<zone> == 0 at step 1 and cannot pull yet; at step 2 it sees
step 1's committed ticket_<zone> == pulls_per_zone and pulling begins. Per
GS1's own "at most one paid pull per simulation step" rule, pulls occupy
steps 2 .. pulls_per_zone + 1 inclusive — exactly pulls_per_zone
pull-steps, and every zone's pulls_made reaches pulls_per_zone by the end
of step pulls_per_zone + 1.
The global End's own horizon is pulls_per_zone + 2 — one step later,
by construction. An activator reads S[source], the state as of the
START of the step it gates (verified directly, step.ts's activator-eval
loop: cmp(S[e.source] ?? 0, p.op, n)) — so the earliest step at which
pulls_made_<zone> >= pulls_per_zone can be OBSERVED true for all three
zones is the step immediately after they all reach it, i.e.
pulls_per_zone + 2. This is the exact, minimal termination horizon, not an
approximation — asserted as four concrete acceptance tests in GZ8 item 3.
SimState.ended is false for every step through pulls_per_zone + 1 and
becomes true at exactly pulls_per_zone + 2, for every seed. In ordinary
interactive Timeline playback, this means Play now auto-stops at
pulls_per_zone + 2, one step past the last real pull, instead of climbing
forever. Monte Carlo's own configured steps must be pulls_per_zone + 2
now (was + 1) — runMonteCarlo / runRange run the configured count
unconditionally and never consult ended mid-run to decide when to stop,
but the recommended config should match the Template's own real completion
point exactly, not stop one step short of it.
Directly the mechanism docs/parameter-activator.md §PA8 already reviewed
and approved as feasible, generalising GS10-3's fixed-literal proof
(src/engine/gacha-hard-pity.test.ts) with a per-zone Parameter in place of
the literal HARD_PITY:
-
pity_<zone>— a Pool, one per zone, starts at0. -
hard_pity_<zone>— a Parameter, one per zone (Zone 1 has neither). -
roll_gate_<zone>(probabilistic, the zone's own SSR/SR/R weights) andforced_ssr_<zone>(deterministic, one target: the zone's SSR result path) are BOTH fed byticket_<zone>'s roll token (viabuy_pull_<zone>), gated by a mutually-exclusive pair of activators onpity_<zone>:pity_<zone> → roll_gate_<zone>,< @hard_pity_<zone> - 1pity_<zone> → forced_ssr_<zone>,>= @hard_pity_<zone> - 1
(the
- 1is GS11-D1/CSU9-D2's off-by-one convention:pitycounts consecutive non-SSR pulls, so an N-pull ceiling forces on the pull wherepitywould otherwise reach N, i.e. compares againstN - 1; this is exactly what the Inspector's live-preview offset UI, PR #189, exists to make legible to whoever tuneshard_pity_<zone>). -
Every SSR result (natural, from
roll_gate_<zone>, or forced, fromforced_ssr_<zone>) resetspity_<zone>to0the SAME step via a CSUafterPulllabel(loop-state/3,timing: 'afterPull', when: 'source-fired',expr: '=0'); every non-SSR result increments it by1the same way (expr: '+1') — identical togacha-hard-pity.test.ts'sl_reset_natural/l_reset_forced/l_inc_sr/l_inc_redges, just with<zone>-suffixed node ids and a Parameter ceiling instead of a literal. -
forced_ssr_<zone>also feedsceiling_hits_<zone>(flow = 1), a plain cumulative Pool counting how many pulls in this run were pity-forced (GZ7.1) — trivial accounting, the same shape asssr_count/sr_count.
Zone 1 (General/Free) has no pity_free Pool, no hard_pity_free
Parameter, and no ceiling_hits_free Pool at all — it is a single
probabilistic roll_gate_free, nothing else (GZ-D2).
Corrected (round 3 — see the changelog above). Not covered by
docs/parameter-activator.md (which only reviewed the pity ceiling) — this
is this document's own contribution.
The first two drafts routed every SSR result into a relay Pool
(ssr_landed_pickup), which then fed two mutually-exclusive Gates gated by
missed_pickup_pickup, mirroring GZ4's pity_<zone> → {roll_gate, forced_ssr} shape one level downstream. That shape has a real bug, found
during implementation and confirmed by tracing an actual run: a Pool's
newly-received resource is not visible to that same Pool's own outgoing
pulls until the following step (the same "a router's push into a Pool
isn't visible to a further pull until next step" rule
docs/example-gacha-simulator.md's GS10-3 section already hit and designed
around for the pity mechanism). Concretely: an SSR landing in
ssr_landed_pickup at step t was only visible to
guarantee_gate_pickup / normal_split_gate_pickup at step t+1 — so at
the exact pulls_per_zone + 1 horizon, the LAST step's SSR (if any) would
not yet have a resolved pickup/standard split, breaking GZ8's own
conservation identity (pickup_count + standard_count == ssr_count) right
at the boundary GZ3.5's round-2 review specifically scrutinized.
The relay Pool could not simply be dropped, either: an activator gates a
node's enablement, not one specific outgoing edge, and a Gate's own
fan-out (unlike a Pool's) splits proportionally across ALL of its outgoing
edges without consulting whether a given target is activator-disabled
(verified directly, src/engine/step.ts's deterministic-Gate flow
computation) — so feeding guarantee_gate_pickup /
normal_split_gate_pickup straight from a Gate source (skipping the relay
Pool) would silently leak resource into whichever gate is disabled that
step, rather than routing it all to the enabled one. Extending the horizon
by one step instead of fixing the wiring was considered and rejected: it
would hide the bug rather than resolve it, and it leaves the LAST pull's
pickup/standard outcome unresolved for a full step — an awkward, delayed-
feeling result for the very last roll of a run, not just an internal digest
nicety.
ticket_pickup (the SAME funding Pool GZ3.4 already uses) connects
directly to four candidate paths, each gated by an AND-conjunction of
the SAME pity_pickup activator pair GZ4 already defines and a NEW pair on
missed_pickup_pickup — multiple activator edges on one target already
AND-combine (SEMANTICS-S.md §S6), so no new engine capability is needed:
| path | pity condition | guarantee condition | outcome |
|---|---|---|---|
roll_normal_open_pickup |
< @hard_pity_pickup - 1 |
< 1 (not owed) |
probabilistic 3-way: an SSR sub-branch, sr_hit_pickup (@w_sr_pickup), r_hit_pickup (@w_r_pickup) |
roll_normal_owed_pickup |
< @hard_pity_pickup - 1 |
>= 1 (owed) |
probabilistic 3-way: SSR sub-branch routes STRAIGHT to pickup_hit_pickup (@w_ssr_pickup), sr_hit_pickup, r_hit_pickup |
roll_forced_open_pickup |
>= @hard_pity_pickup - 1 |
< 1 (not owed) |
guaranteed SSR (the ceiling), still an ordinary probabilistic pickup/standard split |
roll_forced_owed_pickup |
>= @hard_pity_pickup - 1 |
>= 1 (owed) |
guaranteed SSR AND guaranteed pickup — a single deterministic route |
These four (pity-state × guarantee-state) conditions are mutually exclusive
and exhaustive (a clean 2×2 partition), so exactly one path is active
every step — this is now a direct, checkable acceptance property (GZ8),
not an assumption. missed_pickup_pickup — a Pool, values 0 or 1,
starts at 0 (an SSR's first-ever pickup roll is a fair, un-guaranteed
split — Decision GZ-D4, unchanged from round 1).
The two "not owed" paths still need an ordinary probabilistic pickup/
standard split (@w_pickup / @w_standard), since a miss is possible
there — this reuses a single shared 2-way split Gate, ssr_split_open_pickup
(deterministic pass-through from roll_forced_open_pickup's single output,
or the probabilistic SSR sub-branch of roll_normal_open_pickup; the two
sources are themselves mutually exclusive by the SAME pity condition, so
sharing one downstream split Gate is safe) — a pure Gate→Gate chain, no Pool
in between, so it resolves within the SAME step (roll_gate → ssr_hit → ssr_count's existing chain in GZ4 already proves multi-hop Gate chains
settle same-step; only a Pool hop introduces the lag GZ5.0 describes). The
two "owed" paths need no split at all — the SSR result routes straight to
pickup_hit_pickup.
Every path's SSR outcome funnels into one of two shared, single-output
deterministic Gates, pickup_hit_pickup / standard_hit_pickup (each may
have more than one upstream source, since at most one path is ever active
at a time — the same "several sources, always mutually exclusive" shape
pulls_made_<zone>'s four afterPull sources already use in GZ4); SR/R
results funnel into shared sr_hit_pickup / r_hit_pickup, fed by whichever
of the two "not owed"/"owed" NORMAL paths is active (the FORCED paths never
produce SR/R at all, by construction).
- Pity (
pity_pickup, GZ4's Pool):=0sourced frompickup_hit_pickupORstandard_hit_pickup(any SSR resets it, pickup or not);+1sourced fromsr_hit_pickupORr_hit_pickup. - Pulls made (
pulls_made_pickup):+1sourced from ALL FOUR shared hit Gates (pickup_hit_pickup,standard_hit_pickup,sr_hit_pickup,r_hit_pickup) — exactly one fires per step, so this is exactly+1per pull, never double-counted. - SSR count (
ssr_count_pickup):+1sourced frompickup_hit_pickupORstandard_hit_pickup. - Ceiling hits (
ceiling_hits_pickup, GZ4's Pool):+1sourced fromroll_forced_open_pickupORroll_forced_owed_pickupfiring (a probabilistic or deterministic Gate's ownfiredstatus is observable regardless of which branch it took, so this correctly counts "this pull was forced" without needing to know the pickup outcome). - The guarantee flag (
missed_pickup_pickup):=1sourced fromstandard_hit_pickup(a miss just happened — the next SSR is now owed);=0sourced frompickup_hit_pickup(the debt is paid). No label touches it whensr_hit_pickup/r_hit_pickupfire — the guarantee state persists across any number of SR/R pulls, exactly as intended (an owed guarantee is not forgotten while waiting for the next SSR).
This composes cleanly with GZ4's pity ceiling by construction — the four paths ARE the composition (pity-state × guarantee-state), not a separate mechanism layered on top of it.
Shared across all three zones (config data, read-only — sharing this
does NOT reintroduce GZ3.3's currency-coupling concern, since a Parameter is
never simulation state and never appears in a sample() RNG key, GZ3.2):
| key | value | notes |
|---|---|---|
pulls_per_zone |
200 | N — the fairness invariant (GZ0/GZ2); every zone's ticket_<zone> Pool is funded with exactly this many units |
Round 4 addition — the ONLY supported contract for pulls_per_zone is a
safe positive integer (N >= 1, Number.isSafeInteger(N)). The global
End's pulls_made_<zone> >= @pulls_per_zone gate (GZ3.5) is correct FOR
that contract; it is not a general-purpose validator, and the engine does
not itself restrict a Parameter's value to a safe positive integer. The
Parameter's own label now says so directly — Pulls per zone (whole number) / 존별 뽑기 횟수(정수) / ゾーンごとの抽選回数(整数) — a light
in-UI hint, not enforcement (no engine- or common-Inputs-UI-level validation
is added by this PR; that remains a possible follow-up).
Two out-of-contract inputs were traced directly against the engine and are pinned as regression tests, but neither is a supported feature, and neither should be described as one in product copy or UX — both are labelled as current DEFENSIVE behavior (i.e. what happens today with no engine-level guard against the input, not a designed response to it):
N = 0(out of contract: not>= 1) funds everyticket_<zone>with0, so no zone ever pulls andpulls_made_<zone>never leaves0— but0 >= 0is already true ATpulls_made_<zone>'s starting value, so the globalEnd's AND-gate is satisfied from step 1 onward (readingS[]from step 0, where everypulls_madeis already0): an immediate false-positive termination.- A non-integer
N(out of contract: not a safe integer — e.g.200.5) does NOT make a zone idle forever on an un-spendable fractional remainder, contrary to this addendum's own first-draft claim (corrected here after tracing the actual engine directly, rather than reasoning from the ticket-cost abstraction alone). A router's resource pull is satisfied by WHATEVER is available up to its want, not an exact match — the leftover0.5ticket still funds one MORE full pull, andafterPull's+1books a whole pull regardless of the fractional amount that actually moved. Sopulls_made_<zone>reachesceil(N), notfloor(N)—200.5silently becomes 201 real pulls, and the Template still terminates (atceil(N) + 2), just at a rounded-up pull count the Parameter's displayed value never admits to. This rounding-up is not a rounding feature to rely on or explain to a user as intentional — it is unguarded behavior for input outside the contract, pinned only so a future change can't silently make it worse without a test noticing. - A negative
Nis nonsensical under the ticket abstraction (GZ3.4) for the same reason a negative budget was never modelled in GS0; not separately pinned by a regression test.
GZ8 gains an explicit acceptance test pinning the SUPPORTED contract at a
second, smaller safe integer (so the test doesn't merely re-confirm the
shipped default) — that is the only one of these that counts as a GZ8
acceptance item. The two out-of-contract cases above are regression-pinned
separately (clearly labelled as defensive-behavior tests, not acceptance
criteria) so the wrong first-draft claim about non-integer input can never
silently creep back in, and so a 0 edit's step-1 false-positive stays a
known, tracked behavior rather than a rediscovered surprise. Extending
validation to the general engine or the common Inputs UI is explicitly out
of scope for this PR.
Per zone:
| zone | key | value | notes |
|---|---|---|---|
| General/Free | w_ssr_free:w_sr_free:w_r_free |
6:51:943 | SSR 0.6 %, GS4's original weights, unchanged |
| Premium Standard | w_ssr_standard:w_sr_standard:w_r_standard |
10:90:900 | SSR 1.0 % |
| Premium Standard | hard_pity_standard |
80 | ceiling well inside N = 200, so its effect on the tail is visible in Monte Carlo |
| Premium Pickup | w_ssr_pickup:w_sr_pickup:w_r_pickup |
10:90:900 | SSR 1.0 %, same as Standard (isolates the pickup mechanic's effect) |
| Premium Pickup | hard_pity_pickup |
80 | same ceiling as Standard (same reason) |
| Premium Pickup | w_pickup:w_standard |
50:50 | fair split before any guarantee applies |
Premium Standard and Premium Pickup deliberately share identical SSR rate/ceiling numbers: the ONLY variable between them is the pickup mechanic, so GZ7's comparison isolates its effect rather than conflating it with a rate difference. General/Free's roll weights are GS4's original values, unchanged, so it remains directly recognisable as "the same base game" with zero pity layered on — only its funding mechanism (GZ3.4) is now expressed as tickets rather than GS4's original currency amounts.
No pull_cost_<zone> or budget_<zone> Parameter exists. Under the
ticket abstraction (GZ3.4), one ticket costs one ticket by definition, so
"cost per pull" is not a tunable knob for this Template — pulls_per_zone
alone fixes both the funding amount and the trial count for every zone. If
a later revision wants zones to cost visibly different real-money amounts
per pull (a genuine product question, not modelled here), that is new scope
for whichever slice designs it, not a gap in this one.
These are the only quantities that can appear as a Monte Carlo distribution
(a histogram / mean / quantiles over many seeded runs) — loop-mc/1's
resolveTracked accepts Pool ids only (verified, src/engine/montecarlo.ts)
and there is no derived/ratio aggregation across tracked Pools.
| Pool | zones | meaning |
|---|---|---|
pulls_made_<zone> |
all 3 | always exactly pulls_per_zone at the end of every run (GZ3.4/GZ3.5) — a determinism sanity check, not itself a comparison metric |
ssr_count_<zone> |
all 3 | total SSRs landed over the N pulls — the headline pity-effect metric |
ceiling_hits_<zone> |
Zones 2–3 | how many of those SSRs were pity-forced rather than natural (GZ4) |
pickup_count_pickup / standard_count_pickup |
Zone 3 only | how many of Zone 3's SSRs were pickup vs. not — the headline guarantee-effect metric |
pulls_made_<zone> being deterministic (always = pulls_per_zone) is
expected and intentional under GZ-D1/GZ3.4 — it is tracked purely so a
Monte Carlo fixture can assert the whole Template ran to completion every
seed, not because its distribution is interesting.
Round 4 addition — the global End's standing fuel Pool is explicitly
EXCLUDED from this tracked set, from DEFAULT_TIMELINE_SERIES, and (being
neither a Parameter nor a Register) never appears in the Inputs or Summary
panels either. It is pure termination plumbing (GZ3.5) — always 1,
never changes, carries no comparison meaning — and tracking it would only
add a flat, meaningless line to the Timeline / Monte Carlo distribution
list, exactly the kind of noise GZ7 exists to keep out. The End node
itself is not a Pool and was never eligible for tracking regardless.
These compute a derived, single-current-run number for the Summary
panel (RXA @ref expressions, already shipped). They are display-only:
Registers store no per-step state, are never part of loop-mc/1's tracked
set, and can never appear in a Monte Carlo distribution. If a future need
arises for a distributional view of a derived ratio (e.g. "the
distribution of hit-rate across 2,000 seeds", not just its value on the run
currently loaded), that needs a new Monte Carlo capability this document
does not propose.
| Register | expression | format | zones |
|---|---|---|---|
hit_rate_<zone> |
@ssr_count_<zone> / @pulls_per_zone |
percent | all 3 |
pickup_rate_pickup |
@pickup_count_pickup / @pulls_per_zone |
percent | Zone 3 only |
spent/currency amounts are deliberately not given a cross-zone
comparison Register — GZ2 already established the three zones' currencies
share no unit, so "cost per SSR" would only ever be meaningful within one
zone's own currency, and (per GZ3.4) it is trivially 1 / hit_rate in
tickets, not a separate quantity worth its own Register.
Round 4 correction (Hanrim, 2026-09-13, live-preview review of the
implementation PR): the 4th Register was originally specified as
pickup_share_pickup = @pickup_count_pickup / @ssr_count_pickup — Zone 3's
share of its own SSRs that were pickup. That divides 0 / 0 the instant
the Template is opened, before any pull has happened, surfacing as a visible
error badge with no user action taken — this document's own "already a
defined, handled Register state" claim for that case was wrong; a 0 / 0
evaluate-error is a defined state, but it is not a well-formed value to
show a first-time viewer at rest. Fixed to pickup_rate_pickup = @pickup_count_pickup / @pulls_per_zone — pickup rate per pull, not per
SSR. pulls_per_zone is a non-zero constant, so this can never divide by
zero. This changes what the number means (a rarer, lower rate than "share of
SSRs"), not just its formula; "share of SSRs" has no well-defined value at
rest, so it is dropped rather than patched. hit_rate_<zone> was never
affected — it was already / pulls_per_zone, well-defined (0) on a
zero-SSR seed, exactly as this section already described.
- Zone independence, structural. No resource or state edge crosses
between any two zones' node sets (Pools, Parameters, Gates — nothing
shared except the read-only
pulls_per_zoneParameter, GZ6). - Zone RNG stream isolation (GZ3.2). Deleting any one zone's nodes and
edges leaves the other two zones' per-step Pool values byte-identical,
same seed, same step count — a direct, checkable regression test of the
loop-rng/1keyed-draw property. This tests stream isolation (draws are domain-separated by id); it is not, and is not claimed to be, a proof of statistical independence between the zones' outcome distributions. - The fixed horizon's exact contract (GZ3.5, round 4). At
steps = pulls_per_zone + 1, for every seed:pulls_made_<zone> == pulls_per_zonefor all three zones;ticket_<zone>is exactly0for all three zones;- all three zones produced exactly
pulls_per_zoneroll results each (implied by the conservation identity, item 6, but asserted here as its own explicit horizon check); SimState.endedis stillfalse(the globalEnd's own AND-gate readsS[]from the START of this step, i.e. the PREVIOUS step's values —pulls_madeonly just reachedpulls_per_zoneAT this step, so the gate cannot yet observe it satisfied). Atsteps = pulls_per_zone + 2— one step later, exactly:SimState.endedistrue, for every seed (the AND-gate now reads last step'spulls_made_<zone> == pulls_per_zonefor all three zones);- every zone's
pulls_made/ticket/ roll-result values are UNCHANGED frompulls_per_zone + 1(theEndfiring stops the run; it moves no resource in any zone); - running the simulation for additional steps past
pulls_per_zone + 2changes no Pool value and produces no further event, for any zone (idempotent stability — nothing is still "in flight"). - no premature termination: at every step
1..pulls_per_zone,SimState.endedisfalse, for any seed — in particular NOT at step 1 (theticket_<zone> <= 0false-positive round 4 found and rejected would have opened the gate here, before any pull). In ordinary interactive Timeline playback, Play now auto-stops atpulls_per_zone + 2— one step past the last real pull — instead of advancing forever. Monte Carlo's ownstepsconfig must bepulls_per_zone + 2to match (GZ7.1'srecommendedRunConfig); a Monte Carlo run always completes regardless, sincerunMonteCarlo/runRangerun the configuredstepscount directly and never consultendedmid-run to decide when to stop — but astepsvalue short ofpulls_per_zone + 2would never let the trackedendeddistribution reflect the Template's real completion point.
- Pity ceiling holds per zone, each with its OWN
hard_pity_<zone>Parameter, using GZ4's exact acceptance shape from GS9's item set (gap never exceeds ceiling; ceiling pull is guaranteed SSR; pity resets same-step on both natural and forced SSR; pity increments only on non-SSR) — run independently against Zone 2 and Zone 3. - The four-path pickup-guarantee structure holds (GZ5.1/GZ5.2), round 3.
For every seed, for every pull-bearing step (steps `2..pulls_per_zone
- 1`, GZ3.5 — the funding step and any step beyond the horizon fire nothing at all, so a bare "every step" would be contractually false):
- exactly one of the four paths (
roll_normal_open_pickup,roll_normal_owed_pickup,roll_forced_open_pickup,roll_forced_owed_pickup) is active, for every (pity-state × guarantee-state) combination — a direct combinatorial check, not an assumption; - exactly one of the four shared hit Gates (
pickup_hit_pickup,standard_hit_pickup,sr_hit_pickup,r_hit_pickup) fires per pull-bearing step (the branch-outcome sum is exactly1); pickup_count + standard_count + sr_count + r_count == pulls_made(Zone 3's own restatement of item 6's conservation, in terms of the four hit Gates specifically);- the last allowed pull can be an SSR and still conserves: forcing an
SSR on the FINAL step (
pulls_per_zone) still yieldspickup_count + standard_count == ssr_countexactly at the horizon — the specific case round 3's relay-Pool bug broke; - after any standard (non-pickup) SSR, the very next SSR is pickup with
probability
1, regardless of whether that next SSR is natural or ceiling-forced (all ofroll_normal_owed_pickup/roll_forced_owed_pickuproute it topickup_hit_pickupunconditionally); - the guarantee persists across SR/R pulls: after a miss, any number
of intervening SR/R results leaves
missed_pickup_pickup == 1until the next SSR actually resolves it; - a pickup SSR clears the guarantee immediately (same step);
- two consecutive pickups can occur naturally (a pickup roll does not owe
a guarantee);
missed_pickup_pickupis always0or1, never anything else; - running additional steps past the horizon changes no value and produces no further event (restated here since round 1–2's relay-Pool shape could plausibly have failed this specifically; the four-path shape has no Pool in its roll path at all, so this holds by construction, but it is asserted as its own explicit test, not inferred).
- Conservation, per zone:
ssr_count + sr_count + r_count == pulls_made == pulls_per_zone(exactly, every run — GZ7.1); Zone 3 additionally:pickup_count + standard_count == ssr_count; Zones 2–3 additionally:ceiling_hits <= ssr_count. - Determinism. Same graph + seed ⇒ byte-identical states/reports across
all three zones simultaneously, in the one shared run — including a
Reset (back to step 0) and re-run: the SAME seed reproduces both the
SAME per-zone results AND the SAME termination step
(
pulls_per_zone + 2, GZ3.5 round 4), not just the same final values. - Comparison Registers compute correctly, including the zero-SSR / zero-pickup edge cases (GZ7.2).
- Memory budget. The combined three-zone graph's Monte Carlo run (all
tracked Pools across all three zones, at whatever
Kthe implementation PR sets,steps = pulls_per_zone + 2exactly, GZ3.5 round 4) stays under the existingCELL_LIMIT. - The
pulls_per_zoneinput contract (GZ6 round 4 addition) holds at a SECOND safe positive integer, not just the shipped default200— a smallerN(e.g.5) still reachespulls_made_<zone> == Nfor all three zones by stepN + 1andended === trueat exactlyN + 2, confirming the termination contract scales withNrather than being pinned to the default.
- GZ-D1 — every zone runs the SAME fixed pull count
N(pulls_per_zone), not GS0's stop-at-first-SSR (GZ0). Flagged as the most consequential divergence from the existing GS doc; if rejected, GZ7's whole metric set needs redesigning around a stopping condition instead. - GZ-D2 — General/Free has no pity mechanic at all (GZ2).
- GZ-D3 — no shared ticket Pool between Premium Standard and Premium
Pickup, even though both are "paid" in flavour text (GZ3.3) — extends
docs/parameter-activator.md§PA8's separate-pity-Pools precedent to currency. - GZ-D4 — Zone 3's very first SSR is a fair, un-guaranteed pickup/standard
split (
missed_pickup_pickupstarts at0) — the alternative (starting "owed" a guarantee) was considered and rejected as less intuitive with no clear benefit. - GZ-D5 (round 2 — REJECTED, dropped from v1 per explicit direction).
Draft 1 proposed
pulls_to_first_ssr_<zone>/pulls_to_first_pickup_pickuplatch Pools to keep GS0's "pulls to first success" framing as a per-zone readout. Building them needs alabelform that copies an arbitrary Pool's current value, and today'sloop-state/3grammar (+N -N =N +S -S =S) has none — verified againstsrc/engine/stateExpr.ts'sROUTER_KINDSthat the obvious Pool-as-state-edge-source workaround also fails CSU3-5's router-source requirement. Rather than leave this unresolved into the implementation PR, it is dropped entirely from v1: GZ7'sssr_count_<zone>/ceiling_hits_<zone>/pickup_count_pickupalready give the comparison its headline metrics without needing first-occurrence timing. Revisit only if a later slice separately designs alabel-grammar extension that can copy an arbitrary Pool's value (a real engine-contract change, its own design doc). - GZ-D6 — the ticket abstraction collapses "cost" and "pull count" into
one Pool (
pulls_made_<zone>, GZ3.4) instead of GS2.1's separatespent_total. Safe here specifically because one ticket costs one ticket by definition (GZ6); not a precedent for a future version that wants visibly different real-money costs per pull. - GZ-D7 (round 3) — GZ5's pickup-guarantee mechanism is four
mutually-exclusive Gates fed directly by
ticket_pickup, AND-combining the pity and guarantee activators on each, rather than a relay Pool feeding two Gates. Chosen over extending the horizon by one step (GZ5.0): extending the horizon would have hidden the underlying bug rather than fixed it, and would leave the very last pull's pickup/standard outcome unresolved for a full extra step — a worse result, not just a documentation nicety. No new engine capability either way (both shapes only ever use activator AND-combination andafterPulllabels); this is a graph-wiring correction, not an engine-contract change. - GZ-D8 (round 4) — exactly ONE global
End, gated bypulls_made_<zone> >= @pulls_per_zoneAND-combined for all three zones directly on theEndnode, pulling a small standing pre-funded Pool (no live pulse, no relay). Two alternatives were checked directly againststep.tsand rejected: gating onticket_<zone> <= 0false-positives at step 1 (every ticket Pool starts at0, beforefund_<zone>ever commits); relaying throughSource → Pool → Endboth fails outright (a Source may only push to a Pool —step.tsdrops any other target) and, even patched to route through a Pool, reproduces GZ5 round 3's own Pool-arrival-lag bug (availOfreadsS[], the previous step's committed value — a Pool's same-step arrival is invisible to anything pulling from it until the FOLLOWING step). No new engine capability in the shape that was kept:endis already gated byisEnabled/activators exactly like a Gate (ROUTER_KINDSmembership, unchanged), and a Pool with a nonzeroinitialis already ordinary Pool behaviour.
- This document — zone rules, engine mapping, Parameter tables, MC metrics. No graph, no Template entry, no tests.
- Implementation (separate PR, after this document's approval) —
examples/gacha-banner-zones.json(or folded intoexamples/gacha-simulator.jsonas a second bundled Template — the implementation PR decides which), aTEMPLATESentry, KO/JA label overlays,recommendedRunConfig, an engine fixture (must include GZ8's RNG stream-isolation and fixed-horizon regression tests), and e2e coverage. No new engine semantics — GZ4/GZ5 use only shipped primitives.