diff --git a/Tiltfile b/Tiltfile index ed59bf2a84..b1733989a2 100644 --- a/Tiltfile +++ b/Tiltfile @@ -24,12 +24,17 @@ TMP_IGNORE = ['**/*.tmp.*'] # probe-pin isolates through `unshare --map-root-user --mount` (util-linux) and runs its target # under `timeout` (GNU coreutils). macOS ships neither, and there is no Darwin equivalent of an -# unprivileged mount namespace to port to — so both probe-pin resources abort on a Darwin host -# no matter what the tree contains. Gate their auto_init rather than let them boot straight into -# a permanent red: a gate that is red on every change teaches everyone to stop reading the -# colour, which costs more than the gate earns. They stay VISIBLE and clickable, like every -# other opt-in resource here, so the refusal is still one click away when someone wants to see -# it. `os.name` is consulted FIRST and short-circuits, so a Windows host never reaches `uname` +# unprivileged mount namespace to port to — so EVERY probe-pin resource in this file aborts on +# a Darwin host no matter what the tree contains: `probe-pin check` reaches `isolate::run` +# unconditionally from `pipeline()` (crates/probe-pin/src/main.rs:145 — the baseline run, before +# any mutant), and that spawns `unshare` (crates/probe-pin/src/isolate.rs:157). Scoped as a rule +# over all of them rather than as a count: a count goes stale the next time one is added, which +# is precisely how a resource below came to sit here ungated. Gate their auto_init rather than +# let them boot straight into a permanent red: a gate that is red on every change teaches +# everyone to stop reading the colour, which costs more than the gate earns. They stay VISIBLE +# and clickable, like every other opt-in resource here, so the refusal is still one click away +# when someone wants to see it. +# `os.name` is consulted FIRST and short-circuits, so a Windows host never reaches `uname` # — which it does not ship, and which would fail Tiltfile LOAD rather than one resource. IS_LINUX = ( os.name == 'posix' @@ -37,9 +42,11 @@ IS_LINUX = ( ) # auto_init alone would NOT be enough: it governs only the STARTUP run, and the default -# TRIGGER_MODE_AUTO re-runs a resource whenever its deps change. Both probe-pin resources watch -# 'crates/probe-pin/', so off Linux the very next edit there would drag them back into the red -# that auto_init just avoided. Off-Linux they must stop watching too, not merely stop booting. +# TRIGGER_MODE_AUTO re-runs a resource whenever its deps change. Every probe-pin resource lists +# 'crates/probe-pin/' in `deps`, so off Linux the very next edit there would drag it back into +# the red that auto_init just avoided. Off-Linux they must stop watching too, not merely stop +# booting — so the two gates travel together: a probe-pin resource carrying only one of them is +# a bug, not a lighter reading of the policy. PROBE_PIN_TRIGGER = TRIGGER_MODE_AUTO if IS_LINUX else TRIGGER_MODE_MANUAL # Must stay a SUPERSET of what `scripts/engine-source-hash.sh` hashes as the engine cache @@ -281,14 +288,21 @@ local_resource('coverage', # The only local enforcement venue for `probe-pin check` (CI enrollment needs a workflow edit, # which is a hard stop). Measured cost: 6.25s cold, 0.11s incremental, 0.046s for 10 isolated # probe runs -- so an automatic trigger with narrow deps, not a manual knob. This price holds -# ONLY while the manifest pins probe-pin's own test binary; an engine-census manifest is ~25s -# of runs plus an engine build and must be re-priced before it is added here. +# ONLY while the manifest pins probe-pin's own test binary. RE-PRICED IN PART for the engine-census +# manifest added below: the estimate was right that an engine build is additional (measured 32.86s +# on an ENGINE_SRC edit, even after build-native) and high on the runs (measured 14.7s, not ~25s). +# The AS-SHIPPED price of 'probe-pin-census''s own cmd is cold 22.99s / incremental 15.61s in an +# isolated tree; the price under a real `tilt up` -- shared target/, build-lock contention -- is +# NOT measured and is owed by whoever next prices this pair. local_resource('probe-pin-check', # Separate CARGO_TARGET_DIR (same reason as 'clippy'): probe-pin's dep tree is disjoint # from the engine's, so a shared dir would mutually invalidate fingerprints. cmd = ['bash', '-c', 'CARGO_TARGET_DIR=target/probe-pin cargo probe-pin check crates/probe-pin/tests/fixtures/dogfood.toml'], - deps = ['crates/probe-pin/', 'docs/probe-pin.md'], + # 'rust-toolchain.toml': the digest covers used_instruments() (unconditionally [Toolchain]), so + # a rustc move alone is `check` exit 1. The channel is date-pinned, so watching it puts that red + # on the causing commit. Full reasoning at the 'probe-pin-census' resource (named, not adjacent). + deps = ['crates/probe-pin/', 'docs/probe-pin.md', 'rust-toolchain.toml'], # TMP_IGNORE is a FILENAME glob ('**/*.tmp.*') and does not match a tmp/ DIRECTORY, so the # Tier-2 tests' scratch writes under tests/fixtures/tmp/ would retrigger this resource. ignore = TMP_IGNORE + ['**/tmp/**'], @@ -330,3 +344,213 @@ local_resource('probe-pin-e2e', allow_parallel = True, labels = ['lint'], ) + +# The engine-census pin -- the re-pricing the `probe-pin-check` note above demands, measured in an +# isolated tree with a dedicated CARGO_TARGET_DIR (never shared), against the probe set exactly as +# the manifest authors it. ⚠ NO TREE SHA IS STAMPED HERE, deliberately. The earlier stamp +# (`59cc90a8`) named a tree the manifest DOES NOT EXIST IN -- a coordinate that identifies nothing. +# (An earlier draft also called that object UNREACHABLE. Deleted, because it is false whenever a +# worktree HEAD still points at it -- and "absent from that tree" was always the whole reason.) +# THE OPERATIVE GUARD IS THE PROBE COUNT below: +# it is a property of this manifest, re-checkable from this file, and it is what actually decides +# whether these figures still describe the set. THE ROWS BELOW ARE A CLOSED RECORD OF ONE MEASUREMENT -- +# the set held 9 probes when timed -- and NOT a description of whatever the manifest holds when you +# read this: if a probe is added or removed these figures must be RE-TAKEN, not re-labelled. +# +# tool build, cold (fresh target/probe-pin) 6.91s tool build, incremental 0.10s +# the full probe set, engine target unchanged 14.7s (two runs: 14.69 / 14.80) +# the full probe set, AFTER an ENGINE_SRC edit 47.1s SEQUENTIALLY AFTER build-native, and +# with the shared target/ already warm +# THIS RESOURCE'S OWN cmd, as shipped: cold 22.99s incremental 15.61s (isolated tree) +# +# EVERY row above, the last included, was timed BEFORE this resource existed -- the earlier wording +# ("every row except the last") implied the last one was taken through the resource, and it was not. +# The rows above it ran through the `cargo probe-pin` alias; the last is the resource's own cmd, run +# by hand, which is the price 'probe-pin-check''s note actually +# asks for; it is taken in an isolated tree whose dedicated CARGO_TARGET_DIR stands in for the +# shared target/, so it does NOT cover the two hazards below -- those need a real `tilt up`. +# +# => resource cold: read the as-shipped row above (22.99s), which measures it directly; the +# earlier SUM-of-alias-rows derivation is deleted, superseded by that row per this file's rule. +# ~14.8s when only this manifest or the census file changed; +# ~47.2s on an engine-source edit, which is the common trigger -- under the two premises +# named on the 47.1s row above, both of which this resource's own wiring can violate. See +# "TWO PRICE HAZARDS" below; they are DISCLOSED, not measured. +# +# THE ENGINE BUILD *IS* ADDITIONAL -- 'probe-pin-check''s note was right to flag it, and measuring +# it was not a formality. `build-native` runs `cargo nextest run -p phase-engine -p phase-ai --no-run`; +# probe-pin's inner resolve runs `cargo test -p phase-engine --test integration --no-run`. The +# two keep SEPARATE artifact sets (different feature unification across the -p set), so after an +# ENGINE_SRC touch the inner resolve costs 32.86s even with build-native already complete -- +# against 31.20s with no build-native at all. It saves 1.7s of 32.9s. Both sets then sit at a +# fixed point until the next source change: 0.42s / 0.12s respectively, so they do NOT ping-pong. +# +# TWO PRICE HAZARDS THE FIGURES ABOVE DO NOT COVER -- INCLUDING the as-shipped row. Stated, NOT +# measured: both are properties of a real `tilt up` and neither can be timed from a shell, so +# pricing this resource's cmd in an isolated tree does not settle them. BOTH NUMBERS ARE STILL +# OWED (see 'probe-pin-check''s note above). +# 1. `tilt up -- lint` PAYS A PARTIAL COLD ENGINE BUILD. Every figure above was taken against a +# warm target tree -- the alias rows against the shared target/, the as-shipped row against +# its isolated stand-in. Of the labels=['lint'] resources this is the only one whose cargo work +# lands in the shared target/ -- clippy uses target/clippy, probe-pin-check target/probe-pin, +# probe-pin-e2e target/probe-pin-e2e, check-frontend runs no cargo at all. BUT THE LABEL IS +# NOT THE OPERATIVE SET: everything with auto_init = True also runs under `tilt up -- lint`, +# and 'draft-pools' (auto_init = True) runs `cargo run --bin draft-pool-gen` with NO +# CARGO_TARGET_DIR, dev profile, on draft-core -- which depends on phase-engine. So the +# shared tree is PARTIALLY warmed at init: phase-engine's lib and its dependency graph get +# built there. What is NOT warmed is the part this resource pays for -- the integration test +# binary, the engine's dev-dependencies, and anything only `build-native` builds +# (auto_init = 'test' in enabled, so it does not run under a lint-only profile). +# THE SIZE OF THE RESIDUAL IS UNMEASURED, and no figure in this comment bounds it: a cold +# `nextest --no-run` over these packages is both a different command and a FULLY cold tree, +# which is not the state a lint-only `tilt up` leaves. Re-pricing this under a real +# lint-only `tilt up` settles the number; it is owed, not measured. +# 2. THE 47.2s ASSUMES SEQUENTIAL ORDERING, which nothing now imposes. It was timed as +# build-native then probe-pin -- the order `resource_deps` would have forced, and there is +# deliberately no `resource_deps` (below). Both are allow_parallel and both fire on a +# crates/engine/src/ edit, so the inner `cargo test --no-run` can queue on the SHARED build +# lock behind build-native's ~82s nextest. The clippy comment above states the same +# mechanism from the other side: a separate CARGO_TARGET_DIR "gives it its own build lock, +# so it never queues behind the native test builds". The TOOL build here does have such a dir; +# the INNER engine resolve does not, by the choice in the next paragraph -- and it is the inner +# one that queues. 47.2s is therefore a floor, not a ceiling. Under a lint-only +# profile the contender is not build-native (which never starts) but 'card-data': auto_init +# = True, deps = ENGINE_SRC, and gen-card-data.sh runs `cargo build --profile tool` into +# ${CARGO_TARGET_DIR:-target}/tool -- a different profile dir in the SAME target root, and +# the cargo build lock is per target ROOT (the 'wasm' comment above states this). +# +# The tool is built into target/probe-pin-census (its own dir, see the cmd comment) and then +# invoked as a BINARY. The split is deliberate and it is the SHELL that draws it: a prefix +# assignment binds only the command it prefixes, so CARGO_TARGET_DIR covers `cargo build` and is +# already unset by the time the binary runs. probe-pin's own inner `cargo test --no-run` therefore +# INHERITS an unset CARGO_TARGET_DIR and resolves the integration binary out of the SHARED target/ +# -- the same tree every other cargo resource builds into, rather than a second private one. +# Putting CARGO_TARGET_DIR on the `cargo probe-pin` alias instead would push a second, cold engine +# build into target/probe-pin-census. What warms the shared tree is whatever else +# happened to run; this resource orders itself behind nothing, which is hazard 2. +# NO `resource_deps`. MEASURED, not preferred: 'build-native' auto-inits only under the `test` +# profile and this resource only under `lint` (plus the platform gate, which only narrows it +# further), so under `tilt up -- lint` it would wait forever on a resource that never starts -- +# merged, green in the file, and enforced NOWHERE, in the only venue this manifest has. +# The PROFILES are named rather than the two `auto_init` expressions quoted: the argument rests +# only on neither condition implying the other, and a quoted expression is a second copy to keep +# in sync -- which is exactly how this sentence came to misquote the resource below. +# +# Every other resource_deps pair in this Tiltfile satisfies "dependent auto-inits => +# dependency auto-inits" (test-engine/test-ai -> build-native, same group; test-frontend -> +# wasm and coverage -> card-data, dependency always inits; caddy -> frontend violates it only +# under `tilt up -- https tauri`, which nothing rejects, so that pair is already a violation +# and this one would be the second -- the first that fires under a profile the file itself +# documents). Price of not depending on it: build-native saves 1.7s +# of 32.9s on the inner resolve WHEN THE TWO RUN IN SEQUENCE (see the numbers above) -- not worth +# a venue that silently does not run. The parallel case is hazard 2, and it is the price of this +# choice, stated rather than netted out. +# +# PLATFORM: Linux-only, under the file-wide probe-pin policy and for the reason that policy +# states -- this cmd is `probe-pin check`, and that reaches `unshare` unconditionally (see the +# IS_LINUX note at the top of this file for the two coordinates). What the gate BUYS is only +# that a Darwin host does not boot this resource into a red it can never clear; what it does NOT +# buy is any enforcement of the pin off Linux. There is no CI venue to fall back to -- MEASURED: +# `grep -rn probe-pin .github/workflows/` returns nothing, which is the state 'probe-pin-check''s +# note calls a hard stop -- so off Linux this pin's verdict is carried entirely by whatever Linux +# venue last ran it. That is the identical shape 'probe-pin-e2e' states for Tier 2, and it is +# stated here rather than inferred from the flag. +local_resource('probe-pin-census', + cmd = ['bash', '-c', + # Starlark has NO implicit adjacent-string-literal concatenation (a Python rule this + # paste inherited): the `+` is load-bearing, not style. Without it the whole file + # fails to LOAD -- `tilt alpha tiltfile-result` exits 5 at this line -- which takes + # every other resource down with it, not just this one. + # Its OWN CARGO_TARGET_DIR, for the reason 'probe-pin-check' states two resources up: + # this resource and that one both watch 'crates/probe-pin/', both are allow_parallel, + # and a shared dir lets one relink the binary the other is mid-execution on. + 'CARGO_TARGET_DIR=target/probe-pin-census cargo build -q -p probe-pin && ' + + 'target/probe-pin-census/debug/probe-pin check probe-pin/engine-census.toml'], + # deps watches everything that can move THIS RESOURCE'S VERDICT **by changing what the pin + # measures**: census()'s two walk roots, the tool, the manifest, the pinned test file, the module + # declaration that puts that test in the binary, and the toolchain the digest covers -- + # deliberately NOT ENGINE_SRC + AI_SRC. + # ⚠ THE PREDICATE IS STATED BECAUSE THE EARLIER WORD WAS "EVERYTHING", AND THAT WAS FALSE. + # Disclosed residual, not a gap that was missed: the target is resolved by building + # `--test integration`, so ANY sibling file under crates/engine/tests/integration/ can move the + # verdict -- by breaking that build, or by adding a test that FAILS under the manifest's + # control. Those are NOT watched, on purpose: watching them would re-trigger this pin on every + # unrelated integration-test edit, and there are over a thousand of them. Both failure classes + # surface as exit 2 with the cause named in the output. THE DEV-DEP AND LOCKFILE HALF OF THIS + # RESIDUAL IS NO LONGER RESIDUAL -- it was listed here alongside the thousand siblings and + # inherited their exemption, which was wrong by this file's own admission rule: a lockfile-only + # edit leaves a stale GREEN, and Cargo.lock is one file with a tiny edit surface. It, the two + # manifests that reach this target, and .cargo/config.toml are watched below. + # The pin is over exactly what census() reads; ENGINE_SRC is a superset BY DESIGN (its own + # comment requires it to stay a superset of the engine cache key: src + data + build.rs + + # Cargo.toml), and census() reads none of the extras. The live cost of the wider set is not + # hypothetical: gen-card-data.sh PROMOTES tracked files under crates/engine/data/, and + # 'card-data' (auto_init = True, so under EVERY profile) documents that watching its own + # outputs "makes every promote re-trigger card-data -> an infinite regen loop" and ignores + # them for that reason. This resource's ignore list does not cover them, so with ENGINE_SRC + # every promote would re-run the whole probe set at full price for a pin that cannot move. + # AI_SRC happens to EQUAL its walk root today; not depending on it is the same point -- + # "what makes the engine rebuild" and "what census() reads" are different concepts that + # currently share a value, and a later path added to either symbol re-opens this silently. + deps = [ + 'crates/engine/src/', + 'crates/phase-ai/src/', + # THE BUILD INPUTS OF THE TARGET THIS PIN RESOLVES BY BUILDING (`--test integration`). + # A lockfile- or manifest-only edit changes what that target compiles while every watched + # source file stays byte-identical, so without these the resource keeps its PRIOR GREEN + # even when the rebuilt target would fail or move a number. Each is admitted by the rule + # 'main.rs' below is admitted by -- ONE file with a tiny, stable edit surface -- not by the + # "everything that can break the build" reading the comment above already rejects for the + # thousand-odd sibling test files. + # WHICH manifests, MEASURED not shotgunned (`cargo tree -p phase-engine -e normal,dev` + # intersected with the workspace members): phase-engine is the ONLY workspace member in + # this target's graph, so its manifest plus the workspace root's -- [profile.test] and + # [workspace.dependencies], which this target resolves through -- are the whole set. + # Sibling crate manifests are deliberately absent: they cannot reach this build. + # probe-pin's own manifest already rides along inside the 'crates/probe-pin/' dep below. + # Cargo may itself rewrite Cargo.lock when it is stale, which costs at most ONE extra + # settling run, not a loop: the rewrite is idempotent. + 'Cargo.lock', + 'Cargo.toml', + 'crates/engine/Cargo.toml', + # NOT a manifest, watched on the same one-file rule: [env] here sets + # RUST_MIN_STACK = 16777216, which that file records as load-bearing for test threads + # (Debug-formatting the Effect <-> AbilityDefinition recursion overflows a default + # stack). Lowering it turns these tests red with no source edit at all. + '.cargo/config.toml', + # The TOOL that renders the block, watched for the same reason 'probe-pin-check' watches + # it: a change to block::render or the digest moves the rendered block, and without this + # nothing re-triggers the check until an unrelated engine edit happens to fire it. + 'crates/probe-pin/', + 'probe-pin/engine-census.toml', + 'crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs', + # The MODULE DECLARATION. Deleting `mod loop_shortcut_offer_writer_census;` from main.rs + # takes the pinned test out of the binary. MEASURED, not reasoned: three siblings already + # import from `super::loop_shortcut_offer_writer_census` (two take + # `{cfg_test_scoped_lines, rs_files}`, one takes `rs_files`), so that deletion is an + # E0432 BUILD failure, not the zero-selection execution floor -- `check` + # aborts either way and every Abort maps to exit 2. It is watched not because its failure is + # quieter than a sibling's (it is the same class) but because it is ONE file with a tiny, + # stable edit surface: the cost of watching it rounds to zero, which is exactly what is not + # true of the thousand-odd siblings above. + 'crates/engine/tests/integration/main.rs', + # The TOOLCHAIN, for the same reason and by the same rule. `used_instruments()` returns + # [Instrument::Toolchain] UNCONDITIONALLY and the digest covers that list, so a rustc move + # with NO measured number changing is still `check` exit 1 (reported as an instrument + # change, not code drift). `channel` here is date-pinned, so in this repo a rustc move IS + # an edit to this file: watching it puts the red on the commit that caused it instead of + # on the next unrelated engine edit, where it would read as census drift. It does not + # cover a rustc moved WITHOUT this file (`rustup override`, RUSTUP_TOOLCHAIN). + 'rust-toolchain.toml', + ], + # '**/tmp/**' rides along with the 'crates/probe-pin/' dep, NOT as boilerplate: TMP_IGNORE is + # a FILENAME glob ('**/*.tmp.*') that does not match a tmp/ DIRECTORY, and probe-pin's Tier-2 + # tests scratch-write under crates/probe-pin/tests/fixtures/tmp/. 'probe-pin-check' carries + # this exact pairing and states the reason; watching the crate without it re-imports the + # retrigger loop that comment exists to document. + ignore = TMP_IGNORE + ['**/tmp/**'], + auto_init = 'lint' in enabled and IS_LINUX, + trigger_mode = PROBE_PIN_TRIGGER, + allow_parallel = True, + labels = ['lint'], +) diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index 6a616cf2fa..9a2a393660 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -83,6 +83,12 @@ export type InteractionDamageAssignmentMode = "normal" | "asThoughUnblocked"; export type InteractionShortcutCountSpec = { "type": "fixed", "data": { min: number, max: number, suggested: number, } } | { "type": "untilLethal" }; +export type InteractionShortcutPreviewFamily = "mana" | "life" | "damage" | "mill" | "counters" | "tokens" | "cards" | "casts" | "combats" | "turns" | "triggers"; + +export type InteractionShortcutPreviewEntry = { family: InteractionShortcutPreviewFamily, player: number | null, amount: number, }; + +export type InteractionShortcutPreview = { count: number, entries: Array, }; + export type InteractionShortcutPointKind = "targets" | "convokeTaps" | "mode" | "mayChoice" | "unlessBreak" | "manaColor"; export type InteractionShortcutPoint = { group: number, kind: InteractionShortcutPointKind, min: number, max: number, unique: boolean, ordered: boolean, readOnly: boolean, candidateIds: Array, }; @@ -93,7 +99,7 @@ export type InteractionShortcutDecision = { "type": "decline" } | { "type": "acc export type InteractionShortcutReply = { "type": "accept" } | { "type": "shorten", "data": { atIteration: number, } }; -export type InteractionResponseSpec = { "type": "select", "data": { constraint: SelectionConstraint, confirm: ConfirmSemantics, } } | { "type": "assignAmounts", "data": { minTotal: number, maxTotal: number, exactTotal: number | null, } } | { "type": "assignDamage", "data": { total: number, modes: Array, confirm: ConfirmSemantics, } } | { "type": "sequence", "data": { min: number, max: number, unique: boolean, includeAll: boolean, engineValidated: boolean, escape: InteractionChoiceId | null, confirm: ConfirmSemantics, } } | { "type": "groupedSequence", "data": { groups: Array, unique: boolean, confirm: ConfirmSemantics, } } | { "type": "manaGroups", "data": { groups: Array, maxBatch: number, escape: InteractionChoiceId | null, confirm: ConfirmSemantics, } } | { "type": "text", "data": { allowArbitrary: boolean, maxLen: number, confirm: ConfirmSemantics, } } | { "type": "deckPartition", "data": { minMainTotal: number, maxMainTotal: number, confirm: ConfirmSemantics, } } | { "type": "relations", "data": { edges: Array, min: number, max: number, sourceConstraint: InteractionRelationSourceConstraint, allowGroups: boolean, confirm: ConfirmSemantics, } } | { "type": "number", "data": { min: number, max: number, confirm: ConfirmSemantics, } } | { "type": "shortcut", "data": { count: InteractionShortcutCountSpec, points: Array, allowDecline: boolean, confirm: ConfirmSemantics, } } | { "type": "shortcutReply", "data": { minIteration: number, maxIteration: number, confirm: ConfirmSemantics, } }; +export type InteractionResponseSpec = { "type": "select", "data": { constraint: SelectionConstraint, confirm: ConfirmSemantics, } } | { "type": "assignAmounts", "data": { minTotal: number, maxTotal: number, exactTotal: number | null, } } | { "type": "assignDamage", "data": { total: number, modes: Array, confirm: ConfirmSemantics, } } | { "type": "sequence", "data": { min: number, max: number, unique: boolean, includeAll: boolean, engineValidated: boolean, escape: InteractionChoiceId | null, confirm: ConfirmSemantics, } } | { "type": "groupedSequence", "data": { groups: Array, unique: boolean, confirm: ConfirmSemantics, } } | { "type": "manaGroups", "data": { groups: Array, maxBatch: number, escape: InteractionChoiceId | null, confirm: ConfirmSemantics, } } | { "type": "text", "data": { allowArbitrary: boolean, maxLen: number, confirm: ConfirmSemantics, } } | { "type": "deckPartition", "data": { minMainTotal: number, maxMainTotal: number, confirm: ConfirmSemantics, } } | { "type": "relations", "data": { edges: Array, min: number, max: number, sourceConstraint: InteractionRelationSourceConstraint, allowGroups: boolean, confirm: ConfirmSemantics, } } | { "type": "number", "data": { min: number, max: number, confirm: ConfirmSemantics, } } | { "type": "shortcut", "data": { count: InteractionShortcutCountSpec, points: Array, allowDecline: boolean, preview: InteractionShortcutPreview | null, confirm: ConfirmSemantics, } } | { "type": "shortcutReply", "data": { minIteration: number, maxIteration: number, confirm: ConfirmSemantics, } }; export type InteractionOpportunityResponse = { "type": "exactChoices", "data": { choices: Array, } } | { "type": "schema", "data": { spec: InteractionResponseSpec, candidates: Array, } }; diff --git a/client/src/adapter/ws-adapter.ts b/client/src/adapter/ws-adapter.ts index 18ffbb4088..65d339df59 100644 --- a/client/src/adapter/ws-adapter.ts +++ b/client/src/adapter/ws-adapter.ts @@ -203,6 +203,24 @@ export class NativeEngineVersionMismatchError extends Error { * `crates/server-core/src/protocol.rs`. Bump in lockstep when either side * adds, removes, renames, or changes the type of a protocol variant field. * + * 31 — WaitingFor::LoopShortcut publishes the engine-issued declaration, and + * InteractionResponseSpec::Shortcut publishes preview, the per-axis + * consequence of the offered count. Both are optional and neither type + * sets deny_unknown_fields, so a v30 peer still PARSES the frame — a + * capability bump like 24, not a parse bump. UNLIKE 24, no pairing is left + * for the capability gap to bite in, so this entry names no silent-drop + * hazard: full-game floors are exact-match on BOTH sides + * (MIN_SUPPORTED_SERVER_PROTOCOL below, and MIN_SUPPORTED_PROTOCOL in + * crates/server-core/src/protocol.rs, each equal to their own + * PROTOCOL_VERSION), so a v31/v30 full-game pair is refused at the + * handshake and never sends an action frame. The one-version window that + * does exist is lobby-only (LOBBY_MIN_SUPPORTED_SERVER_PROTOCOL below / + * MIN_SUPPORTED_PROTOCOL in crates/lobby-broker/src/protocol.rs) and it + * cannot carry this capability either: DeclareShortcut rides + * ClientMessage::Action, which LobbyClientMessage has no variant for at + * all, and which reject_if_disabled in crates/phase-server/src/main.rs + * answers under ServerMode::LobbyOnly with an explicit rejection rather + * than a silent drop. * 30 — Serialized player-action completion provenance and modal continuations. * 29 — Added requester-correlated ResolveAllRejected response frames. * 28 — Added native ResolveAll request/result frames. @@ -237,7 +255,7 @@ export class NativeEngineVersionMismatchError extends Error { * into a MulliganDecisionPhase::BottomCards sub-phase on * WaitingFor::MulliganDecision. */ -export const PROTOCOL_VERSION = 30; +export const PROTOCOL_VERSION = 31; /** * Lowest server protocol version this client will accept in the handshake. diff --git a/client/src/components/hud/HudBadges.tsx b/client/src/components/hud/HudBadges.tsx index dd4718dfd0..d829e80a12 100644 --- a/client/src/components/hud/HudBadges.tsx +++ b/client/src/components/hud/HudBadges.tsx @@ -444,7 +444,10 @@ const UNBOUNDED_FAMILY_GLYPH: Record = { triggers: "✴", }; -const UNBOUNDED_FAMILY_LABEL_KEY: Record = { +/** Exported for `LoopShortcutModal`'s preview lines: the engine's + * `InteractionShortcutPreviewFamily` is the same 11 literals as `UnboundedFamily`, so the preview + * reuses these labels instead of minting a parallel 11-key catalog in 7 locales. */ +export const UNBOUNDED_FAMILY_LABEL_KEY: Record = { mana: "badges.unboundedMana", life: "badges.unboundedLife", damage: "badges.unboundedDamage", diff --git a/client/src/components/modal/LoopShortcutModal.tsx b/client/src/components/modal/LoopShortcutModal.tsx index bd86211cec..1cd9e73ecf 100644 --- a/client/src/components/modal/LoopShortcutModal.tsx +++ b/client/src/components/modal/LoopShortcutModal.tsx @@ -1,23 +1,70 @@ -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import type { IterationCount, ResourceAxis, WinKind } from "../../adapter/types.ts"; +import type { + InteractionId, + InteractionResponseSpec, + InteractionShortcutPreview, + ViewerInteraction, +} from "../../adapter/generated/interaction"; +import type { IterationCount, ResourceAxis, WaitingFor, WinKind } from "../../adapter/types.ts"; import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts"; import { useGameStore } from "../../stores/gameStore.ts"; -import { familyOf, UnboundedBadge } from "../hud/HudBadges.tsx"; +import { familyOf, UNBOUNDED_FAMILY_LABEL_KEY, UnboundedBadge } from "../hud/HudBadges.tsx"; +import { AmountInput, parseAmount } from "../mana/AmountInput.tsx"; import { DialogShell } from "./DialogShell.tsx"; /** * CR 732.2a/b/c: the interactive loop-shortcut declare + accept-or-shorten * modals. Pure display layer — every rendered value is a direct read of an - * engine schema/proposal field; the frontend derives, filters, and computes - * nothing. `DeclareShortcut.template` is always `null` (per-iteration pin-capture - * is deferred to an engine-side assembly), and the engine remains the sole - * legality authority (`predictability_gate` + `validate_pins`). + * engine schema/proposal/response-spec field; the frontend derives, filters, and + * computes nothing. `DeclareShortcut.template` is always `null`: building pins is not a + * client authority, and the engine remains the sole legality authority + * (`predictability_gate` + `validate_pins`). + * + * MEASURED LIMIT, stated rather than assumed — `null` is what the client can honestly send, + * NOT a payload the engine accepts everywhere. `handle_declare_shortcut` + * (`game/engine.rs`, the `!offer.schema.points.is_empty()` block) REFUSES a `template: null` + * declaration unless the proposer controls the recorded loop period, so only the point-free + * drain shape declares successfully at this base. Carrying the engine's own issued + * declaration through the manual declare path is an ENGINE-side repair; a client that + * reconstructed a template would be inventing rules authority it does not have. */ -// CR 732.1b: render the engine-proposed repeat mode. The count is echoed from the -// schema/proposal verbatim — never chosen or computed here. +/** The engine's published shortcut response spec — the count window, `allowDecline`, and the + * engine-computed preview. A lookup into what the engine already sent, never a derivation. */ +type ShortcutSpec = Extract["data"]; + +function shortcutSpec(interaction: ViewerInteraction | null): ShortcutSpec | null { + for (const opportunity of interaction?.opportunities ?? []) { + if (opportunity.response.type !== "schema") continue; + const { spec } = opportunity.response.data; + if (spec.type === "shortcut") return spec.data; + } + return null; +} + +/** The live shortcut offer's interaction id — the identity React keys the offer body on. + * Walks the same list under the same predicate as `shortcutSpec`, so the two agree on which + * opportunity is "the offer" by construction rather than by convention. + * + * Returns the branded string ITSELF, never a wrapper object. `useGameStore` is zustand 5, whose + * `useStore` is a bare `useSyncExternalStore` with no equality function, so React compares + * successive selector results with `Object.is`: a selector returning a fresh object literal is + * the documented infinite-loop shape, not merely an extra render. `InteractionId` is + * `string & { __brand }` — a primitive at runtime — so this is `Object.is`-stable whenever the + * id has not rotated, for the same reason `shortcutSpec`'s store reference is. */ +function shortcutInteractionId(interaction: ViewerInteraction | null): InteractionId | null { + for (const opportunity of interaction?.opportunities ?? []) { + if (opportunity.response.type !== "schema") continue; + if (opportunity.response.data.spec.type === "shortcut") return opportunity.interactionId; + } + return null; +} + +// CR 732.1b: render the engine-proposed repeat mode — the offer's own stated count, echoed +// verbatim. The picker below narrows WITHIN the engine's published window; this line is the +// offer, not the pick. function CountLine({ count }: { count: IterationCount }) { const { t } = useTranslation("game"); return ( @@ -49,6 +96,43 @@ function FamilyBadges({ axes }: { axes: ResourceAxis[] }) { ); } +/** + * CR 732.2a: what the offer's stated count actually DOES, per axis. Every magnitude is read + * straight off the engine's published preview — already multiplied, already signed. The heading + * names `preview.count` because these numbers describe that count and no other, so a player can + * never read them against a different one; the display layer multiplies nothing. + */ +function PreviewLines({ preview }: { preview: InteractionShortcutPreview }) { + const { t } = useTranslation("game"); + if (preview.entries.length === 0) return null; + return ( +
+

+ {t("comboShortcut.previewTitle", { count: preview.count })} +

+ {preview.entries.map((entry, index) => ( +

+ {entry.player === null + ? t("comboShortcut.previewEntry", { + amount: entry.amount, + resource: t(UNBOUNDED_FAMILY_LABEL_KEY[entry.family]), + }) + : t("comboShortcut.previewEntryPlayer", { + amount: entry.amount, + resource: t(UNBOUNDED_FAMILY_LABEL_KEY[entry.family]), + // Seat display numbering, the same +1 formatting `LifeTotal` uses on the engine's + // seat id. Formatting, not derivation. + player: t("lifeTotal.playerLabel", { seat: entry.player + 1 }), + })} +

+ ))} +
+ ); +} + /** * CR 732.2a: the priority holder (the proposer) may declare the shortcut OR decline it — * "the player with priority may suggest a shortcut" is @@ -57,29 +141,78 @@ function FamilyBadges({ axes }: { axes: ResourceAxis[] }) { * `RespondToShortcutModal`. */ export function DeclareShortcutModal() { - const { t } = useTranslation("game"); const canAct = useCanActForWaitingState(); const waitingFor = useGameStore((s) => s.waitingFor); + // `shortcutSpec` returns a reference INTO store state (or null), so the selector is stable. + const spec = useGameStore((s) => shortcutSpec(s.viewerInteraction)); + // A branded string, not an object literal — same snapshot-stability reason as the line above. + const offerId = useGameStore((s) => shortcutInteractionId(s.viewerInteraction)); + + if (waitingFor?.type !== "LoopShortcut" || !canAct) return null; + + // A typed count must not survive into a LATER offer, and the two ways one offer can follow + // another need two different mechanisms. Naming only the first is how this comment was wrong: + // - offer -> other-state -> offer: covered by the `return null` above, which unmounts the body + // when the state leaves `LoopShortcut`. This component itself never unmounts (GamePage keeps + // both modals mounted and they self-gate), so that guard is the only unmount there is. + // - offer -> offer: covered by the `key` below and ONLY by it. React reconciles by element type + // and position, so without a key two consecutive offers share one `DeclareShortcutOffer` + // instance and its `picked`. The transport may deliver B without the client ever committing a + // render at an intermediate state, so the first guard is not merely weaker here — it never runs. + // `interactionId` is the identity because it rotates in ENGINE state on every accepted action: + // `LoopShortcut` classifies as a non-simultaneous Single decision, and + // `rebind_interaction_slots_after_action` re-mints those "including A→A and A→B→A". A key built + // from the published window, or from any `waitingFor.data` field, is not distinct between two + // offers that happen to carry equal values — the plausible fix that reads as a fix. + // `offerId` is null only when no shortcut opportunity is published, and then `spec` is null too + // (one predicate, one list), so no picker renders and there is no `picked` to leak. + return ; +} + +function DeclareShortcutOffer({ + data, + spec, +}: { + data: Extract["data"]; + spec: ShortcutSpec | null; +}) { + const { t } = useTranslation("game"); const dispatch = useGameStore((s) => s.dispatch); + const { certificate, schema } = data; + + // CR 732.2a: the count window is ENGINE-OWNED. `null` when this offer publishes no finite + // window (UntilLethal) or when the transport published no interaction projection at all — in + // both cases no picker renders and the offer's own count is declared verbatim, as before. + const countSpec = spec?.count.type === "fixed" ? spec.count.data : null; + // No client-side default: until the player types, the box shows the ENGINE's suggested count. + const [picked, setPicked] = useState(null); + const raw = picked ?? (countSpec === null ? "" : String(countSpec.suggested)); + // `parseAmount` is the shared sanitization authority — it REJECTS out-of-window entries rather + // than clamping, so a count the engine did not offer can never be declared. + const chosen = countSpec === null ? null : parseAmount(raw, countSpec.min, countSpec.max); + const confirmDisabled = countSpec !== null && chosen === null; const handleConfirm = useCallback(() => { - if (waitingFor?.type !== "LoopShortcut") return; - // Echo the engine-proposed iteration_count verbatim; pin-capture is deferred, - // so `template` is always null (matches every live engine + AI declare path). - dispatch({ - type: "DeclareShortcut", - data: { count: waitingFor.data.schema.iteration_count, template: null }, - }); - }, [waitingFor, dispatch]); + // `template: null` is unchanged by C5 (see the module header's measured limit) — the picker + // moves the COUNT only. + if (countSpec === null) { + dispatch({ + type: "DeclareShortcut", + data: { count: schema.iteration_count, template: null }, + }); + return; + } + // Refused entry ⇒ submit nothing. The guard lives here once (AmountInput deliberately does + // not re-guard), and the confirm button is disabled in the same state. + if (chosen === null) return; + dispatch({ type: "DeclareShortcut", data: { count: { Fixed: chosen }, template: null } }); + }, [dispatch, countSpec, chosen, schema.iteration_count]); const handleDecline = useCallback(() => { // CR 732.2a: decline the auto-offer; the engine restores ordinary priority. dispatch({ type: "DeclineShortcut" }); }, [dispatch]); - if (waitingFor?.type !== "LoopShortcut" || !canAct) return null; - - const { certificate, schema } = waitingFor.data; // CR 702.51a: engine-computed count of untapped creatures the engine will auto-tap // for convoke — read directly from the schema (the engine owns the derivation). const convokeTappable = schema.convoke_tappable_count; @@ -88,16 +221,22 @@ export function DeclareShortcutModal() {
- + {/* CR 732.2a: declining is offered only when the engine says this offer may be declined. */} + {(spec?.allowDecline ?? true) && ( + + )}
); @@ -111,6 +250,21 @@ export function DeclareShortcutModal() {
+ {countSpec && ( + + )} + {spec?.preview && } {convokeTappable > 0 && (

diff --git a/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx b/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx index 4bed27913e..fb24035bdd 100644 --- a/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx +++ b/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx @@ -1,6 +1,11 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + InteractionId, + InteractionResponseSpec, + ViewerInteraction, +} from "../../../adapter/generated/interaction"; import type { DecisionPoint, GameState, @@ -16,6 +21,47 @@ import { DeclareShortcutModal, RespondToShortcutModal } from "../LoopShortcutMod const dispatchMock = vi.fn(); +type ShortcutSpec = Extract["data"]; + +/** The engine's published shortcut response spec, delivered on `viewerInteraction` exactly as + * `gameStore.legalResultState` assigns it. Defaults mirror the live publisher + * (`game/interaction.rs`): a Fixed window and `allow_decline: true`. */ +function shortcutInteraction( + overrides: Partial = {}, + // The offer's identity. Defaults to the literal every existing row was already written + // against, so parameterizing it changes no existing row; the A→B rows below pass distinct + // ids because a rotating id is precisely what they discriminate on. + interactionId = "session.0.1", +): ViewerInteraction { + const spec: ShortcutSpec = { + count: { type: "fixed", data: { min: 1, max: 5, suggested: 5 } }, + points: [], + allowDecline: true, + preview: null, + confirm: "explicit", + ...overrides, + }; + return { + waitingForKind: { simultaneous: null, terminal: false, code: "shortcut" }, + authorizedSubmitters: [0], + canSubmit: true, + autoPassRecommended: false, + opportunities: [ + { + interactionId: interactionId as InteractionId, + response: { + type: "schema", + data: { spec: { type: "shortcut", data: spec }, candidates: [] }, + }, + surfaces: [], + progress: { selected: 0, minimum: 1, maximum: 1, aggregate: null, confirmable: false }, + }, + ], + attachmentFans: {}, + availability: { type: "inputRequired" }, + }; +} + // A ConvokeTaps decision-point with two tappable creatures (informational — the // engine auto-taps via select_convoke_taps; the modal renders it read-only). const convokePoint: DecisionPoint = { @@ -23,14 +69,20 @@ const convokePoint: DecisionPoint = { kind: { ConvokeTaps: { tappable: [40, 41] } }, }; -function seed(waitingFor: WaitingFor, overrides: Partial = {}) { +// `viewerInteraction` is ALWAYS written (null by default): `setGameStoreForTest` merges into a +// module-level store, so an unset field would leak a previous test's published spec forward. +function seed( + waitingFor: WaitingFor, + overrides: Partial = {}, + viewerInteraction: ViewerInteraction | null = null, +) { const gameState = buildGameState({ objects: {}, priority_player: 0, waiting_for: waitingFor, ...overrides, }); - setGameStoreForTest({ gameState, waitingFor, dispatch: dispatchMock }); + setGameStoreForTest({ gameState, waitingFor, dispatch: dispatchMock, viewerInteraction }); } describe("LoopShortcutModal", () => { @@ -133,6 +185,207 @@ describe("LoopShortcutModal", () => { expect(dispatchMock).toHaveBeenCalledWith({ type: "DeclineShortcut" }); }); + // C5a: the picker declares the count the PLAYER picked. Discriminating by construction — the + // pre-C5 dispatch echoed `schema.iteration_count` ({Fixed:5}), and 2 is neither that, nor the + // engine's `suggested` (5), nor either window edge (1/5), so no hardcoded value satisfies it. + it("declares the picked count, not the engine's suggestion (C5a)", () => { + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 5 } } }), + {}, + shortcutInteraction(), + ); + render(); + + // Opens on the ENGINE's suggested count — the frontend holds no default. + const box = screen.getByRole("spinbutton"); + expect(box).toHaveValue("5"); + + fireEvent.change(box, { target: { value: "2" } }); + fireEvent.click(screen.getByRole("button", { name: "Take the shortcut" })); + // COUNT ONLY, deliberately. `template` is asserted nowhere in the C5 rows: the engine refuses + // a `template: null` declaration on a point-carrying schema (module header), so pinning the + // whole payload here would codify a payload the engine does not accept as the end state. + expect(dispatchMock).toHaveBeenCalledWith({ + type: "DeclareShortcut", + data: expect.objectContaining({ count: { Fixed: 2 } }), + }); + }); + + // C5a bounds: the window is engine-owned. The steppers stop at the published max, and an entry + // outside [min,max] declares NOTHING. The final legal entry is the paired positive reach-guard — + // without it "never dispatched" could pass on a modal that renders no working control at all. + it("steps inside the engine window and refuses an entry outside it (C5a bounds)", () => { + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 3 } } }), + {}, + shortcutInteraction({ count: { type: "fixed", data: { min: 1, max: 3, suggested: 2 } } }), + ); + render(); + + const box = screen.getByRole("spinbutton"); + fireEvent.click(screen.getByRole("button", { name: "Increase amount" })); + expect(box).toHaveValue("3"); + expect(screen.getByRole("button", { name: "Increase amount" })).toBeDisabled(); + + fireEvent.change(box, { target: { value: "9" } }); + fireEvent.click(screen.getByRole("button", { name: "Take the shortcut" })); + expect(dispatchMock).not.toHaveBeenCalled(); + + fireEvent.change(box, { target: { value: "1" } }); + fireEvent.click(screen.getByRole("button", { name: "Take the shortcut" })); + expect(dispatchMock).toHaveBeenCalledWith({ + type: "DeclareShortcut", + data: expect.objectContaining({ count: { Fixed: 1 } }), + }); + }); + + // C5a negative: a window absent from the payload renders NO picker and never invents a + // client-chosen count — the offer's own `iteration_count` is declared verbatim. Both absent + // shapes are covered: no interaction projection at all, and an UntilLethal offer. + it("renders no picker without a published window (C5a negative)", () => { + seed(buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 5 } } })); + render(); + + expect(screen.queryByRole("spinbutton")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Take the shortcut" })); + expect(dispatchMock).toHaveBeenCalledWith({ + type: "DeclareShortcut", + data: expect.objectContaining({ count: { Fixed: 5 } }), + }); + cleanup(); + dispatchMock.mockReset(); + + seed( + buildLoopShortcutWaitingFor(), + {}, + shortcutInteraction({ count: { type: "untilLethal" } }), + ); + render(); + + expect(screen.queryByRole("spinbutton")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Take the shortcut" })); + expect(dispatchMock).toHaveBeenCalledWith({ + type: "DeclareShortcut", + data: expect.objectContaining({ count: "UntilLethal" }), + }); + }); + + // C4/§7.5: a count typed into offer A must not survive into offer B. The body is keyed on the + // offer's `interactionId`, which the engine re-mints on every accepted action. + // + // ⚠ The second render MUST be `view.rerender(...)`, never a second `render(...)`. A fresh + // `render` builds a new tree and mounts a new `DeclareShortcutOffer`, which resets `picked` on + // the UNFIXED code too — the row would go green against the defect and prove nothing. The rows + // above use `cleanup()` + `render()` between shapes; that is the opposite of what these need, so + // do not "fix" these into the house idiom. + it("starts offer B from its own suggestion, not the count typed into offer A (C4)", () => { + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 5 } } }), + {}, + shortcutInteraction( + { count: { type: "fixed", data: { min: 1, max: 9, suggested: 5 } } }, + "session.0.1", + ), + ); + const view = render(); + + const box = screen.getByRole("spinbutton"); + // Positive reach-guard: the entry actually landed, so a later "not 2" cannot pass vacuously + // by the picker never having accepted input. `type="text"` + `role="spinbutton"`, so the + // compared value is a STRING. + fireEvent.change(box, { target: { value: "2" } }); + expect(box).toHaveValue("2"); + + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 7 } } }), + {}, + shortcutInteraction( + { count: { type: "fixed", data: { min: 1, max: 9, suggested: 7 } } }, + "session.0.2", + ), + ); + view.rerender(); + + expect(screen.getByRole("spinbutton")).toHaveValue("7"); + }); + + // The hostile sibling, and it is what kills the plausible wrong fix: offer B publishes a + // BYTE-IDENTICAL window to A and differs only in `interactionId`. A key built from the window — + // or from any `waitingFor.data` field — passes the row above and fails this one. + it("resets on a second offer carrying an identical window (C4 hostile)", () => { + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 5 } } }), + {}, + shortcutInteraction( + { count: { type: "fixed", data: { min: 1, max: 9, suggested: 5 } } }, + "session.0.1", + ), + ); + const view = render(); + + const box = screen.getByRole("spinbutton"); + fireEvent.change(box, { target: { value: "2" } }); + expect(box).toHaveValue("2"); + + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 5 } } }), + {}, + shortcutInteraction( + { count: { type: "fixed", data: { min: 1, max: 9, suggested: 5 } } }, + "session.0.2", + ), + ); + view.rerender(); + + expect(screen.getByRole("spinbutton")).toHaveValue("5"); + }); + + // BL-1 (CR 732.2a), BOTH arms: Decline is offered iff the engine's `allowDecline` says so. The + // false arm asserts Confirm is still present, so "no Decline button" cannot pass by the modal + // having failed to render. + it("renders Decline only when the engine allows it (BL-1)", () => { + seed(buildLoopShortcutWaitingFor(), {}, shortcutInteraction({ allowDecline: true })); + render(); + expect(screen.getByRole("button", { name: "Decline the shortcut" })).toBeInTheDocument(); + cleanup(); + + seed(buildLoopShortcutWaitingFor(), {}, shortcutInteraction({ allowDecline: false })); + render(); + expect(screen.queryByRole("button", { name: "Decline the shortcut" })).toBeNull(); + expect(screen.getByRole("button", { name: "Take the shortcut" })).toBeInTheDocument(); + }); + + // C4 render path: the preview's magnitudes are the ENGINE's, already multiplied and signed, and + // headed by the count they describe. The recompute-guard is the discriminator: moving the picker + // to 2 must leave every number untouched — a component that rescaled the preview to the picked + // count (or that recomputed it at all) fails here. + it("renders the engine preview verbatim and never rescales it (C4 render)", () => { + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 4 } } }), + {}, + shortcutInteraction({ + count: { type: "fixed", data: { min: 1, max: 4, suggested: 4 } }, + preview: { + count: 4, + entries: [ + { family: "life", player: 1, amount: -40 }, + { family: "mana", player: null, amount: 12 }, + ], + }, + }), + ); + render(); + + expect(screen.getByText("Repeating 4 times produces:")).toBeInTheDocument(); + expect(screen.getByText("-40 life — P2")).toBeInTheDocument(); + expect(screen.getByText("12 mana")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "2" } }); + expect(screen.getByText("Repeating 4 times produces:")).toBeInTheDocument(); + expect(screen.getByText("-40 life — P2")).toBeInTheDocument(); + expect(screen.getByText("12 mana")).toBeInTheDocument(); + }); + // T4: the respond window renders the proposal and Accept dispatches Accept. it("renders the proposal and dispatches Accept (T4)", () => { seed(buildRespondToShortcutWaitingFor()); diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index f229ed6115..f38d465729 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 7d956367c5..0024802419 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -26,6 +26,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index d4cffd20d0..3dc2d1c128 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 04613d4886..8058d01020 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 5b5a940a57..2d5409f1dc 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 78993022ba..1c0ca16756 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 8bf35ad432..576a31dedf 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/network/__tests__/protocol.test.ts b/client/src/network/__tests__/protocol.test.ts index 541a869842..e79d01179d 100644 --- a/client/src/network/__tests__/protocol.test.ts +++ b/client/src/network/__tests__/protocol.test.ts @@ -36,8 +36,8 @@ const viewerInteractionWithProducedMana = { } as never; describe("encodeWireMessage / decodeWireMessage", () => { - it("pins the P2P wire protocol to v20", () => { - expect(WIRE_PROTOCOL_VERSION).toBe(20); + it("pins the P2P wire protocol to v21", () => { + expect(WIRE_PROTOCOL_VERSION).toBe(21); }); it("defaults shortcut actions for a legacy payload created before the additive field", () => { @@ -214,17 +214,36 @@ describe("encodeWireMessage / decodeWireMessage", () => { await expect(decodeWireMessage(new Uint8Array())).rejects.toThrow(/empty/); }); + const setupFrameAt = (wireProtocolVersion: number) => ({ + type: "game_setup", + wireProtocolVersion, + assignedPlayerId: 1, + playerToken: "token-123", + state: buildGameState(), + events: [], + legalActions: [], + manaPaymentShortcutActions: [], + }); + it("rejects stale setup wire protocol versions", () => { - expect(() => validateMessage({ + expect(() => validateMessage(setupFrameAt(4))).toThrow(/Wire protocol mismatch/); + }); + + // The ADJACENT-peer pairing, which the far-stale v4 row above cannot exercise: + // 4 is refused whatever this client speaks, so that row proves the mechanism + // and nothing about the version. Both halves here stamp LITERALS — a frame + // built from WIRE_PROTOCOL_VERSION cannot tell a bumped client from an + // unbumped one, which is why every other handshake fixture in the suite is + // useless as an instrument for a bump. Revert 21 → 20 and BOTH halves red: + // the v20 frame stops being refused, and the v21 frame stops being admitted. + // The admitting half is the reach-guard: without it "refuses v20" is also + // satisfied by a client that refuses everything. + it("refuses the previous wire protocol (v20) and admits its own (v21)", () => { + expect(() => validateMessage(setupFrameAt(20))).toThrow(/Wire protocol mismatch/); + expect(validateMessage(setupFrameAt(21))).toMatchObject({ type: "game_setup", - wireProtocolVersion: 4, - assignedPlayerId: 1, - playerToken: "token-123", - state: buildGameState(), - events: [], - legalActions: [], - manaPaymentShortcutActions: [], - })).toThrow(/Wire protocol mismatch/); + wireProtocolVersion: 21, + }); }); // (e) Compressed payload still gates through validateMessage so unknown diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 9f70d390a3..2399511c68 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -80,6 +80,10 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * of silently corrupting state. * * Bumps to date: + * 21 — LegalActionsWire.viewerInteraction carries the loop-shortcut preview, + * and the state snapshot carries WaitingFor::LoopShortcut.declaration. + * Both are optional and parse on a v20 peer; the loss is silent, so the + * handshake is the only place the pairing can be refused. * 20 — Serialized player-action completion provenance and modal continuations. * 19 — Added an action_noop acknowledgement for accepted transport no-ops. * 18 — DebugCardEntries added a serialized, private resolution frame for @@ -112,7 +116,7 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * sub-phase on WaitingFor::MulliganDecision; the MulliganBottomCards * variant was removed */ -export const WIRE_PROTOCOL_VERSION = 20 as const; +export const WIRE_PROTOCOL_VERSION = 21 as const; export type P2PMessage = P2PAuthorityWire & ( | { type: "guest_deck"; deckData: unknown; displayName?: string; reservationToken?: string } diff --git a/crates/engine-inventory-gen/src/main.rs b/crates/engine-inventory-gen/src/main.rs index 5a63988519..90a220d2cd 100644 --- a/crates/engine-inventory-gen/src/main.rs +++ b/crates/engine-inventory-gen/src/main.rs @@ -1,6 +1,6 @@ //! Engine surface inventory generator. //! -//! Walks `crates/engine/src/types/` via `syn`, enumerates every `pub enum` and +//! Walks `crates/engine/src/` via `syn`, enumerates every `pub enum` and //! its variants with file:line, doc comments, and CR annotations. Auto-detects //! sibling-cluster smells (variants sharing a name root that look like //! parameterization candidates per the workspace "Parameterize, don't proliferate" @@ -37,6 +37,10 @@ struct Inventory { #[derive(Serialize)] struct EnumEntry { + /// The bare ident. Carried as a field because the map key is module-qualified: without it + /// a quoted grep (`"LayoutKind"`) — a form the discoverability gate is written in — went + /// from 1 hit to 0. MEASURED on this tree before the field was added. + name: String, file: String, line: usize, doc: String, @@ -74,12 +78,41 @@ struct ClusterSmell { cluster: SiblingCluster, } -const TARGET_DIR: &str = "crates/engine/src/types"; +/// Every directory whose `pub enum`s are engine surface a variant proposal must be able to +/// discover. CLAUDE.md makes an inventory grep the mandatory discoverability gate before +/// proposing a variant and scopes it to "any other engine enum", so the walk is the WHOLE +/// engine crate rather than a hand-kept subset: a `types/` + `analysis/` walk leaves 85 +/// top-level `pub enum`s under `crates/engine/src` structurally invisible to the gate +/// (`game/` 61, `ai_support/` 13, `parser/` 7, `database/` 4). That split is the standing +/// reason for the walk root, and it is re-derivable at any time by grouping the emitted +/// `file` fields. One root is also shorter than the list it replaces. +/// +/// THE TOTALS ARE A SNAPSHOT, NOT A STANDING FACT — deliberately stated apart from the split +/// above, because pinning the two together is what let one stale digit rot the other. Measured +/// 2026-08-13: 655 enums, 5320 variants. If they move, RE-TAKE them rather than re-label; and +/// prefer not to read them here at all, since every run prints its own totals on the success +/// line, which is the only current answer. What must stay true is the invariant those totals +/// are evidence for: the catalogue holds one entry per DECLARATION, so `enum_count` and the +/// declaration count are the same number. +/// +/// THE CATALOGUE KEY IS MODULE-QUALIFIED (`types::card::LayoutKind`), because the widened walk +/// makes ident collisions reachable and an ident key drops one side of every collision. Measured +/// on this tree: `LayoutKind` is declared in BOTH `types/card.rs` and `database/synthesis.rs`, +/// and the two are NOT variant-identical — `Omen` exists only in `card.rs`, `Specialize` only in +/// `synthesis.rs`. An ident key therefore answered the discoverability gate with ONE enum's +/// variant list, so an existence check for the shadowed variant returned a FALSE NEGATIVE — the +/// exact outcome CLAUDE.md makes this grep mandatory to prevent. It also made the output +/// irreproducible: which side survived was decided by `readdir` order. +/// +/// Grep still works on the qualified key: `LayoutKind` is a substring of +/// `types::card::LayoutKind`, so the skill's `rg "" data/engine-inventory.json` +/// existence check is unaffected. Unique keys additionally make the `BTreeMap` order — and so +/// the emitted JSON — a function of the source tree alone. +const TARGET_DIRS: &[&str] = &["crates/engine/src"]; const OUTPUT: &str = "data/engine-inventory.json"; fn main() -> Result<()> { let workspace_root = find_workspace_root()?; - let target = workspace_root.join(TARGET_DIR); let output = workspace_root.join(OUTPUT); let cr_re = Regex::new(r"CR \d{3}(?:\.\d+[a-z]?)?")?; @@ -87,28 +120,54 @@ fn main() -> Result<()> { let mut enums: BTreeMap = BTreeMap::new(); let mut sources: Vec = Vec::new(); - for entry in WalkDir::new(&target).into_iter().filter_map(|e| e.ok()) { - let path = entry.path(); - if path.extension().is_none_or(|ext| ext != "rs") { - continue; - } - let rel = path.strip_prefix(&workspace_root).unwrap_or(path); - sources.push(rel.display().to_string()); + for dir in TARGET_DIRS { + let target = workspace_root.join(dir); + // Sorted so the emitted `sources` list — and the walk itself — does not depend on + // `readdir` order. + // + // ALL THREE per-file failures propagate: walk, read, AND parse. The parse arm used to + // `continue` past unparseable files as "likely WIP" while `sources.push` ran BEFORE + // it, so such a file was LISTED as scanned while contributing zero enums — the + // inventory reported success over a file it never read, and the `add-engine-variant` + // existence gate got a FALSE NEGATIVE indistinguishable from a true one. A WIP file is + // a reason to fix the file, not to hand that gate a silent hole. + for entry in WalkDir::new(&target).sort_by_file_name() { + let entry = entry.with_context(|| format!("walk {}", target.display()))?; + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "rs") { + continue; + } + let module = module_path(path.strip_prefix(&target).unwrap_or(path)); + let rel = path.strip_prefix(&workspace_root).unwrap_or(path); - let content = - fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - let file = match syn::parse_file(&content) { - Ok(f) => f, - Err(_) => continue, // skip unparseable files (likely WIP) - }; + let content = + fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let file = + syn::parse_file(&content).with_context(|| format!("parse {}", path.display()))?; + // AFTER the parse, not before. The ordering is unobservable today (every failure + // above aborts the run and writes nothing), so this is the structural form of the + // fix rather than the fix: `sources` means "files this inventory actually read", + // and position now enforces that instead of the error handling continuing to. + sources.push(rel.display().to_string()); - for item in &file.items { - if let Item::Enum(e) = item { - if !is_pub(&e.vis) { - continue; + for item in &file.items { + if let Item::Enum(e) = item { + if !is_pub(&e.vis) { + continue; + } + let entry = build_enum_entry(e, &content, rel, &cr_re); + let key = if module.is_empty() { + e.ident.to_string() + } else { + format!("{module}::{}", e.ident) + }; + // Rust cannot declare two same-named top-level enums in one file, so a + // collision here means the key stopped identifying a declaration. Loud, + // because a silent overwrite is the defect this key shape exists to close. + if let Some(prev) = enums.insert(key.clone(), entry) { + anyhow::bail!("duplicate inventory key {key}: already held {}", prev.file); + } } - let entry = build_enum_entry(e, &content, rel, &cr_re); - enums.insert(e.ident.to_string(), entry); } } } @@ -165,6 +224,23 @@ fn find_workspace_root() -> Result { } } +/// The module path of a source file relative to the walk root: `types/card.rs` → `types::card`, +/// `game/mod.rs` → `game`, `lib.rs` → `` (crate root). +/// +/// Derived from the PATH rather than from `syn`, which is exact here because only top-level +/// `file.items` are catalogued — an enum inside an inline `mod` is not walked at all. +fn module_path(rel_to_root: &Path) -> String { + let mut parts: Vec = rel_to_root + .with_extension("") + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + if let Some("mod" | "lib" | "main") = parts.last().map(String::as_str) { + parts.pop(); + } + parts.join("::") +} + fn is_pub(vis: &syn::Visibility) -> bool { matches!(vis, syn::Visibility::Public(_)) } @@ -207,6 +283,7 @@ fn build_enum_entry(e: &ItemEnum, _source: &str, rel_path: &Path, cr_re: &Regex) let sibling_clusters = detect_clusters(&variants); EnumEntry { + name: e.ident.to_string(), file: rel_path.display().to_string(), line, doc, diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 7e1a930825..a0bfb98f6f 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -2021,13 +2021,23 @@ fn load_minimal_test_card_database() { /// game on the wire. Undo is a single-player affordance only. #[wasm_bindgen] pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { + restore_game_state_inner(json_str).map_err(|error| JsValue::from_str(&error)) +} + +/// The natively-callable body of [`restore_game_state`]. +/// +/// Split for the same reason — and in the same shape — as `resolve_all_inner` +/// and `scored_candidates_inner`: the `#[wasm_bindgen]` shell may only run on +/// wasm32. Off-target, `JsValue::from_str` panics inside a function that cannot +/// unwind, so a shell that merely RETURNS an error aborts the whole process with +/// SIGABRT instead of failing the test. A native test that calls the shell is +/// therefore only safe while restore succeeds; the moment it errors, the failure +/// is unreadable. Tests call this function. +fn restore_game_state_inner(json_str: &str) -> Result<(), String> { if MULTIPLAYER_MODE.with(|cell| cell.get()) { - return Err(JsValue::from_str( - "restore_game_state refused: undo is disabled in multiplayer sessions", - )); + return Err("restore_game_state refused: undo is disabled in multiplayer sessions".into()); } - let restored = decode_and_rehydrate_restored_game_state(json_str) - .map_err(|error| JsValue::from_str(&error))?; + let restored = decode_and_rehydrate_restored_game_state(json_str)?; let mut state = restored.state; // Reseed the skipped `rng` and fast-forward it to the offset captured at // export (issue #5466) so the restored game draws the values that would have @@ -2058,11 +2068,20 @@ pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { /// /// Differs from `restore_game_state` in two load-bearing ways: /// -/// 1. **Fresh RNG seed.** `restore_game_state` re-seeds from the saved -/// `rng_seed`, which rewinds the ChaCha20 stream to position 0 — -/// correct for undo (replay from origin) but wrong for resume -/// (subsequent draws would replay the pre-save sequence). This -/// function stamps a fresh seed so continued play diverges. +/// 1. **Fresh RNG seed.** `restore_game_state` re-seeds from the SAVED +/// `rng_seed` and fast-forwards to the saved `rng_word_pos`, so the +/// restored game continues the very stream the snapshot was taken on — +/// correct for undo, wrong for resume, where continued play must not +/// re-draw the values the pre-save timeline already committed to. This +/// function stamps a FRESH seed and resets `rng_word_pos` to 0 so the +/// resumed host diverges instead. +/// +/// It does NOT rewind to position 0: that was true only before issue +/// #5466 taught the restore path to carry the offset, and it survives +/// today just for snapshots written back then, which carry +/// `rng_word_pos == 0`. Both the shared decode chokepoint +/// (`PersistedGameState::into_game_state`) and `restore_game_state`'s +/// own repeat call `rehydrate_rng`. /// 2. **Atomic multiplayer-flag flip.** Sets `MULTIPLAYER_MODE` in the /// same call that loads state, so there's no window where a stray /// `restore_game_state` (undo) would be accepted on the resumed @@ -2634,6 +2653,39 @@ pub fn get_ai_tactical_action_proposal_with_diagnostics( })? } +/// Score one parallel-worker sample against the thread-local state. +/// +/// Split out of [`get_ai_scored_candidates`] so native tests can drive the real +/// scoring path: the `#[wasm_bindgen]` shell returns through `to_js`, which calls +/// the real `JSON.parse` binding and panics outside a wasm32 runtime (same reason +/// `resolve_all_inner` exists). +fn scored_candidates_inner( + state: &mut GameState, + difficulty: AiDifficulty, + ai_player: PlayerId, + rng_seed: u64, +) -> Vec<(GameAction, f64)> { + engine::game::layers::flush_layers(state); + + // A pool worker scores on its OWN entropy stream so root-parallel samples + // diverge (`AiWorkerPool` passes `baseSeed + index`); `score_candidates_with_session` + // names this the WASM divergence channel. `rng` is `#[serde(skip)]`, so + // `rng_seed` + `rng_word_pos` are its only carriers: writing one without the + // others splits the stream identity in two. A fresh ChaCha20 stream starts at + // word 0, so a surviving high-water leaves `advance_rng_high_water` guarding a + // position the live cursor is BEHIND and the next `capture_rng_word_pos` + // `.expect`-panics `HighWaterRegression` — which every simulated library + // shuffle performs, and so does `export_game_state_json`. Overwrite all three, + // exactly as `resume_multiplayer_host_state` does. + state.rng_seed = rng_seed; + state.rng = ChaCha20Rng::seed_from_u64(rng_seed); + state.rng_word_pos = 0; + + let config = create_config_for_players(difficulty, Platform::Wasm, state.players.len() as u8); + let session = ai_session_for(state); + score_candidates_for_parallel_worker(state, ai_player, &config, Some(&session)) +} + /// Score candidates inside an isolated AI worker. These are plain, /// serializable hints rather than capabilities: they cannot cross the action /// boundary until the live main engine reissues an exact proposal. @@ -2644,19 +2696,10 @@ pub fn get_ai_scored_candidates( rng_seed: u64, ) -> Result { let difficulty = AiDifficulty::from_label(difficulty); - with_state_mut(|state| { - engine::game::layers::flush_layers(state); - state.rng = ChaCha20Rng::seed_from_u64(rng_seed); - let config = - create_config_for_players(difficulty, Platform::Wasm, state.players.len() as u8); - let session = ai_session_for(state); - Ok(to_js(&score_candidates_for_parallel_worker( - state, - PlayerId(player_id), - &config, - Some(&session), - ))) - })? + let scores = with_state_mut(|state| { + scored_candidates_inner(state, difficulty, PlayerId(player_id), rng_seed) + })?; + Ok(to_js(&scores)) } /// Convert score-only worker output into an authority-bound proposal. @@ -5045,9 +5088,21 @@ mod rng_restore_bridge_tests { // fast-forward the reseeded stream to it, so a restored game draws the // values that would have come NEXT — not a replay from origin. This test // drives the real bridge entry points (nothing calls the engine seam - // directly): deleting `state.capture_rng_word_pos()` in export or - // `state.rehydrate_rng()` in restore turns it red. Asserts on consumed - // randomness, not the stored `rng_word_pos` integer. + // directly). Asserts on consumed randomness, not the stored + // `rng_word_pos` integer. + // + // REVERT-PROBES, all four RUN, not reasoned: + // * delete `state.capture_rng_word_pos()` in `export_game_state_json` + // ⇒ RED. That is the single-deletion discriminator. + // * the restore-side rehydration is DOUBLE-COVERED and therefore has + // no single-deletion discriminator: `restore_game_state` calls + // `rehydrate_rng` itself AND its `decode_restored_game_state` now + // routes through `PersistedGameState::into_game_state`, which + // rehydrates first. Deleting the bridge's own call ⇒ GREEN; + // deleting the chokepoint's ⇒ GREEN; deleting BOTH ⇒ RED. + // The bridge's own call is thus a harmless idempotent repeat, kept + // because `rehydrate_rng` is two absolute assignments from persisted + // fields. Do not read this test as covering it in isolation. clear_game_state(); load_minimal_test_card_database(); @@ -5091,3 +5146,372 @@ mod rng_restore_bridge_tests { clear_game_state(); } } + +/// Native coverage for the AI-scoring bridge's per-worker RNG re-seed. +/// +/// These are `#[cfg(test)]`, not `#[cfg(all(test, target_arch = "wasm32"))]`: the +/// `wasm32`-gated `mod tests` never executes in the native suite, and no Tilt +/// resource or CI job runs `wasm-pack test`. They drive `scored_candidates_inner` +/// rather than the `#[wasm_bindgen]` shell because the shell returns through +/// `to_js`, which calls the real `JSON.parse` binding and panics outside a wasm32 +/// runtime. +/// +/// The seam under test: `get_ai_scored_candidates` re-seeds the worker's entropy +/// stream. `rng` is `#[serde(skip)]`, so `rng_seed` + `rng_word_pos` are its only +/// carriers across a snapshot — writing one without the others splits the stream +/// identity in two, and the resulting high-water regression `.expect`-panics in +/// `GameState::capture_rng_word_pos`, which both `export_game_state_json` and +/// every simulated library shuffle perform. +#[cfg(test)] +mod ai_scoring_rng_bridge_tests { + use super::*; + use engine::types::ability::{AbilityDefinition, AbilityKind, Effect, ResolvedAbility}; + use engine::types::game_state::{StackEntry, StackEntryKind}; + use engine::types::identifiers::CardId; + use engine::types::zones::Zone; + use rand::RngCore; + + /// Carried over verbatim from `server-core`'s `GameSession::from_persisted` + /// rows: deliberately NOT block-aligned (ChaCha20 block 18, word 3), so a + /// fast-forward that only lands on block boundaries cannot pass by accident. + const SAVED_WORD_POS: u128 = 291; + const ORIGINAL_SEED: u64 = 0x0C0D_5EED; + const WORKER_SEED: u64 = 0x0C0E_5EED; + /// Equal seeds would make the C1/C2 rows vacuous. Compile-time, at module + /// scope, so no row can bypass it by skipping a helper. + const _: () = assert!(ORIGINAL_SEED != WORKER_SEED); + + /// Steps 1-7 of the fixture: plant the exact state a pool worker is handed. + /// Deliberately performs **no** scoring call, so the `#[should_panic]` row can + /// reuse it by omitting a call rather than by reconstructing setup. + fn plant_restored_worker_state() { + clear_game_state(); + + let mut state = GameState::new_two_player(ORIGINAL_SEED); + for offset in 0..3u64 { + engine::game::zones::create_object( + &mut state, + CardId(900 + offset), + PlayerId(0), + format!("Planted Library Card {offset}"), + Zone::Library, + ); + } + + // Premise 1: the planted high-water must be something a re-seed can + // regress past, or the rows below cannot discriminate. + assert!( + state.rng_word_pos < SAVED_WORD_POS, + "premise: a fresh state must start below the planted high-water" + ); + + // Plant it the way a shuffle does — advance the live stream, then capture + // it. Never a raw field write. + state.rng.set_word_pos(SAVED_WORD_POS); + state.capture_rng_word_pos(); + + // Scoreable position. This reproduces `resolve_all_tests::priority_state`'s + // recipe rather than calling it: that helper is a private `fn`, so a + // sibling test module cannot name it. + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + state.priority_player = PlayerId(0); + + GAME_STATE.with(|cell| cell.set(Some(state))); + + // Restore REFUSES without a card database (`rehydrate_restored_state_from_card_db` + // errors on absence alone), and these rows are about the RNG triple, not card + // data: `rehydrate_game_from_card_db` returns `()` and treats an unknown name as + // a no-op, so an EMPTY database satisfies the requirement without inventing card + // rows this module would then have to keep true. `restored_card_db_requirements_tests` + // is the row that pins the requirement itself. + CARD_DB.with(|cell| { + *cell.borrow_mut() = Some( + engine::database::CardDatabase::from_json_str("{}") + .expect("an empty card database must parse"), + ); + }); + + // The exact shipped plant: `AiWorkerPool` calls `worker.restoreState(..)` + // before every scoring call, and `restore_game_state` rehydrates the full + // triple. + let json = export_game_state_json().expect("planting must be exportable"); + clear_game_state(); + // The INNER body, not the `#[wasm_bindgen]` shell: off-wasm32 the shell's + // error path builds a `JsValue` inside a non-unwinding fn and SIGABRTs, so + // calling it here would turn any restore failure into an unreadable abort. + restore_game_state_inner(&json).expect("planting must be restorable"); + + // Premise 2, measured: the production restore resumed the saved position, + // so a zero observed below is this entry point's own policy rather than a + // lost serde field. + with_state(|state| { + assert_eq!( + state.rng_word_pos, SAVED_WORD_POS, + "premise: restore must resume the saved high-water" + ); + assert_eq!( + state.rng.get_word_pos(), + state.rng_word_pos, + "premise: restore must leave the live cursor on the saved high-water" + ); + }) + .expect("GAME_STATE must be initialized after restore"); + } + + /// Step 8 and nothing else: drive the real scoring path. + fn drive_scoring() -> Vec<(GameAction, f64)> { + with_state_mut(|state| { + scored_candidates_inner(state, AiDifficulty::VeryHard, PlayerId(0), WORKER_SEED) + }) + .expect("GAME_STATE must be initialized by plant_restored_worker_state") + } + + /// Row A. Revert-probe (RUN): deleting `state.rng_word_pos = 0;` — or the + /// whole commit — reds this row with + /// `HighWaterRegression { current: 291, requested: 0 }`. + #[test] + fn scoring_leaves_a_state_that_can_still_export() { + plant_restored_worker_state(); + drive_scoring(); + + // A production entry point on the very worker objects the pool holds: + // `exportState` is a live `EngineWorkerClient` message type. + export_game_state_json().expect("a scored worker must still be exportable"); + + clear_game_state(); + } + + /// Row B. Same mutant column as Row A by construction — this row buys the + /// *second* production seam (the route the AI simulation itself takes), not + /// extra discrimination. It is its own `#[test]` on fresh state because both + /// seams reach the same `.expect`-ing `capture_rng_word_pos`: sharing a test, + /// whichever ran first would abort the other. + #[test] + fn scoring_leaves_a_state_that_can_still_shuffle() { + plant_restored_worker_state(); + drive_scoring(); + + with_state_mut(|state| { + assert!( + !state.players[0].library.is_empty(), + "reach-guard: the shuffle below must have a library to act on" + ); + engine::game::library::resolve_and_apply_library_shuffle( + state, + PlayerId(0), + &mut Vec::new(), + ) + .expect("a scored worker must be able to shuffle"); + }) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + + clear_game_state(); + } + + /// Row C1. Behavioral (consumed randomness), not a field read, so writing the + /// field without moving the stream cannot satisfy it. + /// + /// Revert-probe (RUN): this row's probe is the **partial** revert — deleting + /// `state.rng = ..` or all three statements. It is GREEN under the + /// whole-commit revert, which leaves the live stream at `WORKER_SEED`@0. Do + /// not read it as whole-commit coverage. + #[test] + fn the_caller_supplied_seed_reaches_the_live_stream() { + plant_restored_worker_state(); + drive_scoring(); + + // `score_candidates_for_parallel_worker` takes `&GameState` and `GameState` + // carries no interior mutability, so nothing at or below the scoring call + // can advance the live stream: this reads back exactly what the entry + // point last wrote. + let mut expected = ChaCha20Rng::seed_from_u64(WORKER_SEED); + let expected_draws: Vec = (0..4).map(|_| expected.next_u32()).collect(); + + let live_draws: Vec = + with_state_mut(|state| (0..4).map(|_| state.rng.next_u32()).collect::>()) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + + assert_eq!( + live_draws, expected_draws, + "the live stream must be the caller's seed from origin, not the restored snapshot's" + ); + + clear_game_state(); + } + + /// Row C2 — the universal discriminator: RED on every mutant and on the + /// whole-commit revert. Both C rows compare against a stream freshly built + /// from `WORKER_SEED`, never against a clone of the post-scoring live stream: + /// a live-vs-restored comparison only proves internal consistency, which the + /// "delete all three" mutant also satisfies. + #[test] + fn the_scored_triple_round_trips_through_the_bridge() { + plant_restored_worker_state(); + drive_scoring(); + + let mut expected = ChaCha20Rng::seed_from_u64(WORKER_SEED); + let expected_draws: Vec = (0..4).map(|_| expected.next_u32()).collect(); + + let json = export_game_state_json().expect("a scored worker must still be exportable"); + clear_game_state(); + restore_game_state(&json).expect("a scored worker's export must be restorable"); + + let restored_draws: Vec = + with_state_mut(|state| (0..4).map(|_| state.rng.next_u32()).collect::>()) + .expect("GAME_STATE must be initialized after restore"); + + assert_eq!( + restored_draws, expected_draws, + "the round-tripped stream must be the caller's seed from origin" + ); + + clear_game_state(); + } + + /// Row 2 — the paired reach-guard. It does **not** red when the fix is + /// reverted and is not meant to: its job is to prove the panic is genuinely + /// reachable through the production shuffle seam from a triple of exactly this + /// shape, so the rows above are evidence rather than assertions about a call + /// that could never have failed. + /// + /// It deliberately omits `drive_scoring()` — calling the scoring entry point + /// first would let a panic from *that* call satisfy the `should_panic`. + /// Residual, stated rather than engineered away: `#[should_panic]` still + /// cannot prove which line panicked; the four sibling rows are what detect a + /// regression in the shared helper. + #[test] + #[should_panic(expected = "HighWaterRegression")] + fn an_incoherent_worker_triple_panics_on_its_next_shuffle() { + plant_restored_worker_state(); + + // Literally the pre-fix line, applied to the restored state. + with_state_mut(|state| state.rng = ChaCha20Rng::seed_from_u64(WORKER_SEED)) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + + with_state_mut(|state| { + engine::game::library::resolve_and_apply_library_shuffle( + state, + PlayerId(0), + &mut Vec::new(), + ) + .expect("unreachable: the incoherent triple must panic before this"); + }) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + } + + /// Row 3's extra fixture shape, applied to the already-restored state between + /// the plant and the scoring call. Ends by re-asserting the RNG triple is + /// untouched — object creation must not have moved the stream, or Row 3's + /// premise is gone. + fn shape_for_in_call_reach() { + with_state_mut(|state| { + state.active_player = PlayerId(0); + state.priority_passes.clear(); + + // The opponent needs a library for the resolved shuffle to act on. + for offset in 0..3u64 { + engine::game::zones::create_object( + state, + CardId(910 + offset), + PlayerId(1), + format!("Opponent Library Card {offset}"), + Zone::Library, + ); + } + + // Two player-0 battlefield permanents, each carrying one zero-cost + // activated `Effect::NoOp` ability. Three issued candidates keeps + // `deterministic_choice`'s `actions.len() == 1` arm from firing. + for offset in 0..2u64 { + let id = engine::game::zones::create_object( + state, + CardId(920 + offset), + PlayerId(0), + format!("Idle Permanent {offset}"), + Zone::Battlefield, + ); + if let Some(object) = state.objects.get_mut(&id) { + object.abilities = Arc::new(vec![AbilityDefinition::new( + AbilityKind::Activated, + Effect::NoOp, + )]); + } + } + + // The stack entry is OPPONENT-controlled: with an AI-owned stack, + // `low_value_priority_pass_from_actions` computes + // `owns_entire_stack == true` and `score_candidates_core` returns + // `[(PassPriority, 1.0)]` before any simulation runs. + let source_id = engine::game::zones::create_object( + state, + CardId(930), + PlayerId(1), + "Opponent Shuffle Source".to_string(), + Zone::Battlefield, + ); + state.stack = vec![StackEntry { + id: source_id, + source_id, + controller: PlayerId(1), + kind: StackEntryKind::ActivatedAbility { + source_id, + ability: Box::new(ResolvedAbility::new( + Effect::Shuffle { + target: engine::types::ability::TargetFilter::Controller, + }, + vec![], + source_id, + PlayerId(1), + )), + }, + }] + .into_iter() + .collect(); + + assert_eq!( + state.rng_word_pos, SAVED_WORD_POS, + "premise: shaping the fixture must not move the saved high-water" + ); + assert_eq!( + state.rng.get_word_pos(), + state.rng_word_pos, + "premise: shaping the fixture must not move the live cursor" + ); + }) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + } + + /// Row 3 — the in-call reach: the panic fires *inside* the scoring call, which + /// is what makes the shipped symptom (a silently degraded AI via the worker + /// pool's failure fallback) real rather than a trap for the next caller. + #[test] + fn scoring_itself_survives_a_simulated_opponent_shuffle() { + plant_restored_worker_state(); + shape_for_in_call_reach(); + + let issued = with_state_mut(|state| { + // Measure the list `score_candidates_core` will see, not the one it + // would have seen a flush ago: `scored_candidates_inner`'s FIRST + // statement is `flush_layers`, and `score_candidates_core` binds + // `build_decision_context_for_semantic_owner` downstream of it. + // `flush_layers` is idempotent (its `mem::replace` leaves the lattice + // `Clean`, and no arm re-dirties), so `drive_scoring()`'s own flush is + // a provable no-op and cannot move the candidate set between the two. + engine::game::layers::flush_layers(state); + engine::ai_support::build_decision_context_for_semantic_owner(state, PlayerId(0)) + .candidates + .len() + }) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + assert!( + issued >= 2, + "premise: gate #10's `actions.len() == 1` arm must not fire; engine issued {issued} candidates" + ); + + drive_scoring(); + + clear_game_state(); + } +} diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 7af74e9c8f..ee7ef04ccf 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -3295,7 +3295,10 @@ pub fn candidate_actions_broad_with_probe( // proposer declares or returns to ordinary priority. // (Scored by `phase_ai::policies::loop_shortcut::LoopShortcutPolicy`.) WaitingFor::LoopShortcut { - proposer, schema, .. + proposer, + schema, + declaration, + .. } => { // CR 732.2a: `UntilLethal` names no count, so it is legal ONLY against an offer // that narrowed no bound. `handle_declare_shortcut` rejects it outright against @@ -3327,15 +3330,23 @@ pub fn candidate_actions_broad_with_probe( // the AI's only non-declining option at such a node is an answer the engine // refuses. `ShortcutDecisionSchema::is_bounded()` is the engine's single // authority for "this producer narrowed the bound"; do NOT re-spell it as a - // comparison against `MAX_SHORTCUT_CYCLES`. Gated on empty `points` because this - // candidate carries `template: None`, which a published pin set fail-closes on. - if schema.points.is_empty() && schema.is_bounded() { + // comparison against `MAX_SHORTCUT_CYCLES`. + // + // The two admissible pin states, and nothing else: an EMPTY published point set + // (nothing to pin, so `template: None` is the complete answer), or a published set + // the offer ALREADY carries a declaration for. That `template` is the ENGINE'S OWN + // published declaration — the very value `handle_declare_shortcut` will validate — + // so there is exactly one pin authority at this node and the AI never constructs + // one. An offer with published points and NO declaration (a seat that never + // answered, or a `Conflicted` latch) still fail-closes: `declaration` is `None`, + // the conjunct below is false, and `DeclineShortcut` remains the only candidate. + if schema.is_bounded() && (schema.points.is_empty() || declaration.is_some()) { v.push(candidate( GameAction::DeclareShortcut { count: crate::analysis::decision_template::IterationCount::Fixed( schema.max_iterations, ), - template: None, + template: declaration.clone(), }, TacticalClass::Utility, Some(*proposer), @@ -5426,6 +5437,7 @@ mod tests { per_cycle: None, }, schema: crate::analysis::decision_template::ShortcutDecisionSchema::default(), + declaration: None, }; let candidates = candidate_actions(&state); @@ -7951,4 +7963,138 @@ mod tests { must be offered as a Mana-class ActivateAbility candidate" ); } + + // ── item-4 C2b row D6-n — the declare candidate is keyed to the offer's OWN declaration ── + + const D6N_PROPOSER: PlayerId = PlayerId(1); + + /// A BOUNDED offer publishing ONE point, carrying `declaration`. Called twice — once `None`, + /// once `Some` — so the two states differ in exactly one field and the mint spells the + /// `WaitingFor::LoopShortcut` anchor exactly once (a counted site in + /// `tests/integration/loop_shortcut_offer_writer_census.rs`). + /// + /// `ShortcutDecisionSchema::default()` carries `MAX_SHORTCUT_CYCLES`, i.e. `is_bounded()` is + /// FALSE — so `max_iterations` is set explicitly below the cap or the row would measure the + /// wrong conjunct. + fn d6n_offer( + declaration: Option, + ) -> GameState { + use crate::analysis::decision_template::{ + DecisionPoint, DecisionPointKind, DecisionSlot, IterationCount, ShortcutDecisionSchema, + }; + let mut state = GameState::new_two_player(42); + state.waiting_for = WaitingFor::LoopShortcut { + proposer: D6N_PROPOSER, + predicted_winner: None, + certificate: crate::analysis::loop_check::LoopCertificate { + unbounded: vec![], + win_kind: crate::analysis::loop_check::WinKind::Advantage, + mandatory: false, + residual_board_delta: crate::analysis::resource::BoardDelta::default(), + per_cycle: None, + }, + schema: ShortcutDecisionSchema { + iteration_count: IterationCount::Fixed(5), + max_iterations: 5, + points: vec![DecisionPoint { + slot: DecisionSlot::target(d6n_source()), + kind: DecisionPointKind::Targets { + legal_targets: vec![TargetRef::Player(PlayerId(0))], + min_targets: 1, + max_targets: 1, + ordered: false, + }, + }], + convoke_tappable_count: 0, + }, + declaration, + }; + state + } + + fn d6n_source() -> crate::types::game_state::YieldTarget { + crate::types::game_state::YieldTarget::ThisObject { + source_id: ObjectId(555), + incarnation: Some(1), + trigger_description: None, + } + } + + /// **Row D6-n — a bounded offer with published points and NO declaration enumerates only + /// `DeclineShortcut`.** + /// + /// CR 732.2a. The declare candidate carries the ENGINE's own published declaration, so an + /// offer that has none (a seat that never answered, or a `LoopAnswer::Conflicted` latch) must + /// fail closed rather than hand the search layer a fabricated pin set — which + /// `handle_declare_shortcut` would then refuse, i.e. an action that looks legal and is not. + /// + /// # Non-vacuity + /// + /// The positive arm is the SAME state one field apart: with `declaration: Some(..)` the + /// candidate appears AND carries that exact template. Without it, a generator that had + /// stopped emitting the candidate for any reason — including not running — would pass the + /// negative arm. + /// + /// REVERT-PROBE: replace `declaration.clone()` in the gate's `template:` with a fabricated + /// `Some(..)` and drop the `declaration.is_some()` conjunct ⇒ the negative arm flips. + /// + /// *What wrong implementation would still pass this row?* One that emits the candidate with + /// the RIGHT gate but a template it built itself — which the positive arm's equality against + /// the offer's own declaration refuses. + #[test] + fn d6n_a_points_carrying_offer_without_a_declaration_enumerates_only_decline() { + use crate::analysis::decision_template::{ + DecisionGroupKey, DecisionKind, DecisionSlot, DecisionTemplate, IterationCount, + PinnedDecision, ReplayMode, TargetPin, + }; + + // ── the negative arm ── + let bare = d6n_offer(None); + let WaitingFor::LoopShortcut { schema, .. } = &bare.waiting_for else { + unreachable!("the fixture parks on the offer") + }; + assert!( + schema.is_bounded(), + "reach-guard: the `Fixed` candidate is gated on `is_bounded()` too, so an unbounded \ + offer would withhold it for the wrong reason" + ); + assert!( + !schema.points.is_empty(), + "reach-guard: a NON-empty published pin set is the conjunct this row is about — with \ + `points` empty the candidate is emitted regardless of the declaration" + ); + assert_eq!( + crate::ai_support::legal_actions(&bare), + vec![GameAction::DeclineShortcut], + "CR 732.2a: with points published and no declaration to state, declining is the only \ + honest candidate" + ); + + // ── the paired positive: the same state one field apart ── + let declaration = DecisionTemplate { + owner: D6N_PROPOSER, + decisions: vec![PinnedDecision::Targets { + slot: DecisionSlot::target(d6n_source()), + targets: vec![TargetPin::Player(PlayerId(0))], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(5), + }, + key: DecisionGroupKey::from_sources(&[d6n_source()], DecisionKind::LoopChoice), + }; + let declared = d6n_offer(Some(declaration.clone())); + assert!( + crate::ai_support::legal_actions(&declared).contains(&GameAction::DeclareShortcut { + count: IterationCount::Fixed(5), + template: Some(declaration), + }), + "POSITIVE CONTROL: with the offer carrying a declaration the candidate returns, and \ + its `template` is the offer's OWN value — the AI never constructs one. got {:?}", + crate::ai_support::legal_actions(&declared) + ); + assert!( + crate::ai_support::legal_actions(&declared).contains(&GameAction::DeclineShortcut), + "the decline stays legal on both arms, which is what keeps the pair one axis apart" + ); + } } diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index 0337696d4b..4471bfbc17 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -33,9 +33,18 @@ pub type IterationIndex = u32; /// CR 603.3b (TriggerOrdering) / CR 732.2a (LoopChoice): which decision family a /// template captures. The `key` discriminant that lets one `decision_templates` Vec /// hold both the trigger-order templates B2 consults and the loop-choice templates -/// B3/B5 will add, so the gate can filter to `TriggerOrdering` only. `LoopChoice` has -/// no Phase-2 consumer (reserved), but the FILTER it enables is load-bearing now (the -/// gate must ignore non-ordering templates). +/// B3/B5 will add, so the gate can filter to `TriggerOrdering` only. The FILTER it +/// enables is load-bearing now (the gate must ignore non-ordering templates), and +/// `LoopChoice`'s own consumer is now known: it is the CROSS-EPISODE CARRIER. A +/// `LoopChoice` entry in `GameState::decision_templates` survives the CR 603.3b batch +/// boundary — `GameState::clear_ephemeral_trigger_order_templates`' retain predicate is +/// scoped to `TriggerOrdering` — so it is the vehicle a later episode's declaration can +/// ride, and it is still POPULATED BY PHASE 4 AND BY NOTHING TODAY. PINNED BY A SHIPPED +/// ROW: `fantastic_four_bounded_loop::r3b_driven_a_loop_choice_carrier_survives_a_whole_ +/// accepted_f4_drive` plants the `(kind × ephemerality)` cells on the real 4-player board +/// and drives a whole accepted CR 732.2a shortcut through `apply()` — the `LoopChoice` +/// cell survives (`3 → 2`) while the `TriggerOrdering` ephemeral cell beside it does not. +/// `loop_shortcut_ranking::r3b_*` is the seam-level statement of the same predicate. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum DecisionKind { TriggerOrdering, @@ -138,6 +147,39 @@ pub struct DecisionSlot { pub index: u8, } +impl DecisionSlot { + /// CR 601.2c (reached for a triggered ability via CR 603.3d) + CR 115.2: the + /// ANNOUNCEMENT target slot a BOUNDED-CYCLE ENTRY publishes. + /// + /// SCOPE OF THE AUTHORITY, stated narrowly because the number 0 is not globally + /// reserved: these two constructors are the single authority for the SUB-INDEX SHARED + /// BY `game::engine::entry_publishes_pin_slots` AND `GameState::loop_answer_journal` — + /// the publisher and the journal writers must agree, the way + /// `game::engine::object_decision_source` already makes them agree on the source half. + /// The `record_loop_pin` recast-template producer runs its OWN sub-index namespace over + /// the same source (0 for its `Targets`/`ConvokeTaps` pin, 1 for its `ManaColor` pin) + /// and deliberately does NOT route through here — its indices answer a different + /// question and coincide numerically only by accident. + /// + /// `pub`, not `pub(crate)`: `crates/engine/tests/integration/` is a SEPARATE CRATE, and + /// a `pub(crate)` constructor is unnameable there, so the integration rows would + /// hand-roll the very literal this constructor exists to delete (the shape + /// `object_decision_source`'s `pub(crate)` already forces on `may_source_key` in + /// `fantastic_four_bounded_loop.rs` and on `decision_source` in `natural_balance.rs`). + /// Every field of this `pub` struct in this `pub` module is already `pub`, so this adds + /// no reachability the type does not already have — it only removes the literal. + pub fn target(source: DecisionSource) -> Self { + Self { source, index: 0 } + } + + /// CR 603.5: the "may" gate on the SAME source — a second choice of one ability + /// instance, which is exactly what the sub-index exists to disambiguate. Same scoped + /// authority and same visibility rationale as [`DecisionSlot::target`]. + pub fn may(source: DecisionSource) -> Self { + Self { source, index: 1 } + } +} + /// CR 603.5: whether a "may" pin takes the optional action or declines it. Typed (not `bool`) /// so both outcomes are self-documenting at every construction and match site. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -153,6 +195,65 @@ pub enum UnlessPaymentOption { Decline, } +/// The observed answer to ONE published decision slot, from ONE seat, across the current +/// loop-detection window. Journalled under the key `(DecisionSlot, PlayerId)` +/// (`GameState::record_loop_answer`), so a seat can only ever answer for itself and the +/// sub-index keeps the two slots one source can publish (CR 601.2c target, CR 603.5 "may") +/// in two entries rather than collapsing them into one latched conflict. +/// +/// CR 732.2a says a shortcut proposal describes "a sequence of game choices, for all +/// players, that may be legally taken based on the current game state and the predictable +/// results of the sequence of choices", and that this sequence "may be a non-repetitive +/// series of choices, a loop that repeats a specified number of times, multiple loops, or +/// nested loops, and may even cross multiple turns". A series whose answers DIFFER between +/// iterations is therefore not, by itself, the conditional action the rule bars; what the +/// rule actually bars is narrower — "It can't include conditional actions, where the +/// outcome of a game event determines the next action a player takes." +/// +/// This engine refuses on a differing answer anyway. That is an ENGINE-CAPABILITY LIMIT, +/// DELIBERATELY MORE CONSERVATIVE THAN CR 732.2a REQUIRES — not a rule the CR states. +/// [`DecisionTemplate`] pins exactly one `MayChoice` per published slot per cycle, so a +/// non-uniform series has no representation here; it is the same "a choice a player could +/// only make reactively is one they cannot pin" disposition [`predictability_gate`]'s +/// CR 732.2a firewall doc already records. Failing to offer is the fail-closed direction: +/// strictly fewer offers, never a wrong pin. +// `Copy` is DROPPED here (and nowhere else): `LoopAnswerValue::Targets` carries a `Vec`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoopAnswer { + /// Every observed iteration answered this (slot, seat) pair identically. + Uniform(LoopAnswerValue), + /// Two observed iterations of the same (slot, seat) pair disagreed. Latched: + /// never returns to `Uniform`. See the type doc — the refusal this produces is this + /// engine's conservative policy, NOT a CR 732.2a mandate. + Conflicted, +} + +/// The VALUE of one observed answer. Variants are distinct CR choice KINDS — the same axis +/// [`PinnedDecision`] and [`DecisionPointKind`] already partition, and under the same +/// CR 732.2a umbrella ("a sequence of game choices, for all players") that makes each of +/// those ONE type rather than one type per rule section. It is therefore a PARTIAL +/// observation-side projection of that kind space (2 of the 6 [`DecisionPointKind`] +/// variants), TOTALIZED at the consumer's wildcard-free `(DecisionPointKind, +/// LoopAnswerValue)` match — not an exhaustive peer of either. +/// +/// Parameterizing [`LoopAnswer::Uniform`] rather than adding a `UniformTargets` sibling is +/// deliberate: a `X`/`TargetX` sibling pair is CLAUDE.md's sibling-cluster smell, and it +/// would put the KIND axis on the same enum level as the LATCH axis (`Uniform` vs +/// `Conflicted`), which is the layer conflation CLAUDE.md's enum-design rule forbids. +/// +/// DELIBERATELY DERIVES NO `Serialize`/`Deserialize`, exactly as [`LoopAnswer`] does: the +/// `#[serde(skip)]` on `GameState::loop_answer_journal` is enforced AT COMPILE TIME by the +/// absence of that derive, and adding one here would silently re-open persistence of a +/// transient window. ([`TargetPin`] and [`MayChoiceOption`] do derive it; the bar lives on +/// the two enums above them, which is where it was put.) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoopAnswerValue { + /// CR 603.5: take the optional action, or decline it. + May(MayChoiceOption), + /// CR 608.2b + CR 601.2c: the announced targets for one slot, in announcement order. + Targets(Vec), +} + /// One pinned decision. Variants are distinct CR choice KINDS (ordering / targeting / /// modal / optional-"may" / "[A] unless [B]" break), not a parameterization axis. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -321,15 +422,139 @@ pub enum DecisionPointKind { ManaColor { color: ManaColor }, } +/// CR 115.2 + CR 601.2c: WHO one announcement names, stored in re-bindable form. The +/// storable dual of [`ConcreteTarget`], which already draws exactly this two-way split at +/// the RESOLVED end of the same pipeline — so this adds no categorical boundary, it gives +/// the existing one a pre-resolution spelling. +/// +/// PROVENANCE, and it is the whole point of the type: a `Seat` here is a TARGET +/// (CR 601.2c), judged by `game::targeting::player_is_legal_target` — existence PLUS +/// CR 702.11c hexproof / CR 702.18a shroud / CR 702.16b protection. A merely CHOSEN player +/// (CR 115.10a — e.g. a CR 701.34a proliferate choice) is NOT this type; it stays +/// [`TargetPin::Player`] and keeps its existence-only authority. Two questions, two +/// spellings. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum AnnouncementSubject { + Object(DecisionSource), + Seat(PlayerId), +} + +/// Why a subject list is not a legal [`Ranking`]. Both clauses are refused at CONSTRUCTION, +/// which is what makes [`Ranking::head`] infallible — no `Option` leaks into the resolver, +/// and a wire-supplied list fails the LOAD rather than the drive (the same disposition +/// `reject_zero_bound_shortcut_offer` takes for a wire-sourced `max_iterations`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RankingError { + /// An empty ranking names nobody: there is no head to announce. + Empty, + /// CR 601.2c: an announcement names its choice per target. A repeated subject is not an + /// ordering — it is the same declaration twice, and it would make the tail unreachable. + DuplicateSubject, +} + +impl std::fmt::Display for RankingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => f.write_str("a ranking must name at least one announcement subject"), + Self::DuplicateSubject => { + f.write_str("a ranking must not name the same announcement subject twice") + } + } + } +} + +/// CR 732.1 + CR 732.2a: a DECLARED, ORDERED pre-commitment over announcement subjects for +/// ONE slot. A one-element ranking IS the old constant pin; that is the parameterization, +/// and it is why there is no `Ranked` sibling of [`TargetSchedule`]. +/// +/// CONSUMED AT AN EPISODE BOUNDARY, NEVER MID-DRIVE. Within one accepted drive only +/// [`Ranking::head`] is ever resolved (see `evaluate_schedule`): advancing to a later entry +/// because a game event removed the head would be the conditional action CR 732.2a bars, and +/// CR 732.2a also requires the sequence to END at a place where a player has priority — +/// which the drive-end handback already is. The tail is a pre-declaration for the NEXT +/// episode, validated by THAT episode's `validate_pins` against THAT episode's published +/// legal set. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "Vec")] +pub struct Ranking(Vec); + +impl Ranking { + /// The invariant is expressed ONCE, here — and this is the `TryFrom` the + /// `#[serde(try_from)]` shim runs, so a wire-supplied empty or duplicated list is + /// refused before any resolver sees it. + pub fn new(subjects: Vec) -> Result { + if subjects.is_empty() { + return Err(RankingError::Empty); + } + // Sort a view, never the payload: the declared ORDER is the whole point of the type. + // `Ord` is derived (it has to be — `DecisionTemplate` derives it for deterministic AI + // action ordering), so this is n·log n on a wire-length-bounded list rather than the + // quadratic scan a non-`Hash` payload would otherwise force. + let mut seen: Vec<&AnnouncementSubject> = subjects.iter().collect(); + seen.sort_unstable(); + if seen.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(RankingError::DuplicateSubject); + } + Ok(Self(subjects)) + } + + /// The constant case; every mechanical migration site uses it. Infallible — one element + /// can violate neither clause. + pub fn one(subject: AnnouncementSubject) -> Self { + Self(vec![subject]) + } + + /// The ONLY reader inside `evaluate_schedule` (CR 732.2a: a drive resolves the head and + /// never advances past it). Infallible by the non-empty invariant `new`/`one` enforce. + pub fn head(&self) -> &AnnouncementSubject { + &self.0[0] + } + + /// The whole list, for the callers that must see past the head without resolving it: + /// the hidden-source redaction walk (`game::visibility`) and the wire length bound. + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + +impl TryFrom> for Ranking { + type Error = RankingError; + + fn try_from(subjects: Vec) -> Result { + Self::new(subjects) + } +} + /// A pinned target. `ByIdentity` re-resolves to a live legal ObjectId each iteration /// (CR 608.2b); `Scheduled` is an iteration-indexed pure function (CR 732.2a). #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum TargetPin { ByIdentity(DecisionSource), - /// A CONSTANT target (CR 732.2a): this pin answers EVERY firing of its source within - /// the period with the declared player. A seat is state-independent by construction — - /// it can never denote "the newest copy" — so no iteration can turn the pin into the - /// conditional action CR 732.2a forbids. + /// A CONSTANT **CHOICE-class** seat (CR 115.10a): this pin answers EVERY firing of its + /// source within the period with the declared player. A seat is state-independent by + /// construction — it can never denote "the newest copy" — so no iteration can turn the + /// pin into the conditional action CR 732.2a forbids. + /// + /// # THE TWO CLASSES NOW HAVE TWO SPELLINGS, AND THIS ONE IS THE CHOICE CLASS + /// + /// CR 115.10a: "unless that object or player is identified by the word 'target' … it's + /// not a target". A seat this pin names was CHOSEN, not targeted, so [`resolve_target`] + /// judges it by `game::players::player_exists_for_choice` — EXISTENCE ONLY. Applying the + /// targeting-only exclusions (CR 702.11c hexproof / CR 702.18a shroud / CR 702.16b + /// protection) here would refuse legal CR 732.2a proposals; that over-veto is what + /// `game::engine`'s `a_shrouded_player_pin_is_still_published_by_the_offer_builder` and + /// `a_shrouded_seat_is_untargetable_yet_still_choosable_at_the_pin_recheck` (this + /// module) exist to keep out. Its live in-process producer is the CR 701.34a proliferate + /// arm (`game::engine::apply_action` → `record_loop_pin`). + /// + /// A CR 601.2c **TARGET**-class seat is a different question and takes the other + /// spelling: [`AnnouncementSubject::Seat`] inside a [`Ranking`] inside + /// [`TargetPin::Scheduled`], judged by `game::targeting::player_is_legal_target`. Both + /// TARGET-class producers emit that spelling — + /// `game::engine::record_trigger_target_answer` (the engine's own CR 601.2c + /// announcement journal) and `game::interaction::materialize_loop_shortcut_response` + /// (the human ingress of the same point kind). The spelling IS the provenance, so the + /// authority is selected by what the answer IS, never by who submitted it. Player(PlayerId), Scheduled(TargetSchedule), } @@ -366,13 +591,21 @@ pub enum IterationCount { /// enforced BY CONSTRUCTION: no variant carries any prior-outcome/event input, so a /// "react to what happened" target is unrepresentable (this is what collapses the /// predictability gate's "no conditional" clause into "total coverage"). +/// +/// AND THE PURITY INVARIANT IS NARROWER THAN "consults the live set", now that each step +/// carries a [`Ranking`] rather than a single subject: a variant consults the live legal set +/// to **re-bind the declared subject** (CR 400.7); it never uses the live set to +/// **substitute a different subject**. Selecting a different entry because a game event +/// removed the first is exactly the conditional action CR 732.2a bars — which is why a +/// `Ranking` is advanced only at an episode boundary, by a caller, never by +/// `evaluate_schedule`. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum TargetSchedule { - Constant(DecisionSource), - RoundRobin(Vec), + Constant(Ranking), + RoundRobin(Vec), /// Pre-declared switch-over: identity for [start, next-start). The switch point is /// FIXED IN ADVANCE (not triggered by an in-loop event), keeping it 732.2a-predictable. - Piecewise(Vec<(u32, DecisionSource)>), + Piecewise(Vec<(u32, Ranking)>), // NOTE (RULED Deferral 2): `IndexedClass { filter: TargetFilter, stride: i32 }` — an // iteration-indexed pick from an object class, evaluated via `matches_target_filter` // — is deferred to Phase 4/B3, where a live `FilterContext` source exists. @@ -444,9 +677,12 @@ pub enum ReplayFailure { /// name neither. Parameterizing the existing variant keeps ONE "target went illegal" /// failure instead of growing a per-pin-kind sibling cluster. IllegalTarget { slot: DecisionSlot, pin: TargetPin }, - /// CR 400.7: an ORDER pin's source (`Order`) is absent from the current battlefield - /// ⇒ the ordering template no longer matches ⇒ fall through to a normal manual - /// prompt. Raised ONLY for the `Order` pin kind, in any `ReplayMode`. + /// CR 400.7: an ORDER pin's source does not re-bind to a live ability instance — + /// [`resolve_ability_instance`] finds no object of that identity at that incarnation in a + /// zone it admits (`Battlefield`; also `Command` for a `ThisObject` source) ⇒ the ordering + /// template no longer matches + /// ⇒ fall through to a normal manual prompt. Raised ONLY for the `Order` pin kind, in any + /// `ReplayMode`. MissingSource { source: DecisionSource }, /// A `RoundRobin`/`Piecewise` schedule has no entry covering this iteration index. ScheduleExhausted { slot: DecisionSlot }, @@ -486,12 +722,84 @@ fn resolve_pin( state: &GameState, ) -> Result { match pin { - // CR 603.3b: replay this source's trigger at its pinned ordering position. The - // source must still be on the battlefield or the ordering template no longer - // matches (CR 400.7). + // CR 603.3b: replay this source's trigger at its pinned ordering position. The pin + // re-binds to the SAME live ability instance (CR 400.7 incarnation) still present in + // a zone the accessor admits — `Battlefield`, plus `Command` for a `ThisObject` + // source — not merely to something still on the battlefield. `Command` is admitted + // because a whole CLASS of sources functions from there (emblems CR 114.4; plane / + // scheme / conspiracy cards CR 113.6p; face-up plane and phenomenon cards CR 901.7; + // Eminence commanders CR 113.6b), but the accessor tests zone presence and identity + // only — whether a given ability functions is `game::functioning_abilities`' + // question, not this accessor's. + // + // Resolving an `Order` pin GRANTS NO CAPABILITY, and the six points that consume + // `resolve`'s output split two ways. FIVE read the vec's ELEMENTS, and not one of them + // reads a `ConcreteDecision::Order`'s PAYLOAD: `inject_pinned_answer`'s two arms + // `find_map` for their own kind, `pinned_targets_for_source` and + // `pinned_mana_color_for_source` skip it in an `if let`, and the `ManaPayment` beat's + // exhaustive match aborts on its mere presence (`Order { .. }`, fields discarded). ONE + // — the per-cycle re-check in `materialize_fixed_shortcut` — reads only the `is_err()` + // VERDICT and discards the vec entirely. (LABELED CODE READ: the six consumption + // points, enumerated and read at both revisions.) + // + // At the five ELEMENT readers an ORDER-ONLY template still fails closed; what the + // re-bind changes is only WHERE: the abort moves out of `resolve()` and into the + // consumer — the trailing `Err(RecastAbort)` of the two `pinned_*` seams, the + // `find_map`'s `ok_or(RecastAbort)` in the injector, the match arm at the + // `ManaPayment` beat. + // + // At the VERDICT reader nothing moves downstream, because there is nothing downstream + // to move to: that gate consults only `resolve`'s `Result`. A pin that fails to + // re-bind breaks the cycle loop AT THAT ITERATION, committing only the cycles before + // it — and for a source the accessor never admits, that is iteration 0, so no cycle + // commits at all. Once every pin re-binds, the gate stops breaking; what happens after + // it is governed by the element readers above, not by this gate. That is true for + // every template shape, Order-only and mixed alike, because the gate never looks at an + // element. + // + // For a mixed template whose OTHER pins themselves resolve, the payoff lands at an + // element reader that has an element of its own kind. Two of the five answer from a + // `Targets` element — `pinned_targets_for_source` and the injector's + // `TriggerTargetSelection` arm — and the one measured here is + // `pinned_targets_for_source`: on the measured value `[Order @ command zone, + // Targets @ battlefield]`, with the `Order` pin re-binding and the `Targets` pin itself + // resolving, it now returns that `Targets` pin's own answer, where before the + // command-zone `Order` pin discarded it and the seam aborted. Where a pin does NOT + // resolve, the template still fails whole at every one of the six consumers, because + // `resolve` is a per-pin `Result` collect that each consumer gates on before reading + // anything: a mixed template carrying a `Targets` pin whose own target is not on the + // battlefield (CR 608.2b) aborts after this commit exactly as before it. It is NOT + // that every element reader succeeds on such a template: a reader that finds no + // element of its own kind (`pinned_mana_color_for_source`, the injector's `MayChoice` + // arm) still reaches its `Err(RecastAbort)`, and the `ManaPayment` beat still aborts on + // the `Order` element's mere presence — after this commit exactly as before it, for + // every mixed template. + // + // The bound that makes this capability-neutral at the `DeclareShortcut` wire is DRIVE + // EQUALITY, not submittability. When an `Order` element re-binds, resolving WITH it + // yields the identical answer to resolving without it at every one of the six + // consumers, EXCEPT at the `ManaPayment` beat, where its presence forces + // `Err(RecastAbort)` — strictly more fail-closed, never more permissive. When it does + // NOT re-bind, the `Result` collect fails the whole template at every consumer; this + // commit changes which sources re-bind, never that disposition. Where the same + // template minus its `Order` pins is itself submittable, that drive was therefore + // already expressible by omitting them. It is NOT always submittable: `pin_slot` + // addresses an `Order` pin to `{source, index 0}` and `validate_pins`' `Order` arm is + // the one arm that checks nothing, so an `Order` pin can be the sole cover of a + // required point, and dropping it then fails `predictability_gate`. That shape is + // pre-existing and zone-independent — this commit changes only which sources can be + // spelled into it, never the shape itself. + // + // No template's ACCEPTANCE changes here: `declaration_conforms` is + // `predictability_gate` + `validate_pins`, and neither reaches `resolve_pin`. What + // changes is what an already-accepted template does. (Pinned by + // `game::engine::stage2_injector_tests::a_command_zone_order_pin_stops_poisoning_the_template_without_gaining_capability` + // rows R/N/N2/P/P-minus; the structural clauses above are labeled code reads.) PinnedDecision::Order { source, pos } => { - let id = resolve_source(source, state).ok_or_else(|| ReplayFailure::MissingSource { - source: source.clone(), + let id = resolve_ability_instance(source, state).ok_or_else(|| { + ReplayFailure::MissingSource { + source: source.clone(), + } })?; Ok(ConcreteDecision::Order { source: id, @@ -593,36 +901,58 @@ fn resolve_target( // `static_abilities::player_cannot_be_targeted_by`. It is NOT enforced here, and // does not need to be. // - // OPEN RESIDUAL — the object-growth route. Stated in full here, because no shipped - // file states it elsewhere. INVARIANT: a `TargetPin::Player` must never reach + // THE RESIDUAL'S DEFERRED FIX SHAPE HAS LANDED FOR THE IN-PROCESS SURFACE: the two + // classes now have TWO SPELLINGS, so they are distinguishable AT THIS SEAM by the + // variant alone. `TargetPin::Player` is the CHOICE class (this arm, existence only); + // a CR 601.2c TARGET-class seat is `AnnouncementSubject::Seat` inside a `Ranking` + // inside `TargetPin::Scheduled`, resolved by the arm below through + // `targeting::player_is_legal_target`. THE PREVIOUS TEXT WAS SCOPED TOO NARROWLY and + // is corrected rather than deleted: it named only `record_loop_pin` (three sites, + // one of which — the CR 701.34a proliferate-target arm — is a genuine CHOICE) and + // was SILENT about the `record_loop_answer` route, along which + // `game::engine::record_trigger_target_answer` did produce a TARGET-class + // `TargetPin::Player` from a `WaitingFor::TriggerTargetSelection` announcement. That + // producer, and the human ingress of the same point kind + // (`game::interaction::materialize_loop_shortcut_response`), now emit the ranked + // spelling. So no IN-PROCESS producer can reach this arm with a target any more, and + // "not live today" is now an enforced property rather than a census result — pinned + // by `tests/integration/loop_shortcut_seat_pin_census.rs`. + // + // OPEN RESIDUAL — the object-growth route, which is why this arm is still not a + // sufficient authority on its own. INVARIANT: a `TargetPin::Player` must never reach // materialization validated only against a legal set derived from the declared pins // themselves. `try_offer_object_growth_shortcut` builds its points through // `pinned_decisions_to_points`, whose legal sets come FROM the pins, so on that // route the offer would ratify its own pin — and CR 732.2a admits only a sequence // "that may be legally taken based on the current game state", which a self-derived - // set cannot establish. NOT live today FROM ANY IN-PROCESS PRODUCER — and the scope - // word is load-bearing: the one `record_loop_pin` arm that can push a - // `TargetPin::Player` is the CR 701.34a proliferate-target arm, and a proliferate - // choice is not a target, so THIS call is its correct authority. + // set cannot establish. That hazard is class-independent: it is about WHERE the + // legal set came from, not about which spelling the pin uses, so the split narrows + // this residual's producer surface without closing it. // - // PINS ALSO ARRIVE WIRE-SOURCED, and no in-process invariant covers that. - // `LoopActionContext` is `#[serde(from = "LoopActionContextRepr")]`, and that shim's - // `From` impl installs the deserialized vector verbatim (`pins: r.pins`), so a - // restored save can carry a Player pin of UNKNOWN class. - // `GameState::migrate_transient_loop_sequence` keeps a loaded sequence ONLY for a - // save captured in a `LoopShortcut` / `RespondToShortcut` window, and on that route - // the pins are replayed by the accept→materialize drive through + // WHAT REMAINS OPEN, PRECISELY — PINS ARRIVE WIRE-SOURCED, and no in-process + // invariant covers that. `LoopActionContext` is + // `#[serde(from = "LoopActionContextRepr")]`, and that shim's `From` impl installs + // the deserialized vector verbatim (`pins: r.pins`), so a restored save can still + // carry a `TargetPin::Player` a foreign writer MEANT as a target. The wire carries + // the spelling, not the writer's intent, so the split cannot adjudicate that case — + // it can only make the honest spelling available and make the in-process producers + // use it. `GameState::migrate_transient_loop_sequence` keeps a loaded sequence ONLY + // for a save captured in a `LoopShortcut` / `RespondToShortcut` window, and on that + // route the pins are replayed by the accept→materialize drive through // `build_recast_template` → `decision_template::resolve`, i.e. through THIS call — // so a wire pin's EXISTENCE half is authority-enforced here too. Same class as the // wire-sourced `max_iterations` defect `reject_zero_bound_shortcut_offer` closes: a // load-seam value the in-process producer census cannot see. // - // The residual opens the moment any producer — IN-PROCESS OR WIRE — puts a - // TARGET-class Player pin into `LoopActionContext.pins`. DAMAGE MODE then: - // `CycleOutcome::Abort` rolls back only the crossing cycle, so cycles `0..k` stay - // committed under a pin no authority ever validated. Deferred fix shape: - // provenance-type the pin so the two classes are distinguishable at this seam — a - // change to a serialized type, hence not this phase. + // DAMAGE MODE if a wire producer does that: `CycleOutcome::Abort` rolls back only + // the crossing cycle, so cycles `0..k` stay committed under a pin no authority ever + // validated. Note what is NOT the damage mode, because the two are easy to conflate: + // a correctly-spelled ranked seat that becomes an illegal target mid-drive is + // handled BY CONSTRUCTION and is not a residual at all — `evaluate_schedule` + // resolves `head()` only and never slides to a later entry, so the drive aborts at + // the boundary (CR 115.7a: "if a target can't be changed to another legal target, + // the original target is unchanged, even if the original target is itself illegal by + // then"; CR 732.2a bars the conditional action sliding would be). TargetPin::Player(p) => crate::game::players::player_exists_for_choice(state, *p) .then_some(ConcreteTarget::Player(*p)) .ok_or_else(illegal), @@ -633,8 +963,14 @@ fn resolve_target( /// Re-bind a stored `DecisionSource` to a live battlefield `ObjectId`. The battlefield /// analogue of `GameState::is_priority_yielded`'s matching arms. KIND-AGNOSTIC: returns /// `None` on no match, and the CALLER maps that to the pin-kind-appropriate -/// `ReplayFailure` (`Order` ⇒ `MissingSource`, a target ⇒ `IllegalTarget`) — the single -/// seam where G2's per-pin-kind failure selection is realized. +/// `ReplayFailure` (`Order` ⇒ `MissingSource`, a target ⇒ `IllegalTarget`) — G2's +/// per-pin-kind failure selection. +/// +/// The `Order` pin and the two `game::engine` slot seams enter through +/// [`resolve_ability_instance`] rather than here; this function is that accessor's +/// BATTLEFIELD DISJUNCT, and it remains the whole answer for the TARGET path +/// ([`resolve_target`]'s `Object` arm and `evaluate_schedule`'s `Object` head), where the +/// battlefield filter IS the CR 608.2b legality re-check. pub(crate) fn resolve_source(src: &DecisionSource, state: &GameState) -> Option { match src { // CR 400.7: bind ONE incarnation — a re-entered permanent bumps `incarnation` @@ -663,19 +999,95 @@ pub(crate) fn resolve_source(src: &DecisionSource, state: &GameState) -> Option< } } +/// CR 608.2b + CR 114.4 + CR 113.6p: re-bind a stored `DecisionSource` to the live ABILITY +/// INSTANCE it identifies — a different question from [`resolve_source`]'s, and the reason +/// this accessor exists rather than a second spelling at each caller. +/// +/// THE ONLY SPELLING of that question, as of the commit that migrated the last two callers. +/// Every production asker routes here: `resolve_pin`'s `Order` arm and `evaluate_schedule`'s +/// `Seat` arm call it directly, and `game::engine::slot_source_prompted` is the `bool`-valued +/// wrapper its four seams use — `inject_pinned_answer`'s `TriggerTargetSelection` and +/// `MayChoice` `find_map` guards, `pinned_targets_for_source`, and +/// `pinned_mana_color_for_source`. There is no bare `resolve_source` slot comparison left in +/// `game::engine`. +/// +/// A *pin's* source identifies a TARGET, so [`resolve_source`] is deliberately +/// BATTLEFIELD-ONLY and that filter IS the CR 608.2b legality re-check: a pinned target that +/// left the battlefield must stop matching, and it must not be widened. A *slot's* source +/// only identifies WHICH ability instance prompts. CR 114.2 puts an EMBLEM — "both owned and +/// controlled by that player" — into the COMMAND zone; that is PLACEMENT. Whether the thing +/// placed there can prompt at all is a separate rule, and it is per ABILITY rather than per +/// object: CR 113.6b, "an ability that states which zones it functions in functions only from +/// those zones". So the command-zone disjunct lives here, scoped to object identity plus the +/// pinned CR 400.7 incarnation, exactly as the battlefield arm is. +/// +/// The class this disjunct serves is every command-zone-functioning ability source, not +/// emblems alone — which is why the filter is NOT tightened to `obj.is_emblem`: +/// +/// * **emblems** — CR 114.4, "abilities of emblems function in the command zone"; +/// * **planes, schemes, conspiracies** — CR 113.6p, whose enumeration is "emblems, plane +/// cards, vanguard cards, scheme cards, and conspiracy cards"; `database::synthesis`'s +/// `synthesize_planechase` / `synthesize_archenemy` / `synthesize_conspiracy` stamp +/// `Zone::Command` onto each such face's triggers and statics; +/// * **phenomena** — CR 901.7, "any abilities of a FACE-UP plane card or phenomenon card in +/// the command zone function from that zone" (CR 113.6p's enumeration does not reach this +/// card type; `synthesize_planechase` covers both faces); +/// * **Eminence commanders** — an ordinary card whose own ability declares its zones, i.e. +/// CR 113.6b again, opted in per definition rather than per card type. +/// +/// `game::functioning_abilities` is where that opt-in is read (`active_zones` for a static, +/// `trigger_zones` for a trigger), and it — not this accessor — decides whether an ability +/// functions. A single object can carry a Command-functioning static and Battlefield-only +/// triggers at once, so an object-level zone test could never be the functioning authority; +/// identity is what this accessor selects on. +/// +/// RESIDUAL, measured and disclosed rather than closed: the command disjunct is +/// `ThisObject`-only. `AllCopies` matches by CARD identity and is battlefield-only, so a +/// command-zone source spelled by card identity — a conspiracy, an Eminence commander — still +/// resolves `None` and fails closed. Graveyard / exile / hand sources resolve `None` too ⇒ +/// every caller fails closed (`game::engine::slot_source_prompted` aborts the drive to manual +/// play; `evaluate_schedule`'s `Seat` arm raises `IllegalTarget`). +pub(crate) fn resolve_ability_instance( + src: &DecisionSource, + state: &GameState, +) -> Option { + if let Some(id) = resolve_source(src, state) { + return Some(id); + } + let YieldTarget::ThisObject { + source_id, + incarnation, + .. + } = src + else { + return None; + }; + state + .objects + .get(source_id) + .filter(|o| o.zone == Zone::Command) + .filter(|o| incarnation.is_none() || *incarnation == Some(o.incarnation)) + .map(|o| o.id) +} + /// CR 732.2a predictability firewall: EXHAUSTIVE `match` over [`TargetSchedule`] with NO /// wildcard arm — a future outcome-carrying variant breaks this build (mirrored by the /// `target_schedule_predictability_firewall_is_exhaustive` test). Every variant is a -/// pure fn of (iteration index, live set); each selects a `DecisionSource`, then -/// re-binds it to a live legal object (CR 608.2b, via `resolve_source`). +/// pure fn of (iteration index, live set); each selects a [`Ranking`], whose HEAD is then +/// re-bound against live state (CR 608.2b). +/// +/// HEAD-ONLY, and that is the CR 732.2a clause rather than a simplification: skipping to a +/// later entry because the head became illegal is a conditional action ("the outcome of a +/// game event determines the next action a player takes"). The tail is the NEXT episode's +/// pre-declaration; only an episode boundary may advance it. fn evaluate_schedule( sched: &TargetSchedule, slot: &DecisionSlot, iter: IterationIndex, state: &GameState, ) -> Result { - let source: &DecisionSource = match sched { - TargetSchedule::Constant(src) => src, + let ranking: &Ranking = match sched { + TargetSchedule::Constant(ranking) => ranking, TargetSchedule::RoundRobin(schedule) => { if schedule.is_empty() { return Err(ReplayFailure::ScheduleExhausted { slot: slot.clone() }); @@ -686,15 +1098,42 @@ fn evaluate_schedule( .iter() .filter(|(start, _)| *start <= iter) .max_by_key(|(start, _)| *start) - .map(|(_, src)| src) + .map(|(_, ranking)| ranking) .ok_or_else(|| ReplayFailure::ScheduleExhausted { slot: slot.clone() })?, }; - resolve_source(source, state) - .map(ConcreteTarget::Object) - .ok_or_else(|| ReplayFailure::IllegalTarget { - slot: slot.clone(), - pin: TargetPin::Scheduled(sched.clone()), - }) + match ranking.head() { + // CR 608.2b: a pinned TARGET object must still be a live battlefield object — this + // is exactly the pre-parameterization `Constant` behaviour, unchanged. + AnnouncementSubject::Object(src) => resolve_source(src, state).map(ConcreteTarget::Object), + // CR 601.2c + CR 115.1: a ranked seat is a TARGET, so it is judged by + // `targeting::player_is_legal_target` (existence + CR 702.11c hexproof / + // CR 702.18a shroud / CR 702.16b protection) rather than by existence alone. Its two + // trailing arguments describe THE ABILITY INSTANCE that would name the seat, not a + // target object — hence `resolve_ability_instance` (which admits the command zone — + // CR 114.4 / CR 113.6p) and NOT `resolve_source` (battlefield-only, and correctly so + // for a pin). + // + // A `None` ANYWHERE in this chain falls through to the `ok_or_else` below: with no + // live ability instance the engine cannot certify that the object it would ask the + // CR 702.11c question about still IS that instance (CR 400.7 / CR 608.2b), and + // CR 732.1 + CR 732.2a make refusing a shortcut free — "the player with priority MAY + // suggest a shortcut" is a permission, not an obligation, so no declaration published + // just means the table plays the loop out manually. (CR 732.2b is the RESPONDER rule + // — each OTHER player accepting or shortening a proposal that already exists — so it + // cannot govern a proposer that publishes nothing.) Announcing a target we cannot + // certify is not + // free. This is the fail-closed branch, not an oversight. + AnnouncementSubject::Seat(p) => resolve_ability_instance(&slot.source, state) + .and_then(|src_id| state.objects.get(&src_id).map(|o| (src_id, o.controller))) + .filter(|(src_id, ctrl)| { + crate::game::targeting::player_is_legal_target(state, *p, *src_id, *ctrl) + }) + .map(|_| ConcreteTarget::Player(*p)), + } + .ok_or_else(|| ReplayFailure::IllegalTarget { + slot: slot.clone(), + pin: TargetPin::Scheduled(sched.clone()), + }) } /// CR 732.2a firewall: a `Scheduled` template may auto-drive a shortcut only if every @@ -942,6 +1381,57 @@ pub fn validate_pins( Ok(()) } +/// CR 732.2a: THE SINGLE AUTHORITY for *"is this declaration a legal answer to this offer's +/// schema?"* — [`predictability_gate`]'s COVERAGE half and [`validate_pins`]' VALUE half, run +/// together against a `required` slot list derived HERE from `schema.points` rather than by +/// each caller. +/// +/// Three sites ask that question — `game::engine::handle_declare_shortcut` (the declare +/// firewall), `game::interaction::materialize_loop_shortcut_response` (the human ingress) and +/// `game::engine::build_bounded_declaration` (the engine's own publisher) — and a declaration +/// PUBLISHED under one predicate but ACCEPTED under another is the divergence this exists to +/// make unrepresentable: `declaration.is_some()` is read by `ai_support::candidates` as "the +/// declare handler will take this", and only a shared predicate makes that true. +/// +/// # `validated_range` STAYS A PARAMETER, and that is a measurement, not a hedge +/// +/// The two pre-existing call sites did NOT pass the same range, so folding one in would adopt +/// one site's semantics for the other: +/// +/// * the declare firewall passes `game::engine::shortcut_validated_range(&count, template)` — +/// the range the ACCEPTED COUNT will drive; +/// * the interaction decoder passes `1`, correct by construction there because it emits only +/// ITERATION-INVARIANT pins. That is the property, stated as a property because the variant +/// list has already moved once: the decoder emits [`TargetPin::ByIdentity`] (which +/// [`resolve_target`] resolves without reading `iteration` at all) and +/// [`TargetPin::Scheduled`] carrying [`TargetSchedule::Constant`], whose arm of +/// [`evaluate_schedule`] selects its [`Ranking`] without consulting the index — unlike the +/// `RoundRobin` / `Piecewise` arms beside it, which that decoder does not emit. Its verdict +/// is therefore identical at any range ≥ 1. +/// +/// Ranges are nested rather than contradictory — `0..n` re-checks are a superset of `0..m` for +/// `m <= n`, so a wider range is strictly stricter — which is why a PUBLISHER must validate at +/// the widest range it could be declared with: passing there implies passing at every count a +/// declarer may name. +/// +/// # Returns `bool`, deliberately +/// +/// All three callers discard the failure KIND (they already spelled `.is_err() || .is_err()`) +/// and their dispositions have nothing in common: manual-play handback via +/// `reject_shortcut_declaration`, `InteractionReasonCode::ConstraintUnsatisfied`, and "publish +/// no declaration". A union error type would have no reader. [`predictability_gate`] and +/// [`validate_pins`] stay public and typed for the rows that assert on the specific violation. +pub fn declaration_conforms( + schema: &ShortcutDecisionSchema, + template: &DecisionTemplate, + validated_range: IterationIndex, + state: &GameState, +) -> bool { + let required: Vec = schema.points.iter().map(|p| p.slot.clone()).collect(); + predictability_gate(template, &required).is_ok() + && validate_pins(schema, template, validated_range, state).is_ok() +} + #[cfg(test)] mod tests { use super::*; @@ -963,6 +1453,16 @@ mod tests { } } + /// The one-element ranking every pre-parameterization schedule site now spells: a + /// `Ranking::one(Object(src))` IS the old `Constant(src)`, which is the migration. + fn obj_rank(src: DecisionSource) -> Ranking { + Ranking::one(AnnouncementSubject::Object(src)) + } + + fn seat_rank(player: PlayerId) -> Ranking { + Ranking::one(AnnouncementSubject::Seat(player)) + } + /// T6: `DecisionPointKind` serializes externally tagged (`{"ConvokeTaps":{...}}`) — the /// FE-consumable JSON shape the WASM bridge passes through — and round-trips equal. Revert: /// switching the enum to internal/adjacent tagging changes the top-level key and fails. @@ -1174,8 +1674,8 @@ mod tests { decisions: vec![PinnedDecision::Targets { slot, targets: vec![TargetPin::Scheduled(TargetSchedule::RoundRobin(vec![ - this_obj(20, None), - this_obj(21, None), + obj_rank(this_obj(20, None)), + obj_rank(this_obj(21, None)), ]))], }], replay: ReplayMode::Scheduled { @@ -1247,8 +1747,8 @@ mod tests { decisions: vec![PinnedDecision::Targets { slot: slot.clone(), targets: vec![TargetPin::Scheduled(TargetSchedule::Piecewise(vec![ - (0, this_obj(20, None)), - (2, this_obj(21, None)), + (0, obj_rank(this_obj(20, None))), + (2, obj_rank(this_obj(21, None))), ]))], }], replay: ReplayMode::Scheduled { @@ -1269,7 +1769,7 @@ mod tests { slot, targets: vec![TargetPin::Scheduled(TargetSchedule::Piecewise(vec![( 1, - this_obj(20, None), + obj_rank(this_obj(20, None)), )]))], }], replay: ReplayMode::Scheduled { @@ -1695,9 +2195,9 @@ mod tests { #[test] fn target_schedule_predictability_firewall_is_exhaustive() { let variants = [ - TargetSchedule::Constant(this_obj(1, None)), - TargetSchedule::RoundRobin(vec![this_obj(1, None)]), - TargetSchedule::Piecewise(vec![(0, this_obj(1, None))]), + TargetSchedule::Constant(obj_rank(this_obj(1, None))), + TargetSchedule::RoundRobin(vec![obj_rank(this_obj(1, None))]), + TargetSchedule::Piecewise(vec![(0, obj_rank(this_obj(1, None)))]), ]; for sched in &variants { // NO wildcard arm: each variant is a pure fn of (iteration index, live set), @@ -1794,4 +2294,641 @@ mod tests { "no live ManaPayment/pending_cast ⇒ UnpayableConvoke (never fabricate taps)" ); } + + // ── item-4 R1 — the parameterized announcement subject (`Ranking`) ── + + /// Insert an object into an arbitrary zone. `bf_object` above is battlefield-only, and + /// rows R1-h/i need the CR 114.4 command zone and the graveyard. + fn zoned_object(state: &mut GameState, id: u64, zone: Zone) -> ObjectId { + let oid = ObjectId(id); + let mut o = GameObject::new( + oid, + CardId(id), + PlayerId(0), + "Ability Source".to_string(), + zone, + ); + o.incarnation = 3; + state.objects.insert(oid, o); + oid + } + + /// CR 702.11c: give `player` hexproof through the transient-grant path + /// `static_abilities::player_has_hexproof` already reads + /// (`transient_grants_static_mode_to_player`). No layer pass is needed — that reader + /// scans `transient_continuous_effects` directly. + fn grant_player_hexproof(state: &mut GameState, player: PlayerId) { + use crate::types::ability::{ContinuousModification, Duration, TargetFilter}; + use crate::types::statics::StaticMode; + state.add_transient_continuous_effect( + ObjectId(9001), + player, + Duration::UntilEndOfTurn, + TargetFilter::SpecificPlayer { id: player }, + vec![ContinuousModification::AddStaticMode { + mode: StaticMode::Hexproof, + }], + None, + ); + } + + /// One `Targets` pin carrying one `Scheduled` pin, slotted on `slot_source`. + fn ranked_template(slot_source: DecisionSource, sched: TargetSchedule) -> DecisionTemplate { + DecisionTemplate { + owner: PlayerId(0), + decisions: vec![PinnedDecision::Targets { + slot: DecisionSlot { + source: slot_source, + index: 0, + }, + targets: vec![TargetPin::Scheduled(sched)], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(1), + }, + key: tri_key(), + } + } + + fn sole_target(out: &[ConcreteDecision]) -> ConcreteTarget { + match &out[0] { + ConcreteDecision::Targets { targets, .. } => targets[0], + other => panic!("expected Targets, got {other:?}"), + } + } + + /// **Row R1-a — the maintained-invariant equality row.** A one-element + /// `Constant(Ranking::one(Object(src)))` is behaviour-identical to the pre-parameterization + /// `Constant(src)`, and the HEAD is what every variant resolves. + /// + /// # Non-vacuity / discrimination + /// + /// The one-element half alone is satisfied by ANY entry-selection rule — first, last, + /// random — because on a one-element list they coincide. The paired reach-guard is + /// therefore a TWO-entry ranking whose head and tail are BOTH legal live objects: only a + /// head-selecting reader answers the head there. + /// + /// REVERT-PROBE: make `Ranking::head` return the LAST entry (`self.0.last().unwrap()`) ⇒ + /// every two-entry assertion below flips to ObjectId(21) ⇒ FAILS, on all three + /// `TargetSchedule` variants. The one-element assertions stay green under that mutation, + /// which is exactly why they are not the discriminator. + #[test] + fn r1a_a_one_element_ranking_is_the_old_constant_and_every_variant_resolves_its_head() { + let mut state = GameState::new_two_player(7); + bf_object(&mut state, 20, 20, 0); + bf_object(&mut state, 21, 21, 0); + let (a, b) = (this_obj(20, None), this_obj(21, None)); + let slot_src = this_obj(99, None); + + // ── the equality half: one element behaves as the old constant subject ── + let one = ranked_template( + slot_src.clone(), + TargetSchedule::Constant(obj_rank(a.clone())), + ); + assert_eq!( + sole_target(&resolve(&one, 0, &state).expect("a live battlefield head resolves")), + ConcreteTarget::Object(ObjectId(20)), + "a one-element ranking resolves exactly what `Constant(src)` resolved" + ); + + // ── the reach-guard: BOTH entries live, so only head-selection answers 20 ── + let two = Ranking::new(vec![ + AnnouncementSubject::Object(a.clone()), + AnnouncementSubject::Object(b.clone()), + ]) + .expect("two distinct subjects are a legal ranking"); + for (label, sched) in [ + ("Constant", TargetSchedule::Constant(two.clone())), + ("RoundRobin", TargetSchedule::RoundRobin(vec![two.clone()])), + ( + "Piecewise", + TargetSchedule::Piecewise(vec![(0, two.clone())]), + ), + ] { + let template = ranked_template(slot_src.clone(), sched); + let out = resolve(&template, 0, &state).expect("the head is a live object"); + assert_eq!( + sole_target(&out), + ConcreteTarget::Object(ObjectId(20)), + "{label}: with BOTH entries legal, the step resolves its ranking's HEAD — a \ + last-entry (or any-entry) reader answers 21 here" + ); + } + + // Attribution control: the tail IS reachable as a head, so 21 is not simply + // unresolvable on this board. + let tail_first = ranked_template(slot_src, TargetSchedule::Constant(obj_rank(b))); + assert_eq!( + sole_target(&resolve(&tail_first, 0, &state).expect("21 is live too")), + ConcreteTarget::Object(ObjectId(21)), + "the tail entry resolves fine when it IS the head — the row measures POSITION, not \ + a dead object" + ); + } + + /// **Row R1-b — the head-only discriminator (CR 732.2a).** An illegal head is + /// `IllegalTarget` even when a later entry is perfectly legal. Skipping to that later + /// entry would be the conditional action CR 732.2a bars ("the outcome of a game event + /// determines the next action a player takes"), and it is the load-bearing guard for the + /// whole cross-episode consumption model: a ranking advances only at an episode boundary. + /// + /// Both subject arms are exercised, because they fail through DIFFERENT predicates: + /// `Object` through `resolve_source`'s `None`, `Seat` through `player_is_legal_target`'s + /// `false` (CR 702.11c hexproof — an existence-only check would let it through). + /// + /// # Non-vacuity / discrimination + /// + /// An `IllegalTarget` is also what a wholly broken resolver returns, so each arm pairs + /// with the SAME ranking reordered to put the legal entry first, which must RESOLVE. + /// + /// REVERT-PROBE: implement first-legal-wins in `evaluate_schedule` + /// (`ranking.iter().find_map(..)` instead of `head()`) ⇒ both refusals below resolve to + /// the second entry ⇒ FAILS twice, while both positives stay green. + #[test] + fn r1b_an_illegal_head_refuses_even_when_a_later_entry_is_legal() { + let mut state = GameState::new_two_player(7); + bf_object(&mut state, 20, 20, 0); // the live object + let live_src = this_obj(20, None); + let absent_src = this_obj(u64::MAX, None); // never inserted ⇒ resolve_source None + let slot_src = this_obj(20, None); // a live battlefield ability instance + + // ── the OBJECT arm ── + let head_dead = Ranking::new(vec![ + AnnouncementSubject::Object(absent_src.clone()), + AnnouncementSubject::Object(live_src.clone()), + ]) + .expect("legal ranking"); + assert!( + matches!( + resolve( + &ranked_template(slot_src.clone(), TargetSchedule::Constant(head_dead)), + 0, + &state + ), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "CR 732.2a: a dead HEAD refuses — it must NOT skip to the live tail" + ); + let head_live = Ranking::new(vec![ + AnnouncementSubject::Object(live_src), + AnnouncementSubject::Object(absent_src.clone()), + ]) + .expect("legal ranking"); + assert_eq!( + sole_target( + &resolve( + &ranked_template(slot_src.clone(), TargetSchedule::Constant(head_live)), + 0, + &state + ) + .expect("the SAME two subjects, legal one first, resolve") + ), + ConcreteTarget::Object(ObjectId(20)), + "paired positive: the identical pair with the LIVE entry first resolves — the \ + refusal above is caused by POSITION, not by the resolver being broken" + ); + + // ── the SEAT arm: hexproof, so the head is illegal as a TARGET while existing ── + grant_player_hexproof(&mut state, PlayerId(1)); + let head_hexproofed = Ranking::new(vec![ + AnnouncementSubject::Seat(PlayerId(1)), + AnnouncementSubject::Seat(PlayerId(0)), + ]) + .expect("legal ranking"); + assert!( + matches!( + resolve( + &ranked_template(slot_src.clone(), TargetSchedule::Constant(head_hexproofed)), + 0, + &state + ), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "CR 702.11c: a hexproofed HEAD refuses — and it EXISTS, so an existence-only \ + authority (`player_exists_for_choice`) would have resolved it" + ); + let head_legal_seat = Ranking::new(vec![ + AnnouncementSubject::Seat(PlayerId(0)), + AnnouncementSubject::Seat(PlayerId(1)), + ]) + .expect("legal ranking"); + assert_eq!( + sole_target( + &resolve( + &ranked_template(slot_src, TargetSchedule::Constant(head_legal_seat)), + 0, + &state + ) + .expect("the SAME two seats, legal one first, resolve") + ), + ConcreteTarget::Player(PlayerId(0)), + "paired positive: seats DO resolve on this board (the source's own controller is \ + not an opponent, so CR 702.11c does not bite) — so the refusal above is the \ + hexproof, not a seat arm that never resolves" + ); + } + + /// **Row R1-d — multi-authority.** Two ranked slots on ONE source are resolved + /// INDEPENDENTLY; neither inherits the other's answer. + /// + /// # Non-vacuity / discrimination + /// + /// Arm A gives the two slots DIFFERENT legal seats, so a resolver that answered once and + /// reused the answer produces two identical targets and fails the vector comparison. Arm B + /// makes slot 1's seat hexproofed while slot 0's stays legal: the whole-template resolve + /// must fail NAMING SLOT 1, and slot 0 alone must still resolve — a copied answer would + /// have made slot 1 succeed. + /// + /// REVERT-PROBE: resolve once and reuse across slots ⇒ arm A's two answers agree ⇒ FAILS, + /// and arm B stops refusing ⇒ FAILS. + #[test] + fn r1d_two_ranked_slots_on_one_source_do_not_inherit_each_others_answer() { + // A 3-seat board so slot 0 and slot 1 name two DIFFERENT seats, neither of them the + // source's own controller — the shape the plan's fixture specifies. + let mut state = crate::game::scenario::GameScenario::new_n_player(3, 7) + .build() + .state() + .clone(); + bf_object(&mut state, 20, 20, 0); + let src = this_obj(20, None); + let slot_at = |index: u8| DecisionSlot { + source: src.clone(), + index, + }; + let pin_at = |index: u8, seat: PlayerId| PinnedDecision::Targets { + slot: slot_at(index), + targets: vec![TargetPin::Scheduled(TargetSchedule::Constant(seat_rank( + seat, + )))], + }; + let template_of = |pins: Vec| DecisionTemplate { + owner: PlayerId(0), + decisions: pins, + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(1), + }, + key: tri_key(), + }; + + // ── arm A: two DIFFERENT legal seats ⇒ two DIFFERENT answers ── + let both = template_of(vec![pin_at(0, PlayerId(1)), pin_at(1, PlayerId(2))]); + let out = resolve(&both, 0, &state).expect("both seats are legal targets here"); + let answers: Vec = out + .iter() + .map(|d| match d { + ConcreteDecision::Targets { targets, .. } => targets[0], + other => panic!("expected Targets, got {other:?}"), + }) + .collect(); + assert_eq!( + answers, + vec![ + ConcreteTarget::Player(PlayerId(1)), + ConcreteTarget::Player(PlayerId(2)) + ], + "each slot resolves its OWN ranking's head — a resolve-once-and-reuse \ + implementation answers PlayerId(1) twice" + ); + + // ── arm B: slot 1's seat is hexproofed; slot 0's stays legal ── + grant_player_hexproof(&mut state, PlayerId(2)); + let err = resolve(&both, 0, &state) + .expect_err("slot 1's seat is now an illegal TARGET (CR 702.11c)"); + match err { + ReplayFailure::IllegalTarget { slot, .. } => assert_eq!( + slot, + slot_at(1), + "the failure names SLOT 1 — slot 0's success was not copied onto it" + ), + other => panic!("expected IllegalTarget, got {other:?}"), + } + // Paired positives: each slot ALONE answers the way the combined run says it does. + assert_eq!( + sole_target( + &resolve(&template_of(vec![pin_at(0, PlayerId(1))]), 0, &state) + .expect("slot 0 alone still resolves") + ), + ConcreteTarget::Player(PlayerId(1)), + "slot 0 is unaffected by slot 1's refusal" + ); + assert!( + matches!( + resolve(&template_of(vec![pin_at(1, PlayerId(2))]), 0, &state), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "slot 1 alone refuses — so the combined refusal is slot 1's own verdict" + ); + } + + /// **Row R1-f — the LOAD-seam invariant.** A wire-supplied empty or duplicated ranking + /// fails deserialization, which is what makes [`Ranking::head`] infallible: no `Option` + /// and no panic path leak into the resolver. Same class as the wire-sourced + /// `max_iterations` defect `reject_zero_bound_shortcut_offer` closes. + /// + /// # Non-vacuity / discrimination + /// + /// The paired positive is a VALID two-entry ranking round-tripping equal — without it a + /// `Deserialize` impl that rejected everything would satisfy both negatives. + /// + /// REVERT-PROBE: drop `#[serde(try_from = "Vec")]` from `Ranking` ⇒ + /// `[]` deserializes into `Ranking(vec![])` ⇒ this row's `is_err()` FAILS (and `head()` + /// on that value would panic in production rather than refuse). + #[test] + fn r1f_an_empty_or_duplicated_ranking_fails_the_load() { + let seat = |p: u8| AnnouncementSubject::Seat(PlayerId(p)); + + assert!( + serde_json::from_str::("[]").is_err(), + "an empty ranking names nobody — it must not survive the load seam" + ); + + let duplicated = + serde_json::to_string(&vec![seat(1), seat(1)]).expect("the raw list serializes"); + assert!( + serde_json::from_str::(&duplicated).is_err(), + "CR 601.2c: a repeated subject is the same declaration twice, not an ordering" + ); + + // Paired positive: a legal ranking round-trips equal, so the refusals above are the + // invariant and not a broken codec. + let valid = Ranking::new(vec![seat(1), seat(0)]).expect("distinct subjects"); + let json = serde_json::to_string(&valid).expect("serialize"); + assert_eq!( + serde_json::from_str::(&json).expect("a legal ranking round-trips"), + valid, + "the newtype serializes as its inner list and reloads through the checked TryFrom" + ); + assert_eq!( + json, r#"[{"Seat":1},{"Seat":0}]"#, + "and the wire shape IS the bare list — the `try_from` shim adds no envelope" + ); + + // The constructor's own two clauses, named (the `TryFrom` above delegates here). + assert_eq!(Ranking::new(vec![]).unwrap_err(), RankingError::Empty); + assert_eq!( + Ranking::new(vec![seat(1), seat(1)]).unwrap_err(), + RankingError::DuplicateSubject + ); + } + + /// **Rows R1-g / R1-h / R1-i — the `Seat` arm's SOURCE, one instrument, three zones.** + /// + /// A ranked `Seat` is a TARGET (CR 601.2c), so `player_is_legal_target` needs the ABILITY + /// INSTANCE that would name it (CR 702.11c is source-controller-relative; CR 702.16b reads + /// the source's characteristics). That is `resolve_ability_instance`, NOT `resolve_source`: + /// + /// * **R1-g, battlefield** — resolves. The control arm; the only one of the three that can + /// fail for a boring reason (a dead harness). + /// * **R1-h, command zone** — resolves. CR 114.2 puts an emblem — "both owned and + /// controlled by that player" — there, and CR 114.4 is why its abilities function from + /// there (CR 113.6p for the plane / scheme / conspiracy members of the same class). + /// `crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz` is a real board + /// whose published `Targets` point names exactly such a source. + /// * **R1-i, graveyard** — refuses. With no live ability instance the engine cannot certify + /// that the object it would ask the CR 702.11c question about still IS that instance + /// (CR 400.7 / CR 608.2b). The seat still EXISTS and a graveyard object still carries a + /// `controller`, so the question is answerable — what is missing is the certification, + /// and CR 732.1 + CR 732.2a make refusing free — "may suggest" is a permission, not an + /// obligation (no declaration ⇒ the table plays it out manually). + /// + /// # Non-vacuity / discrimination + /// + /// R1-g and R1-i must come out the OTHER way on the SAME instrument in the same run: one + /// resolving and one refusing is what proves the harness reports both values. R1-h is the + /// row that discriminates this specification from plain fail-closed-on-`resolve_source`. + /// + /// REVERT-PROBES: (a) derive the arm from `resolve_source` alone ⇒ the command-zone case + /// resolves `None` ⇒ **R1-h FAILS**; (b) break the battlefield disjunct ⇒ R1-g FAILS; + /// (c) fall back to `player_exists_for_choice` when the accessor is `None` ⇒ the graveyard + /// seat resolves ⇒ **R1-i FAILS**. + /// + /// These three SAMPLE the zone space; they do not enumerate it. Exile, a stale CR 400.7 + /// incarnation and a different object are pinned on one board by the shipped row + /// `game::engine::command_zone_sourced_slot_matches_and_graveyard_still_aborts` (row R1-l), + /// whose subject `slot_source_prompted` now delegates to `resolve_ability_instance` — so + /// that coverage transfers to this accessor rather than being skipped. + #[test] + fn r1ghi_a_ranked_seat_resolves_from_battlefield_and_command_but_fails_closed_elsewhere() { + let mut state = GameState::new_two_player(7); + let battlefield = zoned_object(&mut state, 900, Zone::Battlefield); + let emblem = zoned_object(&mut state, 901, Zone::Command); + let graveyard = zoned_object(&mut state, 902, Zone::Graveyard); + + let seat = PlayerId(1); + let resolve_from = |src: DecisionSource, state: &GameState| { + resolve( + &ranked_template(src, TargetSchedule::Constant(seat_rank(seat))), + 0, + state, + ) + }; + + // R1-g: battlefield ⇒ resolves. + assert_eq!( + sole_target( + &resolve_from(this_obj(battlefield.0, Some(3)), &state) + .expect("a live battlefield ability instance certifies the seat") + ), + ConcreteTarget::Player(seat), + "R1-g: the control arm resolves — the instrument can return a target" + ); + + // R1-h: CR 114.4 / CR 113.6p command zone ⇒ resolves. + assert_eq!( + sole_target( + &resolve_from(this_obj(emblem.0, Some(3)), &state) + .expect("CR 114.4: an emblem's abilities function in the command zone") + ), + ConcreteTarget::Player(seat), + "R1-h: a `resolve_source`-derived arm answers None here and would refuse the \ + emblem loop the drive built a CR 114.4 / CR 113.6p disjunct FOR" + ); + + // R1-i: graveyard ⇒ fails closed. + assert!( + matches!( + resolve_from(this_obj(graveyard.0, Some(3)), &state), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "R1-i: no live ability instance ⇒ refuse. The SEAT is fine — R1-g resolved it one \ + assertion ago on this same board — so the refusal is caused by the ZONE" + ); + + // Sibling agreement: an OBJECT head on that same dead source refuses too, so the two + // subject arms say the same thing about a source that is gone. + assert!( + matches!( + resolve( + &ranked_template( + this_obj(graveyard.0, Some(3)), + TargetSchedule::Constant(obj_rank(this_obj(graveyard.0, Some(3)))) + ), + 0, + &state + ), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "the `Object` arm refuses a graveyard subject too (CR 608.2b) — the two arms agree" + ); + + // CR 400.7, so the command disjunct is not a blanket zone exemption: a stale + // incarnation on the SAME emblem refuses. + assert!( + matches!( + resolve_from(this_obj(emblem.0, Some(2)), &state), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "CR 400.7: the command arm re-binds ONE incarnation, exactly like the battlefield \ + arm — a re-created emblem does not certify the old pin" + ); + } + + /// **T4 — an `Order` pin resolves from the command zone through the PUBLIC [`resolve`], + /// and still fails closed everywhere else.** + /// + /// CR 603.3b is the pin's framing (replay this source's trigger at its pinned ordering + /// position); what changed is that "this source" is now re-bound by + /// [`resolve_ability_instance`] — same identity, same CR 400.7 incarnation, present in a + /// zone it admits (`Battlefield`, plus `Command` for a `ThisObject` source) — rather than + /// by `resolve_source`'s battlefield-only filter. + /// + /// **Resolving an `Order` pin grants NO capability.** No production consumer of + /// `resolve`'s output reads an `Order` element's payload: four element readers + /// discriminate on the variant and skip it, the `ManaPayment` beat aborts on its mere + /// presence, and the per-cycle re-check reads only the `is_err()` verdict. What the + /// re-bind buys is that a command-zone `Order` pin stops discarding every OTHER pin's + /// answer in the same template — `resolve` is a per-pin `Result` collect. That payoff and + /// its bound are pinned by + /// `game::engine::stage2_injector_tests::a_command_zone_order_pin_stops_poisoning_the_template_without_gaining_capability`, + /// whose five rows are the measurement; this row measures only the re-bind itself. + /// + /// # Non-vacuity / discrimination + /// + /// Rows a and b are one field apart (the zone) and both `Ok`; rows b/c and b/d are one + /// field apart and OPPOSITE; rows e and f are one field apart — the SAME object, moved — + /// and OPPOSITE. Each negative row asserts its subject exists in the intended state + /// before the negative assertion. + /// + /// REVERT-PROBES: revert the `Order` arm to `resolve_source` ⇒ row **b** fails alone; + /// widen the accessor's command disjunct to any zone ⇒ row **c** fails; drop the CR 400.7 + /// incarnation conjunct ⇒ row **d** fails; widen the `AllCopies` arm to the command zone + /// ⇒ row **f** fails. + #[test] + fn an_order_pin_resolves_from_the_command_zone_and_still_fails_closed_elsewhere() { + let mut state = GameState::new_two_player(7); + let battlefield = zoned_object(&mut state, 900, Zone::Battlefield); + let command = zoned_object(&mut state, 901, Zone::Command); + let graveyard = zoned_object(&mut state, 902, Zone::Graveyard); + let copy = zoned_object(&mut state, 910, Zone::Battlefield); + + let template = |source: DecisionSource| DecisionTemplate { + owner: PlayerId(0), + decisions: vec![PinnedDecision::Order { source, pos: 0 }], + replay: ReplayMode::Static, + key: tri_key(), + }; + let order_source = |out: &[ConcreteDecision]| match out { + [ConcreteDecision::Order { source, .. }] => *source, + other => panic!("expected exactly one Order decision, got {other:?}"), + }; + + // row a — the shipped battlefield arm, and the control that `resolve` can answer Ok. + assert_eq!( + order_source( + &resolve(&template(this_obj(battlefield.0, Some(3))), 0, &state) + .expect("row a: a live battlefield source still re-binds") + ), + battlefield, + "row a: control" + ); + + // row b — THE FIX. One field from a: the source's zone. + assert_eq!( + order_source( + &resolve(&template(this_obj(command.0, Some(3))), 0, &state).expect( + "row b: CR 114.4 — an ability functioning in the command zone \ + re-binds there" + ) + ), + command, + "row b: the CR 603.3b ordering pin no longer needs its source on the battlefield" + ); + + // row c — one field from b: the zone again, the other way. + assert_eq!( + state + .objects + .get(&graveyard) + .expect("reach-guard: row c's source object was built") + .zone, + Zone::Graveyard, + "reach-guard: row c's source exists and is in the graveyard, so its failure is \ + about the ZONE and not about an absent object" + ); + assert!( + matches!( + resolve(&template(this_obj(graveyard.0, Some(3))), 0, &state), + Err(ReplayFailure::MissingSource { .. }) + ), + "row c: the zone set is {{Battlefield, Command}} and nothing else" + ); + + // row d — one field from b: the pinned CR 400.7 incarnation. + assert_ne!( + state + .objects + .get(&command) + .expect("reach-guard: row d's source object was built") + .incarnation, + 2, + "reach-guard: the LIVE incarnation differs from the pinned one" + ); + assert!( + matches!( + resolve(&template(this_obj(command.0, Some(2))), 0, &state), + Err(ReplayFailure::MissingSource { .. }) + ), + "row d: CR 400.7 — a re-created source is a new object with no relation to the \ + pinned one, in the command zone exactly as on the battlefield" + ); + + // rows e/f — ONE object, moved. The widening is `ThisObject`-only, so a + // card-identity-spelled command-zone source still fails closed: the disclosed + // residual, in executable form. + let by_card = YieldTarget::AllCopies { + card_id: CardId(910), + trigger_description: None, + }; + assert_eq!( + order_source( + &resolve(&template(by_card.clone()), 0, &state) + .expect("row e: the card's only copy is on the battlefield") + ), + copy, + "row e: the `AllCopies` arm's control positive" + ); + state + .objects + .get_mut(©) + .expect("reach-guard: row f moves the SAME object row e just resolved") + .zone = Zone::Command; + assert_eq!( + state + .objects + .get(©) + .expect("reach-guard: the object still exists after the move") + .card_id, + CardId(910), + "reach-guard: row f differs from row e in ZONE ONLY — same object, same card id" + ); + assert!( + matches!( + resolve(&template(by_card), 0, &state), + Err(ReplayFailure::MissingSource { .. }) + ), + "row f: DISCLOSED RESIDUAL — the command disjunct is `ThisObject`-only, so a \ + command-zone source named by CARD identity (a conspiracy, an Eminence \ + commander) still fails closed. Disclosed, not closed." + ); + } } diff --git a/crates/engine/src/analysis/loop_check.rs b/crates/engine/src/analysis/loop_check.rs index 10619c60f5..8e90194157 100644 --- a/crates/engine/src/analysis/loop_check.rs +++ b/crates/engine/src/analysis/loop_check.rs @@ -123,10 +123,15 @@ pub struct LoopCertificate { pub unbounded: Vec, /// The classified win condition derived from `unbounded`. pub win_kind: WinKind, - /// CR 104.4b vs CR 732.2a/CR 732.6: whether the cycle is all-mandatory (no - /// "may"/choice once started). `true` ⇒ a forced loop the live path would draw - /// (CR 732.4) absent a net resource; `false` ⇒ an optional loop a player chooses - /// to repeat. The detector cannot infer optionality from two states alone, so + /// CR 732.5 / CR 732.2b: whether NO living player has a meaningful priority action + /// that could break the loop — the producer's own measurement, not a property of the + /// cycle's contents. (`game::engine::interactive_loop_bridge` assigns it from + /// `no_living_player_has_meaningful_priority_action`, which probes EVERY living player + /// as the priority holder.) CR 732.5 is why that is the right question: no player can + /// be forced to take an action that would end a loop, so a loop is unbreakable exactly + /// when nobody HAS such an action to take voluntarily; CR 732.2b is the shortcut-side + /// counterpart — the window in which another player would name a different choice. + /// The detector cannot infer optionality from two states alone, so /// the caller (which drives the actions) supplies it. pub mandatory: bool, /// CR 110.1: non-recycled per-cycle remainder of battlefield permanents (the "+1 @@ -153,10 +158,17 @@ impl LoopCertificate { } } -/// CR 732.2a: the public, log/display summary a `WaitingFor::RespondToShortcut` carries -/// to each responding opponent — "the player with priority suggests repeating this loop -/// N times". Every field is derived from public board state (the confirmed certificate + -/// the proposer's declared count), so there is no hidden information to redact. +/// CR 732.2a: the log/display summary a `WaitingFor::RespondToShortcut` carries to each +/// responding opponent — "the player with priority suggests repeating this loop N times". +/// +/// Every field EXCEPT [`ShortcutProposal::template`] is derived from public board state (the +/// confirmed certificate + the proposer's declared count). `template` is NOT: it is the +/// proposer's `DecisionTemplate` moved here verbatim by `game::engine::handle_declare_shortcut`, +/// and its pins can name objects in hidden zones. It is redacted per viewer in +/// `game::visibility::filter_state_for_viewer` through the shared `pins_name_hidden_source` +/// authority — all-or-nothing per CR 732.2b, the whole template is dropped and never trimmed. +/// The blanket "no hidden information to redact" this doc used to claim is exactly what let +/// this carrier drift from the `WaitingFor::LoopShortcut` offer it is copied from. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ShortcutProposal { /// CR 732.2a: the player with priority who proposed the shortcut. This is separate from @@ -234,7 +246,8 @@ pub enum ShortcutResponse { /// `controller` is the loop's controlling player (so the consumed-axis constraint /// is scoped to *their* life/mana and opponent depletion reads as progress, and /// the win classifier can tell an opponent loss from self-mill/lifegain), and -/// `mandatory` records whether the driven cycle contained an optional choice. The +/// `mandatory` records whether no living player had a meaningful priority action that +/// could break the loop (CR 732.5). The /// caller, which drove the actions, knows both. pub fn detect_loop( cycle_start: &GameState, diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 3197611125..73b5bd5f69 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -673,9 +673,18 @@ pub struct PeriodicDelta { /// The whole-game resource change across one repetition, measured from the very /// frame pair that certified it. pub delta: ResourceVector, - /// CR 704.5a: per published choice slot, the life magnitude one repetition charges - /// to whichever player that slot's pin names. EMPTY for the untargeted class, where - /// the victims are already visible in `delta.life`. + /// CR 704.5a: per ANNOUNCED target slot, the life magnitude one repetition charges + /// to whichever player that slot's declaration names. EMPTY for the untargeted class, + /// where the victims are already visible in `delta.life`. + /// + /// ANNOUNCED, not PUBLISHED, and the distinction is load-bearing rather than pedantic: + /// CR 732.2a withholds a decision point for an announcement the PROPOSER does not make + /// (`game::engine::TargetAnnouncement::NotProposerChoice` — its own doc enumerates the + /// three routes: a single legal assignment, a CR 601.2c `target_chooser` seated on another + /// player, or a non-`Chosen` `TargetSelectionMode`), but CR 704.5a charges that victim all + /// the same. A slot present here with no matching `ShortcutDecisionSchema` point is + /// therefore CORRECT and expected, not a schema/certificate mismatch. Deriving this from + /// the published points instead let the withhold silently raise the bound. pub victim_slot: Vec<(DecisionSlot, i64)>, } @@ -868,49 +877,57 @@ impl ResourceVector { any_increase || mills } - /// The component axes that strictly increased over this delta — the - /// candidate **unbounded** resources a `WinKind` classifier (PR-2) reads to - /// name the loop's win condition. A mill axis surfaces here as a negative - /// `library_delta`, so it is reported separately via its sign. + /// EVERY axis this delta moved, in either direction, as a [`ResourceAxis`] tag with its + /// signed magnitude — the unfiltered fold [`Self::unbounded_components`] narrows. /// - /// Returns each increasing axis as a [`ResourceAxis`] tag with its signed - /// magnitude. - pub fn unbounded_components(&self) -> Vec<(ResourceAxis, i64)> { + /// Named `axis_components` because [`Self::components`] is taken by a different fold over + /// the same fields: that one yields the [`Component`] CONSUMED/GAINED classification with + /// no axis identity, for [`Self::is_net_progress`]. + /// + /// The distinction from that method is the SIGN, and it is the whole reason this exists: + /// `unbounded_components` reports only what a loop *accrues*, so a drain loop's defining + /// term — the victim's NEGATIVE `life` — is invisible through it. A consumer that has to + /// state what a repetition COSTS, rather than what it gains, cannot be built on that + /// method. The one such consumer today is `game::interaction`'s CR 732.2a shortcut + /// preview, which states the finished magnitude of a declared repeat count and would + /// otherwise show a lethal drain as producing nothing. + /// + /// Order is fixed (mana, life, damage, library, poison, counters, triggers, then the + /// scalar axes) and every map is a `BTreeMap`, so the result is deterministic. + pub fn axis_components(&self) -> Vec<(ResourceAxis, i64)> { let mut out = Vec::new(); for (i, &n) in self.mana.iter().enumerate() { - if n > 0 { + if n != 0 { out.push((ResourceAxis::Mana(MANA_INDEX[i]), n)); } } for (pid, &n) in &self.life { - if n > 0 { + if n != 0 { out.push((ResourceAxis::Life(*pid), n)); } } for (pid, &n) in &self.damage_dealt { - if n > 0 { + if n != 0 { out.push((ResourceAxis::DamageDealt(*pid), n)); } } - // CR 401: a mill loop is unbounded *downward* on library size. for (pid, &n) in &self.library_delta { if n != 0 { out.push((ResourceAxis::LibraryDelta(*pid), n)); } } - // CR 704.5c: rising poison on a victim is an unbounded loss axis. for (pid, &n) in &self.poison { - if n > 0 { + if n != 0 { out.push((ResourceAxis::Poison(*pid), n)); } } for (&key, &n) in &self.counters { - if n > 0 { + if n != 0 { out.push((ResourceAxis::Counter(key.0, key.1), n)); } } for (&kind, &n) in &self.generic_triggers { - if n > 0 { + if n != 0 { out.push((ResourceAxis::Trigger(kind), n)); } } @@ -926,13 +943,40 @@ impl ResourceVector { (ResourceAxis::LtbTriggers, self.ltb_triggers), (ResourceAxis::SacTriggers, self.sac_triggers), ] { - if n > 0 { + if n != 0 { out.push((axis, n)); } } out } + /// The component axes that strictly increased over this delta — the + /// candidate **unbounded** resources a `WinKind` classifier (PR-2) reads to + /// name the loop's win condition. A mill axis surfaces here as a negative + /// `library_delta`, so it is reported separately via its sign. + /// + /// Returns each increasing axis as a [`ResourceAxis`] tag with its signed + /// magnitude. + /// + /// CR 401: the `LibraryDelta` exemption is what keeps a mill loop — unbounded + /// *downward* on library size — in the result while every other axis is required to + /// have risen. + /// + /// CR 704.5c: rising poison on a victim is an unbounded loss axis — and unlike mill it + /// needs no exemption, because poison RISES toward the ten-counter loss, so `Poison(p)` + /// is carried by the `n > 0` term itself. RELOCATED, not re-derived: this annotation sat + /// above the poison arm of this method's own loop until the `axis_components` split moved + /// that loop out, and it belongs beside the CR 401 term because the pair is what states + /// WHICH loss axes survive the filter and why. Re-verified against + /// `docs/MagicCompRules.txt`: "704.5c If a player has ten or more poison counters, that + /// player loses the game." + pub fn unbounded_components(&self) -> Vec<(ResourceAxis, i64)> { + self.axis_components() + .into_iter() + .filter(|&(axis, n)| n > 0 || matches!(axis, ResourceAxis::LibraryDelta(_))) + .collect() + } + /// CR 732.2a + CR 704.5a / CR 704.5c / CR 104.3c + CR 121.4: the largest number of /// times this per-period delta may legally be repeated in one shortcut proposal. /// @@ -955,13 +999,21 @@ impl ResourceVector { /// /// # Aggregation per DECLARABLE victim /// - /// `declarable_victims` is the union of the published `Targets` slots' legal targets — - /// EMPTY for the untargeted class. `slot_magnitude` is the per-period life loss the - /// certificate attributed to each published slot. A declaration may aim **every** slot + /// `declarable_victims` is the union of the ANNOUNCED target slots' legal player targets + /// — EMPTY for the untargeted class. `slot_magnitude` is the per-period life loss the + /// certificate attributed to each announced slot. A declaration may aim **every** slot /// at **one** opponent, so a declarable victim's life magnitude is the SUM over all /// slots; that is what makes an all-slots-on-one-seat declaration bounded by /// construction rather than by a cross-slot check in `validate_pins`. /// + /// ANNOUNCED, NOT PUBLISHED, and the caller + /// (`game::engine::bounded_cycle_charged_targets_for_window`) supplies it that way on + /// purpose. CR 732.2a withholds a decision point when the announcement is FORCED — the + /// player makes no choice — but CR 704.5a charges that victim regardless of who chose it. + /// Feeding this the PUBLISHED point set instead dropped a forced victim into the `else` + /// arm below and RAISED the bound; on a victim whose measured period nets a life GAIN it + /// disarmed the life axis at `MAX_SHORTCUT_CYCLES` outright. + /// /// PRECISELY WHAT IS IMPLEMENTED, and how it differs from the specified rule: this /// sums **every** positive `slot_magnitude` and charges that one total `S` to **every** /// member of `declarable_victims`. The specified rule is `S(p) = Σ over slots s with @@ -2526,8 +2578,16 @@ pub(crate) fn loop_states_cover_modulo_growth_scoped<'a>( // // PINS ARE PLUGGED IN HERE (`scope.pinned`, minted by the single authority // `game::engine::bounded_cycle_pin_slots`). Precondition (a) holds by construction: - // the pins that channel carries are `TargetPin::Player` / `MayChoice` designations, - // both state-independent (never "the newest copy"). Precondition (c) is NOT taken on + // the pins that channel carries are SEAT designations and `MayChoice` designations, + // both state-independent (never "the newest copy"). A seat designation now has TWO + // spellings and (a) holds for both: `TargetPin::Player` is the CR 115.10a CHOICE class, + // while a CR 601.2c TARGET-class seat is + // `Scheduled(TargetSchedule::Constant(Ranking::one(AnnouncementSubject::Seat(..))))` — + // one entry, selected without reading the iteration index, so it too can never denote + // "the newest copy". The split changes WHICH AUTHORITY judges a seat's legality, never + // whether the designation is state-independent, which is all this precondition asks. + // (This module reads no pin VARIANT at all — every `TargetPin::` occurrence in it is + // prose — so no relief verdict can move with the spelling.) Precondition (c) is NOT taken on // trust from the mint site: [`pinned_may_choice_relief`] re-runs the mint's own // per-entry acceptance test — controller conjunct included — for THIS entry, so the // relief predicate is the mint predicate rather than a coarser sibling of it. @@ -4336,7 +4396,27 @@ fn stack_entry_has_no_ordering_input(state: &GameState, entry: &StackEntry) -> b /// single legal assignment exists, limit=2) — the same authority the trigger /// dispatcher uses. Fail-closed on any build error, empty slots, or ≥2 legal /// assignments (`Ok(None)` / `Err`). -fn forced_unique_targeting( +/// +/// "The same authority the trigger dispatcher uses" is MEASURED, not asserted: +/// `triggers::prepare_trigger_targets` calls this very function and routes +/// `Ok(Some(targets))` to `PreparedTriggerTargets::AutoAssigned` (targets assigned +/// at dispatch, no prompt) and `Ok(None)` to `NeedsPlayerChoice` (the +/// `WaitingFor::TriggerTargetSelection` prompt). So `true` here is exactly "the +/// dispatcher will announce this target itself and the player is never asked". +/// +/// `pub(crate)` for ONE additional consumer, and it is the publish side of the same +/// question: [`crate::game::engine::entry_publishes_pin_slots`] must not publish a +/// CR 732.2a decision point for a choice no player makes. Exported rather than +/// re-derived there — two copies of this predicate could disagree about whether a +/// choice is forced, and the publisher and the relief disagreeing is precisely the +/// fail-open shape gate (3) exists to prevent. +/// +/// ⚠ THE BOARD IS THE VERDICT. Every caller must pass the frame the rest of its own +/// derivation uses — the announced pair's CARRYING FRAME for a retained sample, the +/// live board only for a live entry. Handing a retained pair the live board is +/// fail-OPEN: a target legal on the frame but gone from the live board collapses the +/// assignment to "forced" and relieves (or unpublishes) a choice that is not forced. +pub(crate) fn forced_unique_targeting( state: &GameState, ability: &crate::types::ability::ResolvedAbility, ) -> bool { @@ -7253,6 +7333,888 @@ mod tests { ); } + /// **Row F1.** CR 732.2a: a FORCED target is not a game choice, so the mint must NOT + /// publish a decision point for it. + /// + /// CR 732.2a describes a shortcut as "a sequence of game choices, for all players"; a + /// published `DecisionPoint` stands for one such choice. When exactly one legal assignment + /// exists, the announcing player makes none — `triggers::prepare_trigger_targets` routes + /// this predicate's `Ok(Some(..))` straight to `AutoAssigned`, so no + /// `WaitingFor::TriggerTargetSelection` is raised, so `record_trigger_target_answer` (whose + /// only two call sites are that prompt's reducer arms) never runs. A point published here + /// would therefore demand a `predictability_gate` answer that CANNOT ARRIVE, and since the + /// gate's `required` set is EVERY published point, one such point makes the whole offer + /// undeclarable — the precise failure the bounded-offer journal exists to remove. + /// + /// # Discrimination, and the confound it breaks + /// + /// (a) vs (b) differ in the number of living opponents, which is confounded with the number + /// of legal assignments — so (a′) repeats the forced verdict at (b)'s SEAT COUNT with one + /// opponent eliminated (CR 800.4 + CR 102.1: a departed seat is not choosable). An + /// implementation keyed on "is this a 2-player game" passes (a) and (b) and FAILS (a′). + /// + /// REVERT-PROBE, MEASURED (deleting the `forced_unique_targeting` withhold from + /// `entry_publishes_pin_slots`, with the mutation `cmp`-proved to have applied): the row + /// FLIPS TO FAILING at arm (a) — "CR 732.2a: no choice is made here, so nothing is + /// published". (a′) and (c) assert the SAME withhold on two further boards and are not + /// separately measured, because (a) panics first; each carries its own reach-guard instead. + /// (b)'s positive is pinned INDEPENDENTLY OF THIS ROW and on BOTH sides of the change, by + /// `bounded_cycle_pin_slots_requires_a_single_mandatory_announcement_slot`'s control on the + /// same 3p `drain_entry` fixture — so "the mint publishes nothing" cannot be what makes the + /// forced arms pass. + /// + /// # Reach-guards + /// + /// Each forced arm asserts the FULL announcement shape first (one mandatory slot, all-player + /// legal set), so the withhold is attributable to forced-ness rather than to one of the + /// upstream cardinality / optionality / all-`Player` conjuncts. Arm (d) asserts the RELIEF + /// survives: gate (3) was already passing on a forced board through + /// `stack_entry_has_no_ordering_input`, so unpublishing the point costs no cover. + #[test] + fn a_forced_target_is_not_a_published_decision_point() { + use crate::analysis::decision_template::DecisionPointKind; + use crate::game::ability_utils::build_target_slots; + use crate::game::engine::{bounded_cycle_pin_slots, entry_publishes_pin_slots}; + + let announcement = |state: &GameState| { + build_target_slots(state, state.stack[2].ability().unwrap()) + .map(|slots| { + slots + .iter() + .map(|s| (s.optional, s.legal_targets.clone())) + .collect::>() + }) + .ok() + }; + + // ── (a) FORCED: 2p, the single opponent is the only legal assignment ── + let (_p2, c2) = grown_window(2, |id| drain_entry(id, vec![])); + assert_eq!( + announcement(&c2), + Some(vec![(false, vec![TargetRef::Player(PlayerId(1))])]), + "reach-guard: ONE mandatory slot over PLAYERS — every conjunct upstream of the \ + forced-ness check accepts this entry, so the withhold below is attributable" + ); + assert!( + forced_unique_targeting(&c2, c2.stack[2].ability().unwrap()), + "reach-guard: one legal assignment ⇒ the dispatcher announces it without asking" + ); + assert!( + entry_publishes_pin_slots(&c2, &c2.stack[2], PlayerId(0)).is_none(), + "CR 732.2a: no choice is made here, so nothing is published — and a mandatory \ + drain has no CR 603.5 gate to publish either" + ); + assert!( + bounded_cycle_pin_slots(&c2, PlayerId(0)).is_empty(), + "and the point mint carries the withhold through" + ); + + // ── (a′) SAME SEAT COUNT as (b), one opponent eliminated ⇒ still forced ── + let (_pe, mut ce) = grown_window(3, |id| drain_entry(id, vec![])); + ce.players + .iter_mut() + .find(|p| p.id == PlayerId(2)) + .expect("fixture: the 3p board seats P2") + .is_eliminated = true; + assert_eq!( + ce.players.len(), + 3, + "reach-guard: the SEAT COUNT still matches (b) — only legality differs" + ); + assert_eq!( + announcement(&ce), + Some(vec![(false, vec![TargetRef::Player(PlayerId(1))])]), + "reach-guard: CR 800.4 + CR 102.1 — a departed seat is not one of the people in \ + the game, so the announcement authority enumerates ONE opponent" + ); + assert!( + entry_publishes_pin_slots(&ce, &ce.stack[2], PlayerId(0)).is_none(), + "the verdict follows the LEGAL SET, not the seat count" + ); + + // ── (b) MATCHED POSITIVE: 3p, two legal assignments ⇒ a real choice ⇒ published ── + let (_p3, c3) = grown_window(3, |id| drain_entry(id, vec![])); + assert!( + !forced_unique_targeting(&c3, c3.stack[2].ability().unwrap()), + "reach-guard: two opponents ⇒ `auto_select => Ok(None)` ⇒ the player IS asked" + ); + let published = bounded_cycle_pin_slots(&c3, PlayerId(0)); + assert_eq!( + published.len(), + 1, + "control: an unforced target choice is still published — without this every \ + assertion above is satisfied by a mint that publishes nothing" + ); + assert!( + matches!(published[0].kind, DecisionPointKind::Targets { .. }), + "control: and it is the CR 601.2c Targets point, not some other kind" + ); + + // ── (c) the CR 603.5 gate SURVIVES the withhold ── + // Withholding the forced target must not suppress the entry: a "may" on the same + // source is a real per-iteration choice with its own sub-index. + let (_pm, cm) = grown_window(2, optional_drain); + let pins = entry_publishes_pin_slots(&cm, &cm.stack[2], PlayerId(0)) + .expect("an optional entry still publishes its CR 603.5 gate"); + assert!( + pins.target.is_none(), + "the forced CR 601.2c target is withheld" + ); + assert!(pins.may.is_some(), "the CR 603.5 take/decline is not"); + assert!( + pins.legal_targets.is_empty(), + "no target slot carries no legal set" + ); + let may_points = bounded_cycle_pin_slots(&cm, PlayerId(0)); + assert_eq!( + may_points.len(), + 1, + "exactly the may point reaches the schema: {may_points:?}" + ); + assert!( + matches!(may_points[0].kind, DecisionPointKind::MayChoice), + "and it is the MayChoice point" + ); + + // ── (d) NO RELIEF IS LOST: gate (3) passes on a forced board without any pin ── + for (label, state) in [("(a) 2p", &c2), ("(a′) eliminated", &ce), ("(c) may", &cm)] { + assert!( + stack_entry_has_no_ordering_input(state, &state.stack[2]), + "{label}: the target axis of gate (3) is discharged by forced-ness itself, so \ + unpublishing the point cannot cost the cover a relief it used to get" + ); + } + } + + /// CR 704.5a — **WITHHOLDING A FORCED ANNOUNCEMENT FROM THE SCHEMA DOES NOT UNCHARGE ITS + /// VICTIM: the bound is the SAME whether or not the point is published.** + /// + /// The sibling row above asserts the CR 732.2a WITHHOLD (a forced announcement is not a + /// game choice, so no decision point is published). That withhold is right, and its blast + /// radius was not: `declarable_victims` and `PeriodicDelta::victim_slot` were BOTH derived + /// from the published point set, so withholding the point dropped the forced victim into + /// `elimination_bounds`' bare-`observed_life_loss` arm and the bound GREW — an offer + /// declaring more repetitions legal than CR 732.2a permits, on the very operator that + /// proves the proposal "may be legally taken based on the current game state". + /// + /// This row therefore asserts THE BOUND, not the publication. A row that only re-asserted + /// "the point is withheld" is exactly the row that already existed and that missed this. + /// + /// # The matched pair, and why the two arms are comparable + /// + /// Both arms run step (7)'s own two derivations verbatim, then the production + /// `elimination_bounds`. They differ in ONE axis — how many opponents the announcement + /// authority enumerates, which is what makes the announcement `Forced` (2p, one legal + /// assignment, point WITHHELD) or `Chosen` (3p, two legal assignments, point PUBLISHED). + /// The extra seat is parked at 40 life so its own headroom never binds, and the delta is + /// byte-identical across the arms, so the ONLY thing that can move the bound is whether + /// the withheld announcement is charged. Asserting EQUALITY pins both directions at once: + /// the pre-fix fail-OPEN (looser when withheld) and an over-correction (tighter when + /// withheld, which would silently shrink offers on boards that work today). + /// + /// * **(A) the ordinary forced drain** — P1 loses 1 per period. Charged: P1's magnitude is + /// `observed 1 + S 1 = 2` over headroom `7 - 1`, so **3**. Uncharged it is `1`, giving + /// **6**. + /// * **(B) the victim who NETS A LIFE GAIN** — P1 *gains* 1 per period while the proposer + /// loses 2. This is the shape where the defect is worst rather than merely loose: + /// uncharged, P1's magnitude is `-1`, `elimination_bounds`' `narrow` guard + /// (`magnitude > 0`) never fires and P1's life axis is DISARMED outright, leaving only + /// the proposer's `20 / 2 = 10`. Charged, the `.max(0)` clamp floors the gain at zero + /// and P1 is charged `0 + S 2 = 2` over headroom `7 - 1`, so **3**. Case (o) of + /// `elimination_bounds_conventions` guards that clamp in isolation; this row is what + /// proves a real production derivation still REACHES it on a forced board. + /// + /// # What a wrong implementation would still pass, and the guard for each + /// + /// * *charge every living seat* (ignore the legal set): both arms move together and stay + /// equal ⇒ the VICTIM-SET assertions below, not the equality, are what reject it. + /// * *republish the forced point* (revert the sibling row's withhold): the two derivations + /// coincide again and equality holds ⇒ the withhold reach-guard rejects it. + /// * *charge the victim but not the magnitude* (or vice versa): arm (A) yields 6, not 3 ⇒ + /// the exact-value assertions reject it. + /// + /// REVERT-PROBE, MEASURED (the mutation `cmp`-proved to have applied, and the file + /// restored byte-identically by SHA256 afterwards): RE-CONFLATE the two questions inside + /// the charging mint — add `.filter(|t| t.announcement == TargetAnnouncement::Chosen)` to + /// `game::engine::bounded_cycle_charged_targets_for_window`, which is precisely "charge + /// only what CR 732.2a publishes". The forced arm then charges NOTHING and the row FLIPS + /// TO FAILING at arm (A)'s first victim-set assertion: `[]` where `[PlayerId(1)]` is + /// required. Arm (B) is not separately measured because (A) panics first; it carries its + /// own exact-value assertion instead. + /// + /// ⚠ THE OTHER OBVIOUS REVERT DOES NOT REACH THIS ROW, and that is worth stating rather + /// than leaving to be re-derived: restoring step (7)'s published-point derivation inside + /// `try_offer_bounded_cycle_shortcut` leaves this row GREEN (measured), because this row + /// calls the charging mint directly. That revert is discriminated by the sibling + /// production-offer row `the_bounded_offer_charges_a_forced_victim_it_publishes_no_point_for`, + /// which flips on it. The two rows cover the two halves of the seam on purpose. + #[test] + fn a_withheld_forced_announcement_is_charged_like_a_published_one() { + use crate::analysis::decision_template::DecisionPointKind; + use crate::game::engine::{ + bounded_cycle_charged_targets_for_window, bounded_cycle_pin_slots, + }; + use std::collections::BTreeMap; + + /// Step (7)'s own two derivations, verbatim — the union of the CHARGED + /// announcements' legal player sets, and the per-slot magnitude keyed by + /// `worst_seat_life_loss`. One function, so neither arm can compute them a + /// different way. + fn step_seven( + state: &GameState, + delta: &ResourceVector, + ) -> (Vec, BTreeMap) { + let touch = + certified_period_touch(&[], state, PeriodCertification::ResourceSignatureOnly); + let charged = bounded_cycle_charged_targets_for_window(&touch, PlayerId(0)); + let mut victims: Vec = charged + .iter() + .flat_map(|(_, seats)| seats.iter().copied()) + .collect(); + victims.sort_unstable(); + victims.dedup(); + let magnitude = charged + .iter() + .map(|(slot, _)| (slot.clone(), delta.worst_seat_life_loss())) + .collect(); + (victims, magnitude) + } + + // One board per arm: `players` seats, P0 (the proposer) at 21, P1 (the victim) at + // 7, and every further seat parked at 40 so only P1's headroom can bind. + let board = |players: u8| { + let (_prior, mut current) = grown_window(players, |id| drain_entry(id, vec![])); + for p in current.players.iter_mut() { + p.life = match p.id { + PlayerId(0) => 21, + PlayerId(1) => 7, + _ => 40, + }; + } + current + }; + let life_delta = |seats: &[(PlayerId, i64)]| { + let mut v = ResourceVector::default(); + for (seat, n) in seats { + v.life.insert(*seat, *n); + } + v + }; + + let forced = board(2); + let chosen = board(3); + + // ── REACH-GUARDS: the two arms really are the withheld/published pair ──────────── + assert!( + bounded_cycle_pin_slots(&forced, PlayerId(0)).is_empty(), + "REACH-GUARD: the 2p arm's announcement must still be WITHHELD (CR 732.2a — one \ + legal assignment is no game choice). Without this the row would be satisfied by \ + re-publishing the forced point, which is the change the sibling row forbids" + ); + assert!( + bounded_cycle_pin_slots(&chosen, PlayerId(0)) + .iter() + .any(|p| matches!(p.kind, DecisionPointKind::Targets { .. })), + "REACH-GUARD: the 3p arm must PUBLISH its `Targets` point, else 'published' and \ + 'withheld' name the same board and the equality below is vacuous" + ); + + for (label, delta, expected, uncharged) in [ + ( + "(A) ordinary forced drain", + life_delta(&[(PlayerId(1), -1)]), + 3, + 6, + ), + ( + "(B) victim nets a life GAIN", + life_delta(&[(PlayerId(1), 1), (PlayerId(0), -2)]), + 3, + 10, + ), + ] { + let (forced_victims, forced_magnitude) = step_seven(&forced, &delta); + let (chosen_victims, chosen_magnitude) = step_seven(&chosen, &delta); + + // The victim SETS, asserted by content: an implementation that charged every + // living seat would keep the two bounds equal and pass the equality alone. + assert_eq!( + forced_victims, + vec![PlayerId(1)], + "{label}: CR 704.5a — the WITHHELD announcement still charges the one seat \ + its legal set names, and only that seat" + ); + assert_eq!( + chosen_victims, + vec![PlayerId(1), PlayerId(2)], + "{label}: and the published one charges both of its legal targets" + ); + assert_eq!( + (forced_magnitude.len(), chosen_magnitude.len()), + (1, 1), + "{label}: one SOURCE announces on both boards, so exactly one slot is \ + charged on each (PER SOURCE, NOT PER ENTRY)" + ); + + let forced_bound = + delta.elimination_bounds(&forced, &forced_victims, &forced_magnitude); + let chosen_bound = + delta.elimination_bounds(&chosen, &chosen_victims, &chosen_magnitude); + assert_eq!( + forced_bound, chosen_bound, + "{label}: CR 704.5a — the bound must not move because CR 732.2a declined to \ + publish the announcement as a game choice. The victim loses the life either \ + way; who chose the target is not an input to how much is lost" + ); + assert_eq!( + forced_bound, expected, + "{label}: and the shared value is the CHARGED one ({expected}), re-derived \ + by hand above — not the UNCHARGED {uncharged} the published-point \ + derivation produced" + ); + assert_ne!( + expected, uncharged, + "{label}: fixture guard — the two derivations must actually disagree on this \ + board, else the row cannot discriminate" + ); + } + } + + /// CR 704.5a + CR 732.2a — **a slot ANNOUNCED TWICE in one window is charged the UNION of + /// its legal sets, not the first frame's.** + /// + /// The two mints agree on WHICH entries the cycle accepts (both read `entry_announces`) + /// and they dedup the same slot the same way — but they keep DIFFERENT FRAMES of a repeat, + /// because publication skips a `NotProposerChoice` frame and charging does not. First-wins + /// charging therefore let a NARROW earlier frame's legal set stand for a slot the schema + /// publishes from a WIDER later one: the schema states the client may pin P2, + /// `declarable_victims` reads `[P1]`, `elimination_bounds` never charges P2, and + /// `max_iterations` GROWS. That is the fail-OPEN direction, on the operator whose whole job + /// is proving the proposed sequence "may be legally taken based on the current game state". + /// + /// # The board, and why it is a legal transition rather than a contrived one + /// + /// Three frames on ONE source (`CHURN_SRC`, P0's). The middle frame carries a P2-controlled + /// permanent whose `StaticMode::Hexproof` affects its controller — CR 702.11c, "you can't + /// be the target of spells or abilities your opponents control" — so the announcement + /// authority enumerates ONE opponent there and the announcement is forced. On the live + /// board that permanent has LEFT, so both opponents are legal and the announcement is the + /// proposer's choice. A permanent leaving the battlefield between two retained ring frames + /// is an ordinary event; nothing here rewinds an irreversible fact (contrast + /// `is_eliminated`, which is why this row does not use the elimination lever the sibling + /// rows use). + /// + /// **REACHABILITY: NARROW, AND NOT CLOSED — stated in both directions.** No production + /// trajectory that reaches this shape has been built, by the reviewer, the orchestrator or + /// this row. Elimination — the realistic mechanism, and the one every tracked dump shows — + /// narrows the legal set MONOTONICALLY, which puts the widest frame first and lands + /// first-wins fail-CLOSED. The fail-open direction needs a seat's untargetability to END + /// mid-window; a corpus census measured 14 cards granting a player untargetability + /// mid-loop, all self-protective and predominantly "until end of turn", which does not + /// expire mid-turn, so the path additionally needs the grantor to leave or a shorter + /// duration. This row builds the grantor-leaves half at the mint's own boundary. It is + /// NOT evidence that a full drive reaches it, and the shape is NOT "unreachable". + /// + /// # What a wrong implementation would still pass this row, and the guard for each + /// + /// * *charge every living seat* — passes the union assertion and FAILS the narrow-frame + /// reach-guard, which pins the first frame's legal set at exactly `[P1]`. + /// * *keep the LAST frame instead of unioning* — indistinguishable HERE (the later frame + /// is the wider one) and equally sound on this board, but it is not monotone in general; + /// the `charged.len() == 1` + slot-identity guards are what keep the row about the DEDUP + /// rather than about frame order, and the doc on + /// `game::engine::bounded_cycle_charged_targets_for_window` carries the monotonicity + /// argument the union rests on. + /// * *publish nothing at all* — the publication reach-guard requires the WIDE point to + /// reach the schema at the same `DecisionSlot`, so a mint that published nothing fails + /// before the claim. + /// * *drop the dedup entirely* — `charged.len() == 1` fails; two charged copies of one + /// slot would double `declared_life_magnitude` and silently halve the bound. + /// + /// REVERT-PROBE, and it is the shipped code's own previous form: replace the union arm + /// with `if charged.iter().any(|(slot, _)| *slot == target.slot) { continue; }`. The + /// charged victim list reads `[PlayerId(1)]` and the row FLIPS TO FAILING at the union + /// assertion; the bound assertion below then reads 6 where 3 is required. + #[test] + fn a_repeated_slots_victim_lists_are_unioned_not_first_wins() { + use crate::analysis::decision_template::DecisionPointKind; + use crate::game::ability_utils::build_target_slots; + use crate::game::engine::{ + bounded_cycle_charged_targets_for_window, bounded_cycle_pin_slots_for_window, + }; + use std::collections::BTreeMap; + + const GRANTOR: ObjectId = ObjectId(600); + + // P1 is parked out of reach so only P2's headroom can bind the life axis, and P2 is + // seeded at 7 so neither the charged nor the uncharged bound lands on the `1` floor. + let mut base = drain_state(3); + for p in base.players.iter_mut() { + p.life = match p.id { + PlayerId(0) => 21, + PlayerId(1) => 40, + _ => 7, + }; + } + + // Window head: nothing on the stack, so frame 1's entry counts as ANNOUNCED there. + let head = base.clone(); + + // Frame 1 — CR 702.11c: P2 controls a permanent granting its controller hexproof, so + // an opponent-controlled source cannot target them and the announcement is forced. + let mut narrow = base.clone(); + let mut grantor = GameObject::new( + GRANTOR, + CardId(77), + PlayerId(2), + "You Have Hexproof".to_string(), + Zone::Battlefield, + ); + grantor.static_definitions = + vec![ + StaticDefinition::new(StaticMode::Hexproof).affected(TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::You), + )), + ] + .into(); + narrow.objects.insert(GRANTOR, grantor); + narrow.battlefield.push_back(GRANTOR); + crate::game::layers::flush_layers(&mut narrow); + narrow.stack.push_back(drain_entry(10, vec![])); + + // The live board — the grantor has left, so both opponents are legal again. + let mut current = base.clone(); + current.stack.push_back(drain_entry(20, vec![])); + + let legal = |state: &GameState, entry: usize| { + build_target_slots(state, state.stack[entry].ability().unwrap()) + .map(|slots| { + slots + .iter() + .map(|s| (s.optional, s.legal_targets.clone())) + .collect::>() + }) + .ok() + }; + + // ── REACH-GUARDS: the window really is the narrow-then-wide repeat ────────────── + assert_eq!( + legal(&narrow, 0), + Some(vec![(false, vec![TargetRef::Player(PlayerId(1))])]), + "REACH-GUARD: CR 702.11c — the hexproof grantor must actually remove P2 from the \ + announcement authority's legal set on the FIRST frame, else this row is two \ + identical frames and the dedup is unobservable" + ); + assert_eq!( + legal(¤t, 0), + Some(vec![( + false, + vec![ + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)) + ] + )]), + "REACH-GUARD: and the live board must admit BOTH opponents, so the later frame is \ + the WIDER one" + ); + + let touch = certified_period_touch( + &[&head, &narrow], + ¤t, + PeriodCertification::ResourceSignatureOnly, + ); + assert_eq!( + touch + .announced + .iter() + .map(|(_, e)| e.id) + .collect::>(), + vec![ObjectId(10), ObjectId(20)], + "REACH-GUARD: exactly two announcements, NARROW FIRST — first-wins keeps the \ + narrow one, which is the whole shape under test" + ); + + // ── The publication half: ONE point, carrying the WIDE legal set ──────────────── + let points = bounded_cycle_pin_slots_for_window(&touch, PlayerId(0)); + assert_eq!( + points.len(), + 1, + "REACH-GUARD: the narrow frame's announcement is forced and withheld, the wide \ + one is the proposer's own choice and published — exactly one point: {points:?}" + ); + assert_eq!( + points[0].kind, + DecisionPointKind::Targets { + legal_targets: vec![ + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)) + ], + min_targets: 1, + max_targets: 1, + ordered: false, + }, + "REACH-GUARD: the SCHEMA states the client may pin P2. Everything below is about \ + the bound owing a charge for that stated pin" + ); + + // ── THE CLAIM: one slot, and its charge is the UNION ──────────────────────────── + let charged = bounded_cycle_charged_targets_for_window(&touch, PlayerId(0)); + assert_eq!( + charged.len(), + 1, + "PER SOURCE, NOT PER ENTRY: both entries carry one source, so one slot is \ + charged. Two copies would double `declared_life_magnitude` and halve the bound \ + instead of widening the victim set: {charged:?}" + ); + assert_eq!( + charged[0].0, points[0].slot, + "the charged slot IS the published slot — without this the union below could be \ + about a different decision point than the one the schema offers" + ); + assert_eq!( + charged[0].1, + vec![PlayerId(1), PlayerId(2)], + "CR 704.5a: a repeated slot charges the UNION of its announcements' legal player \ + sets. First-wins reads [P1] here, so the schema would offer a P2 pin that \ + `elimination_bounds` never charges and `max_iterations` would GROW" + ); + + // ── And the bound really moves, so the union is not a cosmetic set difference ─── + let mut delta = ResourceVector::default(); + delta.life.insert(PlayerId(2), -1); + let magnitude: BTreeMap = charged + .iter() + .map(|(slot, _)| (slot.clone(), delta.worst_seat_life_loss())) + .collect(); + assert_eq!( + delta.elimination_bounds(¤t, &charged[0].1, &magnitude), + 3, + "P2 is a declarable victim, so its life magnitude is `observed 1 + S 1 = 2` over \ + CR 704.5a headroom `7 - 1`; first-wins leaves P2 out of the victim set, charges \ + the bare observed 1 and returns 6" + ); + assert_eq!( + delta.elimination_bounds(¤t, &[PlayerId(1)], &magnitude), + 6, + "fixture guard — the two victim sets must actually disagree on this board, else \ + the assertion above cannot discriminate" + ); + } + + /// CR 601.2c + CR 115.1 + CR 732.2a — **an announcement the PROPOSER does not make is + /// withheld from the schema and charged all the same, on all THREE of CR 601.2c's axes.** + /// + /// `TargetAnnouncement` answers "is announcing this a game choice the proposer makes". + /// `forced_unique_targeting` answers only the assignment-COUNT half of that question, and + /// two `triggers::prepare_trigger_targets` routes raise no prompt for the proposer while + /// the count half reports "not forced": + /// + /// * **(a) CR 601.2c `target_chooser`** — "of an opponent's choice" (Volcanic Offering's + /// shape). `ability_utils::auto_select_targets_for_ability` early-returns `Ok(None)` + /// whenever ANY slot carries a chooser, so the count half is false even with exactly ONE + /// legal assignment — arm (a2) is that exact board. The prompt is raised for the + /// CHOOSER, `record_trigger_target_answer` journals under the seat that answered, and + /// every consumer reads `loop_answer(slot, proposer)` ⇒ an unanswerable published point, + /// which is the undeclarable-offer condition the bounded offer exists to remove. + /// * **(b) `TargetSelectionMode::Random`** — routed to `random_select_targets_for_ability` + /// and then `AutoAssigned`, so no prompt is ever raised, and the pin RELIEVES gate (3): + /// the offer would be minted because of a designation the RNG contradicts at drive time. + /// + /// **SCOPING HONESTY: the publication behaviour PREDATES the commit this row ships in.** + /// What is new is a named authority claiming to answer the whole question while reading one + /// of its three members. This row is not evidence of a defect this commit introduced. + /// + /// # What a wrong implementation would still pass, and the guard for each + /// + /// * *withhold everything* — arm (c) publishes on the SAME 3p board with neither axis set, + /// so a mint that published nothing fails there. + /// * *withhold by legal-set size* — arm (a1)/(b) have TWO legal opponents and are still + /// withheld; arm (c) has the same two and publishes. Size cannot separate them. + /// * *withhold, and also stop charging* — every arm asserts the CR 704.5a charge survives + /// with the full legal player set, which is the half `elimination_bounds` reads. + /// * *key the chooser on presence rather than on the SEAT* — not discriminated here and + /// deliberately so: `collect_target_slots` already drops a chooser equal to the + /// ability's controller, so on these fixtures `is_some()` and `is_some_and(!= proposer)` + /// coincide. The inequality guards `entry.controller != ability.controller` skew, which + /// no fixture in this crate builds. + /// + /// REVERT-PROBE (each measured separately, since the first failing arm panics): delete the + /// `slot.chooser` disjunct from `game::engine::entry_announces` ⇒ arms (a1)/(a2) FLIP TO + /// FAILING on "must be WITHHELD"; delete the `target_selection_mode` disjunct ⇒ arm (b) + /// flips instead. Neither deletion touches arm (c), which is what makes the two axes + /// separately attributable rather than jointly. + #[test] + fn an_announcement_the_proposer_does_not_make_is_withheld_but_still_charged() { + use crate::analysis::decision_template::DecisionPointKind; + use crate::game::ability_utils::build_target_slots; + use crate::game::engine::{ + bounded_cycle_charged_targets_for_window, bounded_cycle_pin_slots, + entry_publishes_pin_slots, + }; + use crate::types::ability::TargetSelectionMode; + + // The drain, with one of CR 601.2c's non-count announcement axes set. + let axis_drain = |id: u64, chooser: bool, random: bool| { + let mut ability = lose_life_targeting(event_amount(), opp_typed(vec![])); + ability.targets = vec![TargetRef::Player(PlayerId(1))]; + if chooser { + // CR 601.2c: "of an opponent's choice" — `resolve_effect_player_ref` reads the + // already-announced opponent target, so the announcing seat is P1. + ability.target_chooser = Some(TargetFilter::Opponent); + } + if random { + ability.target_selection_mode = TargetSelectionMode::Random; + } + churn_entry(id, 0, ability, None) + }; + + let charged_victims = |state: &GameState| { + let touch = + certified_period_touch(&[], state, PeriodCertification::ResourceSignatureOnly); + bounded_cycle_charged_targets_for_window(&touch, PlayerId(0)) + }; + let announcement_slot = |state: &GameState| { + build_target_slots(state, state.stack[2].ability().unwrap()) + .map(|slots| { + slots + .iter() + .map(|s| (s.optional, s.chooser, s.legal_targets.clone())) + .collect::>() + }) + .ok() + }; + + // ── (c) CONTROL first: neither axis, two legal opponents ⇒ PUBLISHED ──────────── + let (_pc, control) = grown_window(3, |id| axis_drain(id, false, false)); + let control_points = bounded_cycle_pin_slots(&control, PlayerId(0)); + assert_eq!(control_points.len(), 1, "control: {control_points:?}"); + assert!( + matches!(control_points[0].kind, DecisionPointKind::Targets { .. }), + "control: an announcement the proposer DOES make is still published — without \ + this every withhold below is satisfied by a mint that publishes nothing" + ); + + // ── (a1) CR 601.2c chooser, 3p: two legal assignments, still not the proposer's ── + let (_p1, chooser_3p) = grown_window(3, |id| axis_drain(id, true, false)); + assert_eq!( + announcement_slot(&chooser_3p), + Some(vec![( + false, + Some(PlayerId(1)), + vec![ + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)) + ] + )]), + "REACH-GUARD (a1): ONE mandatory slot over PLAYERS whose ANNOUNCER is P1, not the \ + proposer — every conjunct upstream of the announcement check accepts this entry, \ + and the legal set is the SAME SIZE as the control's, so size cannot be the \ + discriminator" + ); + assert!( + entry_publishes_pin_slots(&chooser_3p, &chooser_3p.stack[2], PlayerId(0)).is_none(), + "CR 601.2c: P1 announces this target, so no point the PROPOSER could answer is \ + published — a published one is unanswerable at `loop_answer(slot, proposer)` and \ + one unanswerable point makes the WHOLE offer undeclarable" + ); + assert_eq!( + charged_victims(&chooser_3p) + .into_iter() + .map(|(_, seats)| seats) + .collect::>(), + vec![vec![PlayerId(1), PlayerId(2)]], + "CR 704.5a: withheld is not uncharged — whoever announces it, the named seat \ + loses the life" + ); + + // ── (a2) CR 601.2c chooser, 2p: EXACTLY ONE legal assignment, and the count half is + // blind to it — the precise claim, isolated ──────────────────────────────── + let (_p2, chooser_2p) = grown_window(2, |id| axis_drain(id, true, false)); + assert_eq!( + announcement_slot(&chooser_2p), + Some(vec![( + false, + Some(PlayerId(1)), + vec![TargetRef::Player(PlayerId(1))] + )]), + "REACH-GUARD (a2): exactly ONE legal assignment" + ); + assert!( + !forced_unique_targeting(&chooser_2p, chooser_2p.stack[2].ability().unwrap()), + "REACH-GUARD (a2): and the assignment-COUNT authority still reports NOT forced — \ + `auto_select_targets_for_ability` early-returns `Ok(None)` on any chooser. This \ + is why the count half alone minted `Chosen` for a one-assignment announcement" + ); + assert!( + entry_publishes_pin_slots(&chooser_2p, &chooser_2p.stack[2], PlayerId(0)).is_none(), + "so the withhold must come from the CHOOSER axis, not from forced-ness" + ); + assert_eq!( + charged_victims(&chooser_2p) + .into_iter() + .map(|(_, seats)| seats) + .collect::>(), + vec![vec![PlayerId(1)]], + "CR 704.5a: still charged, and only the one seat its legal set names" + ); + + // ── (b) CR 115.1 overridden: the GAME selects, so nobody is prompted ───────────── + let (_p3, random_3p) = grown_window(3, |id| axis_drain(id, false, true)); + assert_eq!( + announcement_slot(&random_3p), + Some(vec![( + false, + None, + vec![ + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)) + ] + )]), + "REACH-GUARD (b): no chooser is involved — this arm is the SELECTION-MODE axis \ + alone, on the control's own legal set" + ); + assert!( + !forced_unique_targeting(&random_3p, random_3p.stack[2].ability().unwrap()), + "REACH-GUARD (b): two legal assignments, so the count authority reports NOT \ + forced and would have published" + ); + assert!( + entry_publishes_pin_slots(&random_3p, &random_3p.stack[2], PlayerId(0)).is_none(), + "CR 115.1 is overridden — `prepare_trigger_targets` routes this to \ + `random_select_targets_for_ability` and `AutoAssigned`, raising no prompt at \ + all, so a pin would be a designation the RNG contradicts at drive time" + ); + assert_eq!( + charged_victims(&random_3p) + .into_iter() + .map(|(_, seats)| seats) + .collect::>(), + vec![vec![PlayerId(1), PlayerId(2)]], + "CR 704.5a: the RNG names one of these seats and it loses the life" + ); + } + + /// CR 704.5a + CR 732.2a — **the same fix at the PRODUCTION OFFER, on a board that + /// publishes NO decision point at all.** + /// + /// [`a_withheld_forced_announcement_is_charged_like_a_published_one`] drives step (7)'s + /// derivations directly; this one drives + /// `try_offer_bounded_cycle_shortcut_metered` — the producer the interactive bridge + /// calls — and asserts the value that actually ships to the client. + /// + /// THE COMBINATION IS UNREACHABLE BEFORE THE FIX: a published schema with ZERO decision + /// points, and a certificate whose `victim_slot` is NON-EMPTY. Both derivations used to + /// read the same list, so "no points" implied "nothing charged" by construction. + /// + /// # The board + /// + /// `ring_announcing_on_its_newest_sample` is a 2-seat ring whose newest retained sample + /// carries the drain entry, so the announcement's legal set is the single opponent and + /// the announcement is FORCED. The harness steps P1's life one point per retained frame, + /// so the certified period's measured delta is P1 `-1`. + /// + /// # The arithmetic, re-derived independently of `elimination_bounds` + /// + /// The certifying pair is the ring frame one period back against the live board, and the + /// harness steps P1 one life point per retained frame, so the MEASURED per-period delta is + /// P1 `-2` (asserted below rather than assumed). `worst_seat_life_loss` is therefore 2 and + /// one slot is charged, so `S = 2`; P1 is a declarable victim and is charged + /// `observed 2 + S 2 = 4` against CR 704.5a headroom `21 - 1 = 20`, giving **5**. + /// Uncharged — the published-point derivation, which sees no points here at all — P1's + /// magnitude is the bare observed `2` and the bound is **10**. + /// + /// P1 is seeded at 21 rather than the harness default so neither value lands on the `1` + /// floor of the legal range, where an over-charging bug would be indistinguishable from + /// the right answer. + /// + /// REVERT-PROBE: restore step (7)'s published-point derivation ⇒ `victim_slot` is empty + /// and `max_iterations` is 10 ⇒ both the non-empty assertion and the value assertion FLIP. + #[test] + fn the_bounded_offer_charges_a_forced_victim_it_publishes_no_point_for() { + use crate::game::engine::{ + try_offer_bounded_cycle_shortcut_metered, BoundedOfferRefusal, ProbeCap, + }; + use crate::types::game_state::WaitingFor; + + let state = ring_announcing_on_its_newest_sample( + |s| { + announcing_ring_source(s, CHURN_SRC); + // Seeded BEFORE any frame is snapshotted, so every retained sample and the + // live board share this headroom and only the harness's own per-frame step + // separates them. + s.players + .iter_mut() + .find(|p| p.id == PlayerId(1)) + .expect("the harness seats P1") + .life = 21; + }, + |frame| { + frame.stack.push_back(drain_entry(950, vec![])); + }, + ); + // REACH-GUARD: the fixture must really be the FORCED shape, or this row is about the + // ordinary published path the F4 dumps already cover. + let announced = announced_from_retained_sample(&state, 950); + assert!( + forced_unique_targeting(announced, announced.stack[0].ability().unwrap()), + "REACH-GUARD: one living opponent ⇒ one legal assignment ⇒ the dispatcher \ + announces the target itself and no player is ever asked" + ); + + let (outcome, meter) = + try_offer_bounded_cycle_shortcut_metered(&state, false, ProbeCap::Shipped); + let waiting = outcome.unwrap_or_else(|refusal: BoundedOfferRefusal| { + panic!( + "REACH-GUARD: the bounded offer must FIRE on this board, else every assertion \ + below is made about a refusal; got {refusal:?}, meter {meter:?}" + ) + }); + let WaitingFor::LoopShortcut { + certificate, + schema, + .. + } = &waiting + else { + panic!("the bounded producer returns a `LoopShortcut` offer; got {waiting:?}") + }; + let per_cycle = certificate + .per_cycle + .as_ref() + .expect("a bounded offer publishes the per-period signature its bound was divided by"); + + assert!( + schema.points.is_empty(), + "CR 732.2a: the ONE announcement on this board is FORCED, so the schema publishes \ + no decision point at all; got {:?}", + schema.points + ); + assert_eq!( + per_cycle.delta.life.get(&PlayerId(1)).copied(), + Some(-2), + "REACH-GUARD: the certified period must really drain the victim, else the bound \ + below is not about a CR 704.5a threshold; delta {:?}", + per_cycle.delta + ); + assert_eq!( + per_cycle + .victim_slot + .iter() + .map(|(_, m)| *m) + .collect::>(), + vec![2], + "CR 704.5a: the forced announcement is CHARGED even though CR 732.2a published no \ + point for it — the combination that was unreachable before, because both \ + derivations read the published list; got {:?}", + per_cycle.victim_slot + ); + assert_eq!( + schema.max_iterations, 5, + "CR 704.5a: headroom `21 - 1` over the charged magnitude `observed 2 + S 2`. The \ + published-point derivation charged nothing here and produced 10, declaring twice \ + as many repetitions legal as CR 732.2a permits" + ); + } + /// A slot an OFFER would publish for `CHURN_SRC`'s entries — built through the same /// authority the gates rebuild it with, so the rows prove the KEY matches rather than /// asserting a hand-written literal. `index: 0` is the CR 115.2 target choice, @@ -7434,7 +8396,10 @@ mod tests { "PINNED: the published CR 603.5 gate specifies that choice ⇒ cover" ); // The MayChoice point is load-bearing on its own: pinning only the target slot - // leaves the resolution choice unspecified. + // leaves the resolution choice unspecified. NOTE the target slot is pinned but NOT + // published on this board — the target is forced-unique (asserted above), so + // `a_forced_target_is_not_a_published_decision_point` is the row that owns that fact. + // Gate (3) still passes here on the ordering-input arm; only gate (6) rejects. assert!( !loop_states_cover_modulo_growth_scoped( &p_may, @@ -12799,6 +13764,7 @@ mod tests { predicted_winner: None, certificate: cert.clone(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; let offer_json = serde_json::to_string(&offer).expect("the LoopShortcut payload carrying it must too"); @@ -12817,6 +13783,7 @@ mod tests { ..cert }, schema: ShortcutDecisionSchema::default(), + declaration: None, }; let shipped_json = serde_json::to_string(&shipped).expect("serializes"); assert!( @@ -14174,9 +15141,10 @@ mod tests { "the extractor must return the whole predicate, not a truncated span; got \ {head}-{end}" ); + // The shared comment rule (`crate::source_census::code`), so a trailing comment naming + // one of the tokens below cannot be read as a code site. let code: Vec<(usize, &str)> = (head..=end) - .map(|i| (i, lines[i])) - .filter(|(_, l)| !l.trim_start().starts_with("//")) + .map(|i| (i, crate::source_census::code(lines[i]))) .collect(); let item6_head = code diff --git a/crates/engine/src/bin/interaction_bindings.rs b/crates/engine/src/bin/interaction_bindings.rs index 2ade424c1c..97f3d208c7 100644 --- a/crates/engine/src/bin/interaction_bindings.rs +++ b/crates/engine/src/bin/interaction_bindings.rs @@ -14,7 +14,8 @@ use engine::types::interaction::{ InteractionRelationConstraint, InteractionRelationSourceConstraint, InteractionResponse, InteractionResponseSpec, InteractionRoleCode, InteractionShortcutCountSpec, InteractionShortcutDecision, InteractionShortcutPin, InteractionShortcutPoint, - InteractionShortcutPointKind, InteractionShortcutReply, InteractionShortcutResponseCode, + InteractionShortcutPointKind, InteractionShortcutPreview, InteractionShortcutPreviewEntry, + InteractionShortcutPreviewFamily, InteractionShortcutReply, InteractionShortcutResponseCode, InteractionSlotKind, InteractionSubmission, InteractionSummaryCode, InteractionWaitingForCode, InteractionWaitingForKind, InteractionZoneCode, SelectionConstraint, SimultaneousDecisionKind, ViewerInteraction, @@ -89,6 +90,9 @@ fn expected_bindings() -> String { InteractionRelationSourceConstraint, InteractionDamageAssignmentMode, InteractionShortcutCountSpec, + InteractionShortcutPreviewFamily, + InteractionShortcutPreviewEntry, + InteractionShortcutPreview, InteractionShortcutPointKind, InteractionShortcutPoint, InteractionShortcutPin, diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index a7b294e186..196ca80426 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -1839,33 +1839,41 @@ fn scheduled_display_axes( axes } -/// CR 732.2a: which player's HUD a pumped `axis` belongs to, given the loop's -/// `controller`. Exhaustive by design (no wildcard) — a new `ResourceAxis` -/// variant must make a deliberate attribution choice here, never silently inherit -/// a default. +/// The seat a resource axis LANDS ON, for the four axes that name one — and `None` for every +/// axis that is a whole-game quantity with no seat at all. /// -/// A payload-keyed axis names the player it acts on, so the badge follows the -/// payload, NOT permanent control: +/// **This is the SINGLE authority for that partition.** Both consumers derive from it rather +/// than restating it: [`attribution_player`] below (which resolves `None` to the loop's +/// controller for the HUD badge) and `game::interaction`'s CR 732.2a shortcut preview (which +/// publishes `None` as "no seat"). Two exhaustive matches over the same 17 `ResourceAxis` +/// variants would force a future payload-keyed axis to make two decisions but not the SAME +/// decision, and the offer could then attribute a seat the HUD does not. +/// +/// Exhaustive by design (no wildcard) — a new `ResourceAxis` variant must make a deliberate +/// seat choice here, never silently inherit a default. +/// +/// A payload-keyed axis names the player it acts on, so the seat follows the payload, NOT +/// permanent control, and in particular is NOT keyed from the loop's proposer — a drain's +/// magnitude belongs to the player LOSING the life, which is exactly the key +/// `ResourceVector`'s per-player maps already use: /// - CR 119.3 + CR 704.5a: `Life(p)` — CR 119.3 makes `p` the player whose life total the /// effect adjusts, and CR 704.5a is why that matters (the afflicted player reaching 0 life /// loses). A drain drives an opponent's total down and lifegain raises the controller's own; -/// either way the badge belongs on `p`'s HUD. -/// - CR 120: `DamageDealt(p)` — damage accrues to the player it is dealt to, so an -/// opponent-burn loop shows `∞` on the victim's HUD. -/// - CR 704.5b: `LibraryDelta(p)` — a mill drives an opponent's library toward the -/// empty-draw loss and a self-mill the controller's own; the badge follows `p`. -/// +/// either way it belongs to `p`. +/// - CR 120.1: `DamageDealt(p)` — damage accrues to the player it is dealt to, so an +/// opponent-burn loop lands on the victim. +/// - CR 401 + CR 704.5b: `LibraryDelta(p)` — a mill drives an opponent's library toward the +/// empty-draw loss and a self-mill the controller's own; the seat follows `p`. /// - CR 704.5c: `Poison(p)` — a poison ∞ drives the afflicted player toward the -/// 10-poison loss, so the badge belongs on the VICTIM's HUD. +/// 10-poison loss, so it belongs to the VICTIM. /// -/// Every aggregate axis carries no victim PlayerId and is attributed to the loop's -/// `controller` (the player generating the unbounded resource). -fn attribution_player(axis: ResourceAxis, controller: PlayerId) -> PlayerId { +/// Every aggregate axis carries no victim `PlayerId` and therefore no seat. +pub(crate) fn payload_seat(axis: ResourceAxis) -> Option { match axis { ResourceAxis::Life(p) | ResourceAxis::DamageDealt(p) | ResourceAxis::LibraryDelta(p) - | ResourceAxis::Poison(p) => p, + | ResourceAxis::Poison(p) => Some(p), ResourceAxis::Mana(_) | ResourceAxis::Counter(_, _) | ResourceAxis::Trigger(_) @@ -1878,10 +1886,22 @@ fn attribution_player(axis: ResourceAxis, controller: PlayerId) -> PlayerId { | ResourceAxis::DeathTriggers | ResourceAxis::EtbTriggers | ResourceAxis::LtbTriggers - | ResourceAxis::SacTriggers => controller, + // A whole-game quantity: mana in a pool, tokens on a board, triggers on a stack. + // Nothing in the payload names a player, so there is no seat to report. + | ResourceAxis::SacTriggers => None, } } +/// CR 732.2a: which player's HUD a pumped `axis` belongs to, given the loop's `controller`. +/// +/// A thin resolution of [`payload_seat`], which is the authority for the partition and carries +/// the per-axis CR citations: a payload-keyed axis badges on the seat it names, and every +/// aggregate axis badges on the loop's `controller` (the player generating the unbounded +/// resource). Deliberately NOT a second exhaustive match — see `payload_seat`. +fn attribution_player(axis: ResourceAxis, controller: PlayerId) -> PlayerId { + payload_seat(axis).unwrap_or(controller) +} + /// CR 732.2a: whether the object-growth `∞` display set the accept registered for `axis` /// still has LIVE authority — i.e. at least one registered member is still on the /// battlefield (CR 110.1: a permanent stops being one as it moves to another zone). diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 9bdcc6ddb5..4302cf2322 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -1451,6 +1451,10 @@ fn reconcile_terminal_result(state: &mut GameState, result: &mut ActionResult) { predicted_winner: None, certificate, schema, + // CR 732.2a: the object-growth path re-derives its pins at materialize time + // from the carried recast template, so this offer states no engine-side + // declaration of its own. + declaration: None, }; result.waiting_for = state.waiting_for.clone(); } @@ -1527,6 +1531,9 @@ fn interactive_loop_bridge(state: &mut GameState, result: &mut ActionResult) { predicted_winner: Some(winner), certificate, schema, + // CR 732.2a: Path A publishes no decision points at all (the pin list above is + // empty), so there is nothing for a declaration to pin. + declaration: None, }; result.waiting_for = state.waiting_for.clone(); } @@ -2071,13 +2078,10 @@ fn certified_bounded_cycle_offer<'a>( verdicts: &mut crate::analysis::resource::PeriodVerdicts<'a>, cert_out: &mut Option, ) -> Result { - use crate::analysis::decision_template::{ - DecisionPoint, DecisionPointKind, DecisionSlot, IterationCount, - }; + use crate::analysis::decision_template::{DecisionPoint, DecisionSlot, IterationCount}; use crate::analysis::resource::{ certified_period_touch, PeriodCertification, PeriodTouch, PeriodicDelta, ResourceVector, }; - use crate::types::ability::TargetRef; let cur = ResourceVector::snapshot(state); // Written as an explicit newest-first walk rather than `find_map` because the candidate @@ -2290,20 +2294,38 @@ fn certified_bounded_cycle_offer<'a>( return Err(BoundedOfferRefusal::UnspecifiedChoiceWindow); } - // (7) THE BOUND. `declarable_victims` is the union of the published slots' legal targets - // — EMPTY for the untargeted class, where the victims are already in `delta.life`. + // (7) THE BOUND, derived from the ANNOUNCEMENT authority — never from `points`. + // + // ⚠ THE PUBLISHED POINT SET IS THE WRONG INPUT HERE, and reading it from there was a + // measured fail-OPEN. Publication answers CR 732.2a ("a sequence of game choices"), so + // step (6)'s mint WITHHOLDS the point for a FORCED announcement — correctly, since the + // announcing player makes no choice. The bound answers CR 704.5a ("if a player has 0 or + // less life, that player loses the game"), and a forced victim loses that life exactly as + // a chosen one does. Deriving the bound from `points` therefore made the CR 732.2a + // withhold drop the forced victim out of `declarable_victims`, charging it bare + // `observed_life_loss` instead of `observed_life_loss.max(0) + declared_life_magnitude` — + // so `max_iterations` GREW, and the offer stated more legal repetitions than are legal. + // Measured on an ordinary forced 2p targeted drain: 9 charged vs 19 uncharged; on a + // victim whose measured period NETS A LIFE GAIN the uncharged form leaves + // `elimination_bounds`' `narrow` guard (`magnitude > 0`) unfired and DISARMS the life + // axis at `MAX_SHORTCUT_CYCLES` entirely. + // + // `bounded_cycle_charged_targets_for_window` reads the SAME acceptance authority the + // point mint does (`entry_announces`), so the charged SLOT set is a superset of the + // published `Targets` slots by construction: on a board where every announcement is the + // proposer's own choice — every tracked dump today — this derivation is value-identical to + // the one it replaces. ⚠ THE SUPERSET IS OVER SLOTS ONLY. A repeated slot's per-slot LEGAL + // set is what `declarable_victims` below reads, and the two mints keep different frames of + // a repeat, so that set is made a superset separately, by the charging mint's UNION dedup + // (see its doc for the monotonicity proof). Claiming the per-slot legal set is a superset + // "by construction" from the shared acceptance authority alone is FALSE. + let charged_targets = bounded_cycle_charged_targets_for_window(&touch, proposer); + // `declarable_victims` is the union of those announcements' legal PLAYER sets — EMPTY for + // the untargeted class, where the victims are already in `delta.life`. let declarable_victims: Vec = { - let mut v: Vec = points + let mut v: Vec = charged_targets .iter() - .filter_map(|p| match &p.kind { - DecisionPointKind::Targets { legal_targets, .. } => Some(legal_targets), - _ => None, - }) - .flatten() - .filter_map(|t| match t { - TargetRef::Player(p) => Some(*p), - _ => None, - }) + .flat_map(|(_, victims)| victims.iter().copied()) .collect(); v.sort_unstable(); v.dedup(); @@ -2311,20 +2333,19 @@ fn certified_bounded_cycle_offer<'a>( }; // CR 704.5a: what ONE repetition charges to whichever seat a slot's pin names. The // max-vs-sum reasoning, the gain clamp and the fail-closed direction live on the - // function; `elimination_bounds` then sums the published slots per declarable victim. + // function; `elimination_bounds` then sums the charged slots per declarable victim. // Extracted rather than inlined so the fork has a callable seam. ⚠ THE "`victim_slot` IS // EMPTY ON EVERY TRAJECTORY THAT OFFERS TODAY" NOTE THAT STOOD HERE IS FALSIFIED, and is // replaced rather than softened: the answer-beat sampling site in `apply_action` announces // the entries a FORCED pre-priority window puts on the stack, and a CR 608.2b `Targets` - // declaration is exactly the shape that resolves across one. On the F4 boards `points` now - // carries Torch's `Targets` point, so this value is NOT dropped — it reaches + // declaration is exactly the shape that resolves across one. On the F4 boards the + // announcement carries Torch's target slot, so this value is NOT dropped — it reaches // `elimination_bounds` in production and `r1_the_bounded_offer_fires_on_the_real_f4_dump` // re-derives the published bound with a non-zero declared term. let worst_seat_life_loss: i64 = periodic.delta.worst_seat_life_loss(); - periodic.victim_slot = points + periodic.victim_slot = charged_targets .iter() - .filter(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) - .map(|p| (p.slot.clone(), worst_seat_life_loss)) + .map(|(slot, _)| (slot.clone(), worst_seat_life_loss)) .collect(); // `.cloned()`, not `.copied()`: `(DecisionSlot, i64)` is not `Copy`. let slot_magnitude: std::collections::BTreeMap = @@ -2365,14 +2386,161 @@ fn certified_bounded_cycle_offer<'a>( IterationCount::Fixed(max_iterations), max_iterations, ); + // (10) The DECLARATION the engine can already specify for this offer, read out of the + // answer journal the same window populated. Built AFTER the schema because `points` is + // moved into `build_shortcut_schema`, and taking `&schema` keeps one point list rather + // than two. + let declaration = build_bounded_declaration(state, proposer, &schema); Ok(WaitingFor::LoopShortcut { proposer, predicted_winner: None, certificate, schema, + declaration, }) } +/// CR 732.2a: the declaration THIS offer can already state, derived from what the proposer +/// actually answered at each published point — never from a constant and never from the +/// declaring client. +/// +/// CR 732.2a describes a shortcut proposal as "a sequence of game choices, for all players, +/// that may be legally taken based on the current game state and the predictable results of +/// the sequence of choices". Every published point of `schema` is one such choice; the +/// `(DecisionSlot, PlayerId)` journal holds the answer the proposer gave it during the +/// detection window (CR 601.2c announcements via `record_trigger_target_answer`, CR 603.5 +/// "may" answers via the `DecideOptionalEffect` arm). This function is the single authority +/// for turning that observation into a [`DecisionTemplate`], so the AI candidate generator and +/// the per-viewer projection read ONE value instead of each deriving their own. +/// +/// **Not a duplicate authority.** `game::interaction::materialize_loop_shortcut_response` +/// builds a conformant `DecisionTemplate` of the same shape (same `owner` / `decisions` / +/// `ReplayMode::Scheduled` / `DecisionGroupKey::from_sources` / `(!points.is_empty())` guard), +/// but from the CLIENT'S OWN submitted pins — a human's picks. This one is built from the +/// ENGINE'S OWN observed answers. Two inputs, one shape; a reviewer reading only the shape +/// would otherwise see duplication. +/// +/// # PUBLISHED IS VALIDATED — `is_some()` means "the declare handler will take this" +/// +/// `ai_support::candidates` gates its `DeclareShortcut` candidate on `declaration.is_some()` +/// and hands this very template to `handle_declare_shortcut`, which validates it. So the +/// publisher must not be able to emit anything that handler would refuse: step (5) runs the +/// SHARED [`crate::analysis::decision_template::declaration_conforms`] — the same coverage + +/// value-legality predicate that handler and the human ingress run — rather than a third +/// derivation of `required` alongside theirs. +/// +/// The range is `shortcut_validated_range(&schema.iteration_count, ..)`, i.e. this offer's own +/// ceiling, because it is the WIDEST count any declarer may name against this schema +/// (`is_bounded()` publishers set `iteration_count == Fixed(max_iterations)` and the handler +/// rejects anything above the cap). `validate_pins` re-checks `0..range`, so passing at the +/// ceiling implies passing at every shorter `Fixed(n)` the handler could be given. +/// +/// LATENT, NOT LIVE, and the distinction is not decoration: no tracked board reaches a +/// declaration this refuses — row D1 measures both gates passing at the full range on all +/// three dumps — because the publisher copies `legal_targets` from the same announcement the +/// journal answer came from, and `record_trigger_target_answer` bails above one announced +/// slot while this schema hard-codes `min/max: 1`. That agreement is an accident of two +/// functions with two predicates; step (5) is what makes it an invariant. +/// +/// FAIL-CLOSED on every uncertainty, because a wrong pin is worse than no offer: +/// +/// * an empty point set publishes no declaration at all — a declaration against an empty +/// schema would be the one shape `handle_declare_shortcut` validates neither +/// `predictability_gate` nor `validate_pins` against (both live inside its +/// `if !offer.schema.points.is_empty()` block); +/// * `None` (that seat never answered this slot) and [`LoopAnswer::Conflicted`] (it answered +/// two ways — see that type: an engine-capability refusal, NOT a CR 732.2a mandate) are the +/// SAME disposition here, because neither names a single answer to pin; +/// * the `(kind, value)` match is WILDCARD-FREE, so a future `DecisionPointKind` or +/// `LoopAnswerValue` variant gets a compile-time visit here instead of a silent pin. The +/// two kind/value MISMATCH groups return `None` rather than `unreachable!` because what +/// makes them unreachable is a key-shape agreement between publisher and writer, not a type +/// guarantee. +fn build_bounded_declaration( + state: &GameState, + proposer: PlayerId, + schema: &crate::analysis::decision_template::ShortcutDecisionSchema, +) -> Option { + use crate::analysis::decision_template::{ + DecisionGroupKey, DecisionKind, DecisionPointKind, DecisionTemplate, LoopAnswer, + LoopAnswerValue, PinnedDecision, ReplayMode, + }; + // (1) D4's grounds: an empty schema publishes no declaration. + if schema.points.is_empty() { + return None; + } + let mut decisions = Vec::with_capacity(schema.points.len()); + for point in &schema.points { + // (2) The journal read, under the PROPOSER's own key — the same key + // `record_trigger_target_answer` and the `DecideOptionalEffect` arm write under. + let LoopAnswer::Uniform(value) = state.loop_answer(&point.slot, proposer)? else { + return None; + }; + // (3) The wildcard-free (kind, value) match. + decisions.push(match (&point.kind, value) { + // CR 603.5: the "may" gate, answered Take or Decline. + (DecisionPointKind::MayChoice, LoopAnswerValue::May(take)) => { + PinnedDecision::MayChoice { + slot: point.slot.clone(), + take, + } + } + // CR 601.2c + CR 608.2b: the announced targets for this slot, in announcement + // order, re-checked for legality at every resolution. + (DecisionPointKind::Targets { .. }, LoopAnswerValue::Targets(targets)) => { + PinnedDecision::Targets { + slot: point.slot.clone(), + targets, + } + } + // Kind/value MISMATCH — the publisher and the journal writer disagree about what + // this slot is. Fail closed. + (DecisionPointKind::MayChoice, LoopAnswerValue::Targets(_)) + | (DecisionPointKind::Targets { .. }, LoopAnswerValue::May(_)) => return None, + // CR 700.2 modal / CR 732.6 "[A] unless [B]" / CR 601.2h + CR 702.51a convoke / + // CR 608.2d + CR 605.3b mana color: kinds this offer's publisher + // (`bounded_cycle_pin_slots_for_window`, which mints only `Targets` and + // `MayChoice`) cannot produce today. `LoopAnswerValue` carries no answer shape for + // any of them, so there is nothing to pin even when the slot IS journalled. + ( + DecisionPointKind::Mode { .. } + | DecisionPointKind::UnlessBreak + | DecisionPointKind::ConvokeTaps { .. } + | DecisionPointKind::ManaColor { .. }, + LoopAnswerValue::May(_) | LoopAnswerValue::Targets(_), + ) => return None, + }); + } + // (4) The template. `replay.count` carries the offer's own SUGGESTION; the driving count + // comes off `GameAction::DeclareShortcut` and nothing reads this copy (see + // `build_recast_template`'s note and `analysis::decision_template::resolve`'s doc). + let template = DecisionTemplate { + owner: proposer, + decisions, + replay: ReplayMode::Scheduled { + count: schema.iteration_count.clone(), + }, + key: DecisionGroupKey::from_sources( + &schema + .points + .iter() + .map(|point| point.slot.source.clone()) + .collect::>(), + DecisionKind::LoopChoice, + ), + }; + // (5) VALIDATE BEFORE PUBLISHING — the same authority `handle_declare_shortcut` accepts + // under. See this function's "Published is validated" doc section for why the range is the + // schema's OWN count and why this is not a third derivation. + crate::analysis::decision_template::declaration_conforms( + schema, + &template, + shortcut_validated_range(&schema.iteration_count, Some(&template)), + state, + ) + .then_some(template) +} + /// CR 704.5a / CR 704.5c: a determinate lethal drain (0-or-less life / 10-poison) repeats /// UntilLethal; every other CR 732.1b win seeds a `Fixed(1)` frontend count picker. Extracted /// as a pure classifier so the exhaustive `WinKind` mapping is unit-testable without a @@ -2494,7 +2662,7 @@ fn pinned_decisions_to_points( /// CR 115.2 + CR 732.2a: does the ability's HEAD effect declare the "target opponent" PLAYER /// filter — a `Typed` filter with no type constraints, no object properties, and /// `controller: Opponent`, the shape `game::targeting::find_legal_targets` collapses to -/// players-only (`crates/engine/src/game/targeting.rs:192-193`)? +/// players-only? /// /// SHAPE ACCEPTANCE ONLY, and the `bool` return is what enforces it: the published legal /// set must come from the announcement authority (`ability_utils::build_target_slots`), never @@ -2531,9 +2699,11 @@ fn declares_opponent_player_target(ability: &crate::types::ability::ResolvedAbil /// What ONE accepted stack entry publishes: the slot keys, plus the legal set the /// ANNOUNCEMENT authority itself built for the target slot. pub(crate) struct EntryPinSlots { - /// CR 115.2 target choice — `index: 0`. `None` for shape (B), the may-only entry: - /// announcing it surfaces NO choice at all (`targets.is_empty()` and zero built slots), - /// so there is no CR 601.2c announcement choice for a pin to specify. + /// CR 115.2 target choice — `index: 0`. `None` in TWO shapes, and both are the absence of + /// a CR 601.2c *choice* rather than the absence of a target: shape (B), the may-only entry, + /// announces NO slot at all (`targets.is_empty()` and zero built slots); shape (A′) + /// announces one whose assignment is FORCED (`forced_unique_targeting`), which CR 732.2a + /// does not count as a game choice and which the dispatcher answers itself. pub(crate) target: Option, /// CR 603.5 "may" gate — `index: 1`, `Some` only if `ability.optional` — the mint /// additionally refuses on recipient, stored auto-choice and prompt-cardinality grounds @@ -2546,13 +2716,111 @@ pub(crate) struct EntryPinSlots { /// exactly one mandatory choice. Deriving it a second time from the head effect's /// filter would let the two disagree about WHICH choice is being published, which is /// the same class of divergence the cardinality conjunct closes about HOW MANY. - /// Empty for shape (B), which publishes no target slot to carry a legal set for. + /// Empty for shapes (B) and (A′), neither of which publishes a target slot to carry a + /// legal set for. + pub(crate) legal_targets: Vec, +} + +/// CR 601.2c (reached for a triggered ability via CR 603.3d): the ONE target an accepted +/// entry ANNOUNCES — the slot key, the legal set the announcement authority itself built, +/// and whether announcing it is a game CHOICE. +/// +/// THE TWO QUESTIONS THIS TYPE KEEPS APART, because conflating them was a measured +/// fail-OPEN. PUBLICATION answers CR 732.2a — *is this a game choice the player makes?* — +/// and shapes the schema. CHARGING answers CR 704.5a — *which seat is charged, and how +/// much?* — and shapes the bound. A forced announcement is not a choice, so it is withheld +/// from the schema; its victim still loses the life, so it is still charged. Deriving the +/// bound from the PUBLISHED point set made the CR 732.2a withhold silently drop the forced +/// victim into `elimination_bounds`' cheaper arm and RAISE `max_iterations`. +pub(crate) struct AnnouncedTarget { + /// CR 115.2 target choice — `index: 0`, the same key a published point carries, so a + /// charge and a publication of the same announcement can never land on different slots. + pub(crate) slot: crate::analysis::decision_template::DecisionSlot, + /// The legal set of the ONE announcement slot, taken VERBATIM from + /// `ability_utils::build_target_slots` — the same authority that decided there is + /// exactly one mandatory choice, and the same one `forced_unique_targeting` rebuilds + /// slots with. Never a second derivation from the head effect's filter. pub(crate) legal_targets: Vec, + pub(crate) announcement: TargetAnnouncement, +} + +/// CR 732.2a: whether announcing an [`AnnouncedTarget`] is a *game choice the PROPOSER makes +/// at a prompt of their own*. +/// +/// Not a `bool`: the two arms name two different CR readings, and the whole defect this +/// type exists to prevent came from a caller re-deriving "was it a choice?" from a +/// downstream artifact instead of reading the answer. +/// +/// ⚠ THE QUESTION IS THREE-AXIS, and this type answered ONE of them while carrying the name of +/// all three. CR 601.2c routes an announcement by WHO announces (`target_chooser`) as well as +/// by HOW MANY assignments are legal (`forced_unique_targeting`), and CR 115.1 is overridden +/// outright when the game selects at random (`TargetSelectionMode`). Only the middle axis was +/// read. The publication BEHAVIOUR that gap produced predates the commit this type ships in — +/// what was new is a named authority claiming to answer "is announcing this a game choice the +/// proposer makes" while covering one of its three members. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TargetAnnouncement { + /// The PROPOSER announces this target, at a prompt that is really raised for them: + /// no other seat announces it (CR 601.2c `target_chooser`), the game does not select it + /// (CR 115.1 vs `TargetSelectionMode`), and `forced_unique_targeting` is false — so + /// `triggers::prepare_trigger_targets` routes it to `NeedsPlayerChoice`, a + /// `WaitingFor::TriggerTargetSelection` comes up under the proposer's own seat, and + /// `record_trigger_target_answer` can journal an answer AT THIS SLOT AND UNDER THIS KEY. + /// + /// ⚠ NOT "two or more legal assignments". That claim stood here and is FALSE: the conjunct + /// the code applies is the NEGATION of `forced_unique_targeting`, i.e. + /// `auto_select_targets_for_ability != Ok(Some(_))`. Two-or-more is its principal member, + /// but `Err` ("No legal target combinations available") negates it too, so this variant + /// carries no assignment COUNT — only the "nobody else and nothing else announced it, and + /// the dispatcher did not settle it" reading above. + Chosen, + /// The proposer makes no such announcement, so CR 732.2a publishes no decision point for + /// it. THREE DISJOINT ROUTES, each named at the code that takes it: + /// + /// * `forced_unique_targeting` — exactly one legal assignment, so the announcement is + /// determined rather than chosen and `triggers::prepare_trigger_targets` routes it to + /// `AutoAssigned` without asking anyone. + /// * CR 601.2c `target_chooser` ("of an opponent's choice") — a prompt IS raised, but for + /// ANOTHER seat. `ability_utils::auto_select_targets_for_ability` early-returns + /// `Ok(None)` whenever any slot carries a chooser, so `forced_unique_targeting` is false + /// even with ONE legal assignment. The writer journals under the ANNOUNCING seat while + /// the consumer reads `loop_answer(slot, proposer)` — an unanswerable published point, + /// which is the exact undeclarable-offer condition the bounded offer exists to remove. + /// * `TargetSelectionMode` other than `Chosen` — the game selects (CR 115.1 is overridden; + /// `triggers::prepare_trigger_targets` calls `random_select_targets_for_ability` and + /// routes to `AutoAssigned`), so no prompt is ever raised and a pin would be a + /// designation the RNG contradicts at drive time. + /// + /// CHARGED ALL THE SAME: CR 704.5a asks which seat loses how much life, and nobody having + /// made the choice changes neither who pays nor how much. Only the CR 732.2a publication + /// reader acts on this value. + NotProposerChoice, } -/// CR 732.2a: the per-iteration choice slots ONE stack entry publishes for `proposer`, or +/// CR 601.2c + CR 603.5: everything ONE accepted stack entry ANNOUNCES for `proposer`, +/// BEFORE the CR 732.2a question of how much of it is a published game choice. +/// +/// THE SINGLE ACCEPTANCE AUTHORITY. Both [`entry_publishes_pin_slots`] (publication) and +/// [`bounded_cycle_charged_targets_for_window`] (CR 704.5a charging) are thin readers of +/// this one function, so the two can never disagree about WHICH entries are in the cycle — +/// only about which of their announcements is a published choice. Two independent +/// acceptance chains that could disagree is exactly the shape gate (3)'s single-authority +/// rule exists to forbid. +struct EntryAnnouncement { + target: Option, + may: Option, +} + +/// CR 732.2a: the per-iteration choice slots ONE stack entry PUBLISHES for `proposer`, or /// `None` when it publishes none. /// +/// A THIN READER of [`entry_announces`], which owns every acceptance conjunct documented +/// below; this function contributes exactly one thing on top of it — the CR 732.2a +/// publication decision (a `Forced` announcement is not a game choice, so no point is +/// published for it). The CR 704.5a charging reader +/// ([`bounded_cycle_charged_targets_for_window`]) reads the SAME announcement, so the two +/// cannot disagree about which entries are in the cycle. +/// /// SINGLE AUTHORITY, and that is the whole point of its existence: the MINT /// ([`bounded_cycle_pin_slots_for_window`]) maps it over the certified period's announced /// pairs, of which `state.stack` is the zero-window degenerate case, and the RELIEF @@ -2573,6 +2841,12 @@ pub(crate) struct EntryPinSlots { /// `targets.is_empty()` and zero built slots); and its source object still exists, so the /// slot can re-bind (CR 400.7 incarnation, fail-closed on absence). /// +/// Shape (A) carries one further conjunct that is a property of the BOARD rather than of the +/// ability: the announcement must actually be a CHOICE. A slot with exactly one legal +/// assignment is FORCED (shape (A′)) — `triggers::prepare_trigger_targets` announces it +/// without asking anyone — so it publishes its CR 603.5 gate alone, or nothing. See the +/// `forced_unique_targeting` call in the body for why publishing it is the undeclarable case. +/// /// SCOPE OF THE ANSWER: because the relief is a `continue` at gate (3), the relief /// predicate must be no coarser than EVERY fact `stack_entry_has_no_ordering_input` /// rejects on — not just the target one. Correspondence, in that function's own order: @@ -2585,6 +2859,43 @@ pub(crate) fn entry_publishes_pin_slots( entry: &StackEntry, proposer: PlayerId, ) -> Option { + let announced = entry_announces(state, entry, proposer)?; + // CR 732.2a: a shortcut describes "a sequence of game choices", so ONLY a `Chosen` + // announcement earns a decision point. `NotProposerChoice` is withheld — see + // [`TargetAnnouncement::NotProposerChoice`] and the shape (A′) block in + // [`entry_announces`]. + let published = announced + .target + .filter(|target| target.announcement == TargetAnnouncement::Chosen); + // An entry that publishes NOTHING publishes no slot set at all. This is the fail-closed + // reading shapes (B) and (A′) each carried inline as a `Some(may?)`, stated ONCE here + // instead of once per shape; the observable result is identical. + if published.is_none() && announced.may.is_none() { + return None; + } + let (target, legal_targets) = match published { + Some(target) => (Some(target.slot), target.legal_targets), + None => (None, vec![]), + }; + Some(EntryPinSlots { + target, + may: announced.may, + legal_targets, + }) +} + +/// CR 601.2c + CR 603.5 (reached for a triggered ability via CR 603.3d): what ONE stack +/// entry ANNOUNCES for `proposer`, or `None` when the entry is not one this cycle accepts. +/// +/// Every acceptance conjunct documented on [`entry_publishes_pin_slots`] lives here. What +/// does NOT live here is the CR 732.2a publication decision: this function reports whether +/// the announcement is `Chosen` or `Forced` and lets its two readers apply that fact to the +/// question each is answering — the schema (publication) or the bound (CR 704.5a charging). +fn entry_announces( + state: &GameState, + entry: &StackEntry, + proposer: PlayerId, +) -> Option { use crate::analysis::decision_template::DecisionSlot; if entry.controller != proposer { return None; @@ -2655,8 +2966,13 @@ pub(crate) fn entry_publishes_pin_slots( // player shape while the ONE slot the announcement actually surfaces belongs to a // chained sub-ability targeting OBJECTS (measured: head `LoseLife` at // `TargetChoiceTiming::Resolution` contributing 0 slots + a chained - // `LoseLife{Typed{[Creature]}}` contributing 1, legal set three objects). A - // `TargetPin::Player` cannot specify such a choice, so publishing it would hand + // `LoseLife{Typed{[Creature]}}` contributing 1, legal set three objects). NO SEAT PIN + // can specify such a choice — neither spelling: not the CR 115.10a + // `TargetPin::Player`, and not the CR 601.2c + // `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(..))))` the announcement + // journal now emits, since both resolve to a `ConcreteTarget::Player` and the slot + // wants objects. The provenance split changes which authority judges a seat, not what + // a seat can denote, so this conjunct is untouched by it. Publishing anyway would hand // gate (3)'s `continue` a slot no pin can answer. // // `Err` (no legal target, CR 603.3d) also yields `None` — fail-closed, matching this @@ -2723,10 +3039,7 @@ pub(crate) fn entry_publishes_pin_slots( .as_ref() .is_none_or(|key| state.may_trigger_auto_choice(key).is_none()) }) - .map(|_| DecisionSlot { - source: source.clone(), - index: 1, - }); + .map(|_| DecisionSlot::may(source.clone())); let mut slots = super::ability_utils::build_target_slots(state, ability).ok()?; // SHAPE (B) — may-only. The announcement authority surfaced NO choice, so there is no // CR 601.2c target for a pin to specify and the entry publishes its CR 603.5 gate @@ -2734,16 +3047,14 @@ pub(crate) fn entry_publishes_pin_slots( // nothing" rather than "declared something the builder declined"; `optional` is // inherited from the `may` expression, which is `None` without it. A `may` the three // conjunct groups above suppressed leaves shape (B) with NO slot at all, so the whole - // entry publishes `None` — the fail-closed direction. + // entry publishes `None` — the fail-closed direction, now applied by the publication + // reader rather than restated here. Shape (B) also charges NOTHING under CR 704.5a: + // there is no announced target, so there is no seat a declaration could aim at. if slots.is_empty() { if !ability.targets.is_empty() { return None; } - return Some(EntryPinSlots { - target: None, - may: Some(may?), - legal_targets: vec![], - }); + return Some(EntryAnnouncement { target: None, may }); } if slots.len() != 1 { return None; @@ -2766,12 +3077,97 @@ pub(crate) fn entry_publishes_pin_slots( if !declares_opponent_player_target(ability) { return None; } - // Shape (A) — targeted. Index 1 is kept for the may slot in BOTH shapes, so slot - // identity is stable across them. - Some(EntryPinSlots { - target: Some(DecisionSlot { source, index: 0 }), + // SHAPE (A′) — NOT THE PROPOSER'S CHOICE, so there is no CHOICE OF THEIRS to publish. + // CR 732.2a describes a shortcut as "a sequence of game choices, for all players": a + // decision point stands for a game choice, and the proposer makes none of these three. + // CR 603.3d routes a trigger's announcement through CR 601.2c–d, and CR 601.2c has the + // player "announce their choice of an appropriate object or player for each target". + // + // WITHHOLD, never journal-an-auto-selection, and the difference is observable rather + // than stylistic: `triggers::prepare_trigger_targets` sends this exact predicate's + // `Ok(Some(..))` to `PreparedTriggerTargets::AutoAssigned`, so no + // `WaitingFor::TriggerTargetSelection` is ever raised, so `record_trigger_target_answer` + // — whose only two call sites are that prompt's reducer arms — never runs. A point + // published here would demand a `predictability_gate` answer no writer can produce, and + // one unanswerable point makes the WHOLE offer undeclarable (the gate's `required` set is + // every published point). Journalling the auto-selection instead would model a decision + // the player never made. + // + // THE SAME AUTHORITY AS THE RELIEF, exported rather than re-derived: gate (3)'s + // `stack_entry_has_no_ordering_input` asks `forced_unique_targeting` about this same + // fact, so withholding the point loses no relief — the entry passes gate (3) on the + // ordering-input arm instead of the pin arm. Evaluated on `state`, which for a window + // mint IS the pair's own carrying frame (`bounded_cycle_pin_slots_for_window` passes + // `frame`), the same board `build_target_slots` above enumerated the legal set from; that + // function's doc records why the live board would be fail-open here. + // + // Consistent with the sibling refusal one level up: a `ControllerRef::You` head is + // already refused as "a single forced seat, not a per-opponent choice". Forced-unique + // targeting is that same condition measured on the legal SET rather than on the filter. + // The `may` survives — a CR 603.5 take/decline is a real choice on the same source — and + // an entry with neither publishes nothing at all, exactly as shape (B) does. + // + // ⚠ `forced_unique_targeting` ANSWERS THE ASSIGNMENT-COUNT AXIS ONLY, and CR 601.2c has + // two more that decide the same question — WHO announces, and whether anybody does. The + // two cheap conjuncts run FIRST because each independently makes the count irrelevant: + // + // * CR 601.2c `target_chooser` ("of an opponent's choice", e.g. Volcanic Offering). The + // prompt is raised for the CHOOSER, and `record_trigger_target_answer` journals under + // the seat that answered it, while every consumer of a published point reads + // `loop_answer(slot, proposer)`. So the point is unanswerable at the proposer's key and + // one unanswerable point makes the WHOLE offer undeclarable — the same failure the + // forced arm above avoids. Note the count axis CANNOT see this: + // `auto_select_targets_for_ability` early-returns `Ok(None)` when ANY slot carries a + // chooser (`ability_utils.rs`, whose own comment names the `TargetSelectionMode::Random` + // guard as its mirror), so `forced_unique_targeting` is false here even when exactly ONE + // legal assignment exists. `slots.len() == 1` is already enforced above, so this reads + // the single announcement slot. The `!= proposer` half is not redundant with + // `collect_target_slots`' own `player != ability.controller` filter: it keys on the seat + // the CONSUMER reads, which is `entry.controller`, and those two coincide in production + // but are separate fields. Same shape as the sibling `may` mint's + // `.filter(|gate| gate.prompt_player == proposer)` — direction: strictly FEWER offers. + // * `TargetSelectionMode` other than `Chosen` — CR 115.1's "require their controller to + // choose" is overridden and the GAME selects. `triggers::prepare_trigger_targets` sends + // this to `random_select_targets_for_ability` and then to `AutoAssigned`, so no prompt + // is raised at all; worse than merely unanswerable, a pin here also RELIEVES gate (3), + // so the offer would be minted because of a designation the RNG contradicts at drive + // time. Written as `!is_chosen()` rather than `is_random()` deliberately: a future + // variant is withheld by DEFAULT, which is this function's documented fail-closed + // contract that the schema can only ever UNDER-publish. + // + // SCOPING HONESTY: this publication behaviour PREDATES the commit these types ship in. + // What is new is [`TargetAnnouncement`] claiming authority over "is announcing this a game + // choice the proposer makes" while reading one of the three axes. This is not a repair of + // a defect this commit introduced. + // + // ⚠ WITHHELD FROM THE SCHEMA IS NOT UNCHARGED, and the two used to be the same act. + // CR 704.5a asks which seat loses how much life, and a forced victim loses it exactly as + // a chosen one does — nobody having made the choice changes who pays, not how much. + // Reporting the shape here rather than dropping the announcement is what lets + // [`bounded_cycle_charged_targets_for_window`] charge it while + // [`entry_publishes_pin_slots`] still withholds it. Before, the shape was destroyed at + // this line and the CR 704.5a bound — derived from the surviving PUBLISHED points — read + // the withhold as "no victim", charging bare `observed_life_loss` instead of + // `observed_life_loss.max(0) + declared_life_magnitude`, so `max_iterations` GREW: the + // offer stated more legal repetitions than CR 732.2a permits. + let announcement = if slot.chooser.is_some_and(|chooser| chooser != proposer) + || !ability.target_selection_mode.is_chosen() + || crate::analysis::resource::forced_unique_targeting(state, ability) + { + TargetAnnouncement::NotProposerChoice + } else { + TargetAnnouncement::Chosen + }; + // Shape (A) / (A′) — targeted. Index 1 is kept for the may slot in BOTH shapes, so slot + // identity is stable across them. Both sub-indices come from `DecisionSlot`'s own + // constructors, which the CR 603.5 and CR 601.2c journal writers also use. + Some(EntryAnnouncement { + target: Some(AnnouncedTarget { + slot: DecisionSlot::target(source), + legal_targets: slot.legal_targets, + announcement, + }), may, - legal_targets: slot.legal_targets, }) } @@ -2815,8 +3211,11 @@ pub(crate) fn entry_publishes_pin_slots( /// with an unbindable slot), so the schema can only ever under-publish. /// /// Class served: every proposer-controlled triggered ability on the stack whose declared -/// target is a player — never a named card. Command-zone sources (CR 114.2 emblems) are -/// included; [`slot_source_prompted`] is the matching half at replay time. +/// target is a player — never a named card. Command-zone sources are included — every +/// command-zone-functioning ability source, not emblems alone (emblem CR 114.4; plane, scheme, +/// conspiracy CR 113.6p; a face-up phenomenon CR 901.7; an Eminence commander per its own +/// ability's declared zones, CR 113.6b) — and [`slot_source_prompted`] is the matching half at +/// replay time. /// /// PER SOURCE, NOT PER ENTRY: N stack entries from ONE source mint N byte-identical /// `DecisionSlot`s (real boards reach 35 entries on one source), and the sub-index @@ -2916,6 +3315,106 @@ pub(crate) fn bounded_cycle_pin_slots_for_window( points } +/// CR 704.5a: what ONE CERTIFIED PERIOD CHARGES — the announcement slot of every accepted +/// entry, paired with the seats that announcement may name, whether or not CR 732.2a +/// publishes it as a decision point. +/// +/// DELIBERATELY NOT A FILTER OVER [`bounded_cycle_pin_slots_for_window`]'s OUTPUT, and that +/// is the entire reason this exists as its own reader. Publication answers CR 732.2a — "a +/// sequence of game choices, for all players" — so a FORCED announcement publishes nothing. +/// Charging answers CR 704.5a — "if a player has 0 or less life, that player loses the +/// game" — and the victim loses that life whether or not anybody chose it. Deriving the +/// bound from the published set therefore let the CR 732.2a withhold silently drop a forced +/// victim into `ResourceVector::elimination_bounds`' cheaper `observed_life_loss` arm, +/// RAISING `max_iterations`: the offer would state more legal repetitions than CR 732.2a +/// permits, on the very operator whose job is to prove the proposed sequence "may be legally +/// taken based on the current game state". +/// +/// SAME ACCEPTANCE AUTHORITY as the publication mint — both read [`entry_announces`] — so +/// the charged SLOT set is a superset of the published `Targets` slots by construction, never +/// an independently-derived one that could name an entry the schema does not. +/// +/// ⚠ THE SUPERSET IS OVER SLOTS, NOT OVER EACH SLOT'S LEGAL SET, and conflating the two is +/// what the dedup below exists to prevent. The two mints read the same announcements but keep +/// DIFFERENT ONES of a repeated slot: publication skips a `NotProposerChoice` frame entirely, +/// charging does not. So a first-wins charge could retain a narrow frame's legal set for a +/// slot the schema publishes from a WIDER later frame — the schema would offer a pin the bound +/// never charged, and `max_iterations` would GROW. +/// +/// PER SOURCE, NOT PER ENTRY, for the reason [`bounded_cycle_pin_slots`] documents at +/// length: one state-independent designation specifies every instance of that source's +/// announcement, so its slot is charged ONCE however many entries carry it. On a repeat the +/// victim lists are UNIONED rather than first-wins. +/// +/// # Why the union is MONOTONE — it can only tighten the bound, never loosen it +/// +/// The union changes exactly one input to +/// [`crate::analysis::resource::ResourceVector::elimination_bounds`]: +/// `declarable_victims` (its caller's flat union over these victim lists) can only GAIN +/// members. It cannot change `slot_magnitude`, which is keyed by SLOT and whose value is the +/// slot-independent `worst_seat_life_loss` — the union adds no slot. And for the one seat `p` +/// a union adds, that function's per-seat life magnitude moves from `observed_life_loss` to +/// `observed_life_loss.max(0) + S`, where `S = declared_life_magnitude >= 0` by construction +/// (its initializer filters `*m > 0` and sums; the empty sum is `0`). For `observed >= 0` that +/// is `observed + S >= observed`; for `observed < 0` it is `S >= 0 > observed`. So the +/// magnitude never decreases, and `narrow` — `bound.min(headroom.max(0) / magnitude)` over a +/// non-negative numerator, fired only when `magnitude > 0` — is monotone non-increasing in its +/// divisor. Hence the bound can only SHRINK. That is this repo's fail-closed direction. +/// +/// # Reachability of the shape this closes: NARROW, AND NOT CLOSED +/// +/// Stated honestly in both directions, because neither the reviewer nor the orchestrator built +/// the window. Divergent legal sets for ONE slot across a window need the legal PLAYER set to +/// GROW between frames. ELIMINATION — the realistic mechanism, and the one every tracked dump +/// exhibits — narrows it MONOTONICALLY (CR 800.4 + CR 102.1), which puts the widest frame +/// FIRST and lands first-wins fail-CLOSED. The fail-open direction needs a seat's +/// untargetability to END mid-window: a corpus census measured 14 cards granting a player +/// untargetability mid-loop, all self-protective and predominantly "until end of turn", which +/// does not expire mid-turn — so the path additionally needs the granting permanent to LEAVE, +/// or a shorter duration. `a_repeated_slots_victim_lists_are_unioned_not_first_wins` builds +/// exactly that board (CR 702.11c player hexproof whose grantor leaves between frames). It is +/// NOT a claim that a full production trajectory reaches it, and it is NOT "unreachable". +pub(crate) fn bounded_cycle_charged_targets_for_window( + touch: &crate::analysis::resource::PeriodTouch<'_>, + proposer: PlayerId, +) -> Vec<( + crate::analysis::decision_template::DecisionSlot, + Vec, +)> { + use crate::analysis::decision_template::DecisionSlot; + let mut charged: Vec<(DecisionSlot, Vec)> = Vec::new(); + for (frame, entry) in &touch.announced { + let Some(target) = entry_announces(frame, entry, proposer).and_then(|a| a.target) else { + continue; + }; + // CR 115.2: an object target is not a seat any CR 704 loss threshold applies to, so + // only players are collected — the same projection the bound always applied to the + // published set, moved to the authority that owns the legal set. + let victims: Vec = target + .legal_targets + .iter() + .filter_map(|t| match t { + TargetRef::Player(p) => Some(*p), + _ => None, + }) + .collect(); + // UNION, NOT FIRST-WINS. `position` (not `iter_mut().find`) so the immutable probe's + // borrow ends before the `None` arm pushes. + match charged.iter().position(|(slot, _)| *slot == target.slot) { + Some(i) => { + let seats = &mut charged[i].1; + for victim in victims { + if !seats.contains(&victim) { + seats.push(victim); + } + } + } + None => charged.push((target.slot, victims)), + } + } + charged +} + /// CR 732.2a: assemble a loop-shortcut offer's READ-side schema from its already-reified /// decision `points`, its proposed repeat mode, and its CR 704 count bound. /// @@ -3272,6 +3771,8 @@ fn until_lethal_fallback( // sampler with no seat semantics, so it clears unconditionally; the period is evidence about // the seat that recorded it, so only the proposer's own is theirs to discard. state.loop_detect_ring.clear(); + // CR 603.5: the recorded "may" answers describe the window that just ended. + state.loop_answer_journal = None; if state.loop_period_controller() == Some(proposer) { state.last_loop_action_sequence.clear(); } @@ -3283,17 +3784,36 @@ fn until_lethal_fallback( } /// CR 732.2a: how many whole cycles one shortcut drive must aggregate before the measured -/// delta is complete. A `RoundRobin`/`Piecewise` target schedule rotates its OBJECT sources -/// over its length, so a full period is that length; every other pin (a `Constant` target, a +/// delta is complete. A `RoundRobin`/`Piecewise` target schedule rotates its STEPS over its +/// length, so a full period is that length; every other pin (a `Constant` target, a /// `Player` pin, a non-target pin, or no template at all) settles in ONE cycle. Returns the -/// max schedule length over the template's `Targets` pins, defaulting to 1. +/// max schedule length over the template's `Targets` pins, defaulting to 1. A step's subject +/// is a `Ranking`, which lives INSIDE the step and never changes the count — this seam is +/// type-only across that parameterization. /// -/// DORMANT for every Stage-2 crownable loop (Ruling B): `TargetSchedule` rotates DecisionSource -/// objects, not players, and `live_mandatory_loop_winner` crowns on PLAYER fallers — an -/// object-rotating loop produces no player faller, so it never crowns; the only crownable >2p -/// player drain pins ALL opponents every cycle (`TargetPin::Player` is constant, period 1). The -/// seam is built for generality; a multi-cycle aggregation is fail-safe (an object loop reaching -/// the arm measures 1 cycle, finds no faller, does not crown). +/// DORMANT for every Stage-2 crownable loop (Ruling B) — and the REASON has now been restated +/// TWICE, because each restatement was falsified by the next commit and the history is the +/// useful part. (i) It was "`TargetSchedule` rotates DecisionSource objects, not players"; +/// parameterizing a step's subject admitted `AnnouncementSubject::Seat` and killed that. +/// (ii) It was then "no in-tree producer emits a `Seat` into a schedule", which is FALSE as of +/// the provenance split: `record_trigger_target_answer` and +/// `game::interaction::materialize_loop_shortcut_response` both mint +/// `Scheduled(TargetSchedule::Constant(Ranking::one(AnnouncementSubject::Seat(..))))` for a +/// CR 601.2c announced seat. +/// +/// (iii) The property that actually holds, and the one this function's return value depends on, +/// is about ROTATION rather than about subjects: **no in-tree producer emits a multi-STEP +/// schedule** (`RoundRobin` / `Piecewise`) **or a multi-entry `Ranking`**. Every seat-carrying +/// schedule the engine mints is a one-step `Constant`, which lands on the `1` arm of the match +/// below — the same `1` a `TargetPin::Player` lands on, so the split moved the spelling and not +/// the period. `live_mandatory_loop_winner` crowns on PLAYER fallers, and a loop whose targets +/// do not rotate produces no NEW player faller per cycle to aggregate. The only crownable >2p +/// player drain pins ALL opponents every cycle (constant, period 1 — via the +/// `Scheduled(Constant(_))` arm below since the split, via `TargetPin::Player(_)` before it, +/// and those two arms return the same `1`). The seam is built for generality and a multi-cycle +/// aggregation is fail-safe either way (a loop reaching the arm measures 1 cycle, finds no +/// faller, does not crown), so a future ROTATING producer changes what must be re-argued here, +/// not what this function returns. /// /// CR 732.2a SAFETY LIMIT: the returned period is clamped to `MAX_SHORTCUT_CYCLES`. Both /// consumers derive their `0..period` range from this one helper (`validate_pins` and @@ -3324,8 +3844,9 @@ fn shortcut_drive_period( .unwrap_or(1) // CR 732.2a SAFETY LIMIT: the drive period is STRUCTURALLY unbounded in the engine — // its length is the client template schedule's own length. On the WS transport the - // 8 KB inbound-frame cap (phase-server/src/main.rs:409/1420) already bounds a hostile - // schedule to a few hundred entries (~1-2 s stall, not a million-cycle remote DoS), + // inbound-frame cap (`phase-server`'s `MAX_WS_MESSAGE_BYTES`, 64 KB, applied at its + // `ws.max_message_size`) already bounds a hostile schedule to a finite entry count + // (a bounded stall, not a million-cycle remote DoS), // but in-process callers (WASM/Tauri/local) bypass that cap, so clamp here AT THE // SOURCE for every caller. Real schedules rotate over a handful of object sources // (period ≪ cap), so this is invisible to every legitimate loop; a clamped-shorter @@ -3684,42 +4205,24 @@ fn inject_pinned_answer( } } -/// CR 608.2b + CR 114.2: does this SLOT's source identify the ability instance that raised -/// the prompt carrying `source_id`? +/// CR 608.2b + CR 114.4 + CR 113.6p: does this SLOT's source identify the ability instance +/// that raised the prompt carrying `source_id`? /// -/// [`crate::analysis::decision_template::resolve_source`] is deliberately BATTLEFIELD-ONLY, -/// and that filter IS the CR 608.2b (`docs/MagicCompRules.txt:2789`) legality re-check for -/// `ByIdentity` **target** pins — a pinned target that left the battlefield must stop -/// matching. It must not be widened. But a SLOT's source only identifies WHICH ability -/// instance prompts, and CR 114.2 (`:828`) puts a planeswalker EMBLEM — "both owned and -/// controlled by that player" — in the **command zone**, where it stays for the whole game -/// and raises its triggers from. So the command-zone disjunct lives HERE, at the caller, -/// scoped to object identity + the pinned CR 400.7 incarnation. +/// The zone reasoning — why a SLOT's source admits the command zone while a PIN's source is +/// battlefield-only, and why graveyard / exile / hand still fail closed — now lives on +/// [`crate::analysis::decision_template::resolve_ability_instance`], the single accessor for +/// "which live ability instance is this". This call site is the identity comparison against +/// the prompting object; a `None` there means the caller aborts to manual play. /// -/// Graveyard / exile / hand sources still fail ⇒ the caller aborts to manual play. +/// FOUR production seams ask through here: `inject_pinned_answer`'s `TriggerTargetSelection` +/// and `MayChoice` `find_map` guards, and the drive's `pinned_targets_for_source` / +/// `pinned_mana_color_for_source`. fn slot_source_prompted( state: &GameState, src: &crate::analysis::decision_template::DecisionSource, source_id: ObjectId, ) -> bool { - if crate::analysis::decision_template::resolve_source(src, state) == Some(source_id) { - return true; - } - // CR 114.2: the command-zone arm. `AllCopies` is card-identity matching and an emblem - // has no card, so only `ThisObject` participates. - let crate::types::game_state::YieldTarget::ThisObject { - source_id: pinned_id, - incarnation, - .. - } = src - else { - return false; - }; - *pinned_id == source_id - && state.objects.get(pinned_id).is_some_and(|o| { - o.zone == crate::types::zones::Zone::Command - && (incarnation.is_none() || *incarnation == Some(o.incarnation)) - }) + crate::analysis::decision_template::resolve_ability_instance(src, state) == Some(source_id) } /// PR-7 Phase 4b: CR 732.2a finite materialization of a confirmed `Fixed(N)` loop @@ -3947,8 +4450,39 @@ fn materialize_fixed_shortcut( // partial-cycle event leak). Ring-clear BEFORE handback so this same `apply()` does // not instantly re-emit a fresh offer for the same (now-interrupted) loop; a later // beat re-detects genuinely. + // + // CR 732.2a: "The ending point of this sequence must be a place where a player has + // priority, though it need not be the player proposing the shortcut." THIS BLOCK IS + // THAT ENDING POINT, and it is the ending point for BOTH entry paths above — `n` + // cycles done with no cross-lethal, and `break 'cycles`. + // + // What the boundary means for a declared `Ranking`: within one accepted drive only its + // HEAD is ever resolved (`evaluate_schedule`), so this seam is where the NEXT episode + // may legitimately re-evaluate the tail. The reasoning is not restated here — it lives + // on `analysis::decision_template::Ranking` ("CONSUMED AT AN EPISODE BOUNDARY, NEVER + // MID-DRIVE"), and a second copy is the drift the R1 doc sweep exists to prevent. + // + // PROBE-PINNED (probe arm `MUT_SEAM`): the window clear here is load-bearing, not a + // backstop. MEASURED — skipping it on the f4 accepted drive leaves `loop_detect_ring` + // non-empty (12) and the journal populated (3 answers), and this same `apply()` + // re-emits a `LoopShortcut` offer. + // PROBE-PINNED: the abort entry reaches this seam ASYMMETRICALLY. MEASURED + // `ring=16, answers=0` on `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle`: the + // ring is LIVE there, so the ring-clear stays load-bearing on this path, but the journal is + // ALREADY empty — the `loop_answer_journal = None` below is a ⚠ FORWARD TRIPWIRE on this + // entry path, not a co-equal half of the CR 603.5 claim. The DISCRIMINATING statement of the + // journal half is the f4 row + // `fantastic_four_bounded_loop::r3a_the_accepted_drive_ends_at_the_priority_point_with_the_window_cleared`. + // + // LABELLED INTERPRETATION, not a pinned claim: the `waiting_for` re-seat below is a + // NORMALIZATION whose load-bearing case no fixture in this repo exercises today. On + // all four fixtures measured reaching this seam the state is ALREADY + // `WaitingFor::Priority` on entry, and skipping the re-seat changes nothing observable + // (probe arm `MUT_PRIORITY`). *state = committed; state.loop_detect_ring.clear(); + // CR 603.5: the recorded "may" answers describe the window that just ended. + state.loop_answer_journal = None; priority::reset_priority(state); state.waiting_for = WaitingFor::Priority { player: living_priority_seat(state), @@ -4106,23 +4640,129 @@ pub(crate) fn object_decision_source( }) } +/// CR 608.2b + CR 601.2c (reached for a triggered ability via CR 603.3d) + CR 732.2a: +/// journal ONE seat's announced target choice for the current loop-detection window. THE +/// SINGLE WRITE AUTHORITY for the target axis — both `WaitingFor::TriggerTargetSelection` +/// reducer arms route through here, never inline, so the two cannot drift. +/// +/// FAIL-CLOSED ON A DEAD IDENTITY, and this DIVERGES DELIBERATELY from the proliferate +/// `record_loop_pin` site below, which `filter_map`s an unresolvable object away. There a +/// short pin vector still drives; here it would be journalled as a UNIFORM answer and then +/// fail `validate_pins` as an illegal pin value at declare time — a WRONG PIN rather than +/// no offer. `collect::>>()` makes any unresolvable member abandon the whole +/// write. +/// +/// FAIL-CLOSED ON A MULTI-SLOT ANNOUNCEMENT, and the key is why. `DecisionSlot::target` +/// hard-codes `index: 0` (its own doc: the sub-index disambiguates the two choices of ONE +/// ability instance — CR 601.2c target vs. CR 603.5 may — and nothing finer), so every slot of +/// a multi-slot announcement lands on ONE key. `LoopAnswerValue::Targets`' contract is "the +/// announced targets for one slot, in announcement order", so what gets stored is wrong in two +/// distinguishable ways: two slots taking DISTINCT targets latch `Conflicted` (fail-closed, +/// harmless), while two slots taking the SAME target — which CR 601.2c expressly permits, "if +/// the spell uses the word 'target' in multiple places, the same object or player can be chosen +/// once for each instance" — store a `Uniform` one-pin vector that LOOKS like a valid answer to +/// a two-choice announcement. Refusing the whole write is the only reading that cannot hand a +/// widened publisher a wrong pin. Deriving a real per-slot sub-index is the right long-term +/// answer and is deliberately NOT attempted here: `DecisionSlot`'s index namespace is shared +/// with the publisher and with `record_loop_pin`'s own numbering, so widening it is a design +/// change, not a guard. +/// +/// The slot count is read from the PROMPT IN HAND rather than passed by the caller. Both reducer +/// arms run BEFORE the handler replaces `waiting_for` (that is why the seat and source are only +/// readable there), so `state.waiting_for` here IS the `TriggerTargetSelection` the announcement +/// answers — the same value the arm matched on, since `apply_action`'s reducer matches a CLONE +/// and nothing writes the field in between. Reading it makes the guard un-driftable by +/// construction: a third arm cannot pass a stale or invented count, and a caller holding no +/// prompt at all has no announcement to journal and is refused. (A `debug_assert!` on the count +/// is deliberately NOT used: a multi-slot trigger announcement is legal and reachable in +/// production — `triggers.rs` measures a combat-damage trigger surfacing two target slots — so +/// asserting would panic a debug build on a correct game.) +/// +/// Gating is inherited, not restated: `record_loop_answer` carries the +/// `samples() && !in_simulation_probe()` gate, so this adds no second gate. +fn record_trigger_target_answer( + state: &mut GameState, + source_id: Option, + player: PlayerId, + targets: &[crate::types::ability::TargetRef], +) { + use crate::analysis::decision_template::{ + AnnouncementSubject, DecisionSlot, LoopAnswer, LoopAnswerValue, Ranking, TargetPin, + TargetSchedule, + }; + use crate::types::ability::TargetRef; + let announced_slots = match &state.waiting_for { + WaitingFor::TriggerTargetSelection { target_slots, .. } => target_slots.len(), + // No announcement in hand ⇒ nothing to journal. + _ => return, + }; + if announced_slots > 1 { + return; + } + let Some(source) = source_id.and_then(|id| object_decision_source(state, id)) else { + return; + }; + let Some(pins) = targets + .iter() + .map(|t| match t { + // CR 400.7: bind to the CURRENT incarnation, so a re-entered permanent stops + // matching instead of being falsely replayed. + TargetRef::Object(id) => object_decision_source(state, *id).map(TargetPin::ByIdentity), + // CR 601.2c: THIS PRODUCER IS TARGET CLASS, and the spelling says so. This + // writer is gated on `WaitingFor::TriggerTargetSelection`, i.e. on a CR 601.2c + // announcement ("the player announces … choices … including the targets"), so + // the seat it journals was TARGETED, not merely chosen. It therefore emits the + // announcement-subject spelling, whose resolver arm applies CR 702.11c hexproof / + // CR 702.18a shroud / CR 702.16b protection. A merely CHOSEN seat (CR 115.10a — + // e.g. the CR 701.34a proliferate arm) keeps `TargetPin::Player` and its + // existence-only authority; see that variant's own doc. Two questions, two + // spellings, and the spelling IS the provenance. + // + // CR 732.2a is still satisfied: a seat is state-independent by construction — it + // can never denote "the newest copy" — and a one-element `Ranking` under + // `Constant` is answered identically at every iteration index, so no iteration + // can turn the pin into a conditional action. + TargetRef::Player(pl) => Some(TargetPin::Scheduled(TargetSchedule::Constant( + Ranking::one(AnnouncementSubject::Seat(*pl)), + ))), + }) + .collect::>>() + else { + return; + }; + if pins.is_empty() { + // A declined / empty announcement is not an answer a pin can specify. + return; + } + state.record_loop_answer( + DecisionSlot::target(source), + player, + LoopAnswer::Uniform(LoopAnswerValue::Targets(pins)), + ); +} + /// FIX-1 (CR 608.2b): the concrete targets of the recorded `Targets` pin whose slot source /// re-binds LIVE to `source_id` this iteration (the beat's cost / trigger source, e.g. the Relic /// cost source for a tap-cost pin or the Kilo trigger source for a proliferate pin). Resolving the /// WHOLE `template` means ANY pin that no longer resolves to a live legal object (a target left /// its zone) aborts the whole beat fail-closed — a broken loop never certifies. `Err(RecastAbort)` /// if no `Targets` pin's source matches `source_id`. +/// +/// "Whose slot source re-binds to `source_id`" is asked through [`slot_source_prompted`], the one +/// spelling of that question — so the slot may name any ability instance the beat's prompt could +/// have come from (CR 608.2b keeps the pinned TARGETS battlefield-only through `resolve_source`; +/// the SLOT's source is a different question and admits the command zone). fn pinned_targets_for_source( template: &crate::analysis::decision_template::DecisionTemplate, iteration: crate::analysis::decision_template::IterationIndex, clone: &GameState, source_id: ObjectId, ) -> Result, RecastAbort> { - use crate::analysis::decision_template::{resolve, resolve_source, ConcreteDecision}; + use crate::analysis::decision_template::{resolve, ConcreteDecision}; let decisions = resolve(template, iteration, clone).map_err(|_| RecastAbort)?; for d in decisions { if let ConcreteDecision::Targets { slot, targets } = d { - if resolve_source(&slot.source, clone) == Some(source_id) { + if slot_source_prompted(clone, &slot.source, source_id) { return Ok(targets); } } @@ -4132,17 +4772,21 @@ fn pinned_targets_for_source( /// FIX-1 (CR 608.2d): the recorded mana color of the `ManaColor` pin whose slot source is /// `source_id` (the driving mana ability's source). `Err(RecastAbort)` if unpinned. +/// +/// The slot-source question is [`slot_source_prompted`]'s, exactly as in +/// [`pinned_targets_for_source`]: CR 608.2d is the choice this pin records, and which ability +/// instance offered that choice is what the predicate answers. fn pinned_mana_color_for_source( template: &crate::analysis::decision_template::DecisionTemplate, iteration: crate::analysis::decision_template::IterationIndex, clone: &GameState, source_id: ObjectId, ) -> Result { - use crate::analysis::decision_template::{resolve, resolve_source, ConcreteDecision}; + use crate::analysis::decision_template::{resolve, ConcreteDecision}; let decisions = resolve(template, iteration, clone).map_err(|_| RecastAbort)?; for d in decisions { if let ConcreteDecision::ManaColor { slot, color } = d { - if resolve_source(&slot.source, clone) == Some(source_id) { + if slot_source_prompted(clone, &slot.source, source_id) { return Ok(color); } } @@ -5128,6 +5772,8 @@ fn materialize_object_growth_shortcut( } } state.loop_detect_ring.clear(); + // CR 603.5: the recorded "may" answers describe the window that just ended. + state.loop_answer_journal = None; state.last_loop_action_sequence.clear(); priority::reset_priority(state); state.waiting_for = WaitingFor::Priority { @@ -5230,13 +5876,19 @@ struct LoopShortcutOffer<'a> { predicted_winner: Option, certificate: &'a crate::analysis::loop_check::LoopCertificate, schema: &'a crate::analysis::decision_template::ShortcutDecisionSchema, + /// The engine's OWN published declaration for this offer, borrowed. Cloned only on the + /// fallback path in `handle_declare_shortcut`, where a `template: None` declaration + /// resolves against it. + declaration: Option<&'a crate::analysis::decision_template::DecisionTemplate>, } -/// CR 732.2a (MagicCompRules.txt:6372) + CR 800.4a (MagicCompRules.txt:6408): reject a +/// CR 732.2a + CR 800.4a: reject a /// shortcut declaration and hand priority back to the next living seat — the manual-play /// handback every reject path in `handle_declare_shortcut` lands on. Single -/// authority: a sixth reject path added later cannot forget to sync -/// `result.waiting_for`. +/// authority: a SEVENTH reject path added later cannot forget to sync +/// `result.waiting_for` — six exist today (the sixth is the `template.owner` firewall). +/// Cited by CR number, not by `MagicCompRules.txt` line: that file is gitignored and +/// re-fetched, so its line coordinates rot on every rules release. fn reject_shortcut_declaration(state: &mut GameState, result: &mut ActionResult) { priority::reset_priority(state); state.waiting_for = WaitingFor::Priority { @@ -5292,7 +5944,8 @@ fn handle_declare_shortcut( // the single authority — BEFORE the proposal is built — into the same fail-closed // manual-play handback the pin validation above uses. This is THE catastrophic remote // vector: `Fixed(u32)` scalar-encodes up to ~4.3e9 cycles in ~10 bytes, sailing through - // the 8 KB WS frame cap → one GameState clone + drive per cycle. Both confirmation paths + // the WS frame cap (`phase-server`'s `MAX_WS_MESSAGE_BYTES`, 64 KB) → one GameState clone + + // drive per cycle. Both confirmation paths // (solitaire-immediate below, APNAP Accept) consume this one proposal, and both drive // helpers (materialize_fixed_shortcut / materialize_object_growth_shortcut) read `n` from // it, so this one check bounds every Fixed drive on every transport. The drive helpers do @@ -5332,6 +5985,41 @@ fn handle_declare_shortcut( crate::analysis::decision_template::IterationCount::Fixed(_) | crate::analysis::decision_template::IterationCount::UntilLethal => {} } + // A `template: None` declaration is not "no pins" — it is "no OVERRIDE of the pins this + // offer already published". Resolve it against the offer's own engine-issued declaration so + // the manual ingress and `ai_support::candidates` (which reads the identical field) declare + // the SAME proposal against the SAME offer. Until this line existed the engine published a + // declaration on the offer and then discarded it here, so the AI — which sends + // `Some(declaration)` — was accepted while the browser, which sends `None`, was refused on + // one and the same offer. No rules citation is minted here: the block immediately below + // carries this handler's, and `docs/MagicCompRules.txt` is absent from this tree, so an + // unverified restatement would be worse than none. + // + // PLACEMENT IS LOAD-BEARING, AND MEASURED: this sits ABOVE the `template.owner` firewall + // below. `declaration_conforms` is `predictability_gate && validate_pins` and reads no + // `owner` at all — measured: a template differing from a conforming one ONLY in `owner` + // still conforms. That firewall is therefore the SOLE refuser of a foreign-owner + // declaration, and moving this statement one line down would hand the firewall a `None` + // (which passes) and then hand the `Some(t)` arm a foreign-owner template it accepts. + // Pinned by `r3_placement_a_restored_foreign_owner_declaration_is_refused`. + // + // WHAT THIS DOES TO THE `None if …loop_period_controller() != Some(proposer)` ARM BELOW, + // stated because it reads like a loosening and is not: that arm is BYPASSED whenever the + // offer published a declaration, because `&template` then takes the `Some(t)` arm instead. + // That is intended. The arm exists so a PINLESS drive never runs — its own doc says "with + // nothing this proposer can re-derive from, a pin-consuming drive would run with no pins at + // all" — and a resolved declaration supplies exactly those pins. The substitute gate is + // `declaration_conforms`, which is strictly STRONGER for this case: the arm asserts only + // that a re-derivation SOURCE exists, while `declaration_conforms` validates the actual + // pins against the actual schema over the range the accepted count will drive. + // + // THE ARM IS NOT DEAD AFTERWARDS — do not "simplify" it away. It still decides every offer + // that published no declaration, and that set is non-empty by construction: + // `build_bounded_declaration` returns `None` on a journal miss or on a kind/value mismatch + // even with a non-empty schema, both non-bounded mints hard-code `declaration: None`, and a + // restored save may carry `None`. Pinned by + // `a_template_free_declaration_is_admitted_only_by_the_proposers_own_period`. + let template = template.or_else(|| offer.declaration.cloned()); // CR 732.2a + CR 603.5: the declared template's `owner` is CLIENT-SUPPLIED — the // `GameAction::DeclareShortcut { template }` payload arrives here verbatim — and it is // the comparand `inject_pinned_answer` uses to decide WHOSE CR 603.5 choice a pin may @@ -5355,8 +6043,6 @@ fn handle_declare_shortcut( if !offer.schema.points.is_empty() { match &template { Some(t) => { - let required: Vec = - offer.schema.points.iter().map(|p| p.slot.clone()).collect(); // CR 732.2a: validate over the range the ACCEPTED COUNT will drive, not // over the schedule's own period. `shortcut_drive_period` answers a // different question (how many cycles one measurement must aggregate), and @@ -5364,15 +6050,15 @@ fn handle_declare_shortcut( // set at an index the count reaches, and REFUSED conforming declarations // whose count is shorter than the schedule. let validated_range = shortcut_validated_range(&count, Some(t)); - if crate::analysis::decision_template::predictability_gate(t, &required).is_err() - || crate::analysis::decision_template::validate_pins( - offer.schema, - t, - validated_range, - state, - ) - .is_err() - { + // Coverage + value legality via the shared authority, so the predicate this + // handler ACCEPTS under is the same one `build_bounded_declaration` PUBLISHES + // under and the human ingress EMITS under. The range is this site's own. + if !crate::analysis::decision_template::declaration_conforms( + offer.schema, + t, + validated_range, + state, + ) { reject_shortcut_declaration(state, &mut result); return Ok(result); } @@ -5447,9 +6133,13 @@ fn handle_declare_shortcut( /// Re-offer suppression, by seam: /// - Interactive bridge (Seam 1, `find_live_loop_winner` reads `loop_detect_ring`, gated by /// `!stack.is_empty()`): suppressed by the GENERAL deliberate-action invariant, not by this -/// handler. `apply_action` (engine.rs:3006-3011) invalidates `loop_detect_ring` for every -/// deliberate (non-`PassPriority`/`OrderTriggers`) action; `DeclineShortcut` is a deliberate -/// break, so the ring is already empty before this handler runs. Seam-1 suppression is the +/// handler. `apply_action`'s deliberate-action ring clear invalidates `loop_detect_ring` for +/// every action that is neither `PassPriority`/`OrderTriggers` nor the answer to a +/// `WaitingFor::is_forced_cascade_window` window; `LoopShortcut` is in neither exemption (it +/// is not a member of that window class — see the `forced_cascade_window_class` test), so +/// `DeclineShortcut` is a deliberate break and the ring is already empty before this handler +/// runs. Cited by SYMBOL, not by line: this reference named a hard coordinate that rotted +/// twice, so a fresh number would only schedule a third rot. Seam-1 suppression is the /// shared invariant every cast/activate/play-land relies on — the handler does NOT re-clear /// the ring (re-clearing would special-case `DeclineShortcut` to distrust an engine-wide /// invariant). The interactive e2e's "no re-offer" assertion guards this end-to-end: a future @@ -5486,8 +6176,8 @@ fn handle_decline_shortcut( waiting_for: state.waiting_for.clone(), log_entries: vec![], }; - // Seam 1 (loop_detect_ring) is already invalidated by apply_action's deliberate-action - // ring-clear (engine.rs:3006-3011) — see doc. Only Seam 2 is the handler's gap, and only + // Seam 1 (loop_detect_ring) is already invalidated by `apply_action`'s deliberate-action + // ring clear — see doc. Only Seam 2 is the handler's gap, and only // for the decliner's OWN period (CR 732.2a): if state.loop_period_controller() == Some(proposer) { state.last_loop_action_sequence.clear(); @@ -6345,6 +7035,8 @@ fn pass_priority_once_with_pipeline( state.record_loop_detect_sample(); } else if !wf.is_forced_cascade_window() { state.loop_detect_ring.clear(); + // CR 603.5: the recorded "may" answers describe the window that just ended. + state.loop_answer_journal = None; } // CR 603.3b/603.3d/603.5/608.2/903.9a + CR 703.1/117.3a + CR 732.2a: leave the // ring intact on every FORCED PRE-PRIORITY window, not just trigger ordering. @@ -6447,7 +7139,7 @@ fn finish_completed_or_interrupted_until_stack_empty_sessions(state: &mut GameSt // against an absurd/hostile count — NOT a rules constraint. It bounds both a `Fixed(n)` // cycle count (handle_declare_shortcut) and a template drive period (shortcut_drive_period). // Motivating vector: a `u32` count scalar-encodes up to ~4.3e9 cycles in ~10 JSON bytes, so -// it sails through the 8 KB inbound WS frame cap (phase-server/src/main.rs:409/1420) yet +// it sails through the inbound WS frame cap (`phase-server`'s `MAX_WS_MESSAGE_BYTES`, 64 KB) yet // would force ~4.3e9 GameState clones — a byte cap cannot see it, only this count cap can. // 1_000 is generous vs any honest Fixed count (~10x KCI-style loops); worst-case bounded // cost is 1_000 cycles x <=10_000 beats = 1e7. @@ -7173,6 +7865,8 @@ fn apply_action( ) && !answering_forced_window { state.loop_detect_ring.clear(); + // CR 603.5: the recorded "may" answers describe the window that just ended. + state.loop_answer_journal = None; } // Keep the semantic owner of the prompt before reducing it. Under turn @@ -8648,7 +9342,36 @@ fn apply_action( GameAction::CancelCast, ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, // CR 608.2d: Player decided whether to perform an optional effect ("You may X"). - (WaitingFor::OptionalEffectChoice { .. }, GameAction::DecideOptionalEffect { accept }) => { + ( + WaitingFor::OptionalEffectChoice { + player, source_id, .. + }, + GameAction::DecideOptionalEffect { accept }, + ) => { + // CR 603.5 + CR 732.2a: journal the answer BEFORE the handler runs — it + // replaces `waiting_for`, so the prompt's own seat and source are only + // readable here. The key comes from `object_decision_source`, the same + // producer `entry_publishes_pin_slots` uses, so publish-side and record-side + // keys agree by construction rather than by coincidence. `record_loop_answer` + // carries the `samples() && !in_simulation_probe()` gate. + let (answering_player, may_source) = (*player, *source_id); + if let Some(source) = object_decision_source(state, may_source) { + use crate::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, MayChoiceOption, + }; + // CR 603.5 rides sub-index 1, via `DecisionSlot::may` — the SAME + // constructor `entry_publishes_pin_slots` publishes the gate with, so the + // sub-index is a literal on neither side of the journal. + state.record_loop_answer( + DecisionSlot::may(source), + answering_player, + LoopAnswer::Uniform(LoopAnswerValue::May(if accept { + MayChoiceOption::Take + } else { + MayChoiceOption::Decline + })), + ); + } engine_payment_choices::handle_optional_effect_choice(state, accept, &mut events)? } ( @@ -8710,6 +9433,13 @@ fn apply_action( predicted_winner, certificate, schema, + // Threaded: `handle_declare_shortcut` resolves a `template: None` declaration + // against the offer's own published declaration, so the manual ingress and + // `ai_support::candidates` (which reads this identical field) declare the SAME + // proposal against the SAME offer. The hostile-fixture obligations this bind + // used to defer — foreign period, restore ingress — are discharged by the rows + // named on that handler's `or_else`. + declaration, }, GameAction::DeclareShortcut { count, template }, ) => { @@ -8720,6 +9450,7 @@ fn apply_action( predicted_winner: *predicted_winner, certificate, schema, + declaration: declaration.as_ref(), }, count, template, @@ -10628,20 +11359,40 @@ fn apply_action( ( WaitingFor::TriggerTargetSelection { player, + source_id, target_slots, target_constraints, .. }, GameAction::SelectTargets { targets }, - ) => engine_stack::handle_trigger_target_selection_select_targets( - state, - *player, - target_slots, - target_constraints, - targets, - &mut events, - )?, - (WaitingFor::TriggerTargetSelection { .. }, GameAction::ChooseTarget { target }) => { + ) => { + // CR 608.2b + CR 732.2a: journal the announcement BEFORE the handler runs — it + // replaces `waiting_for`, so the prompt's own seat and source are only readable + // here, and the key reads the source object's CR 400.7 incarnation, which + // resolution can invalidate. `apply_action_boundary_core` snapshots the whole + // state and restores it on every `Err` return, so a write made before a handler + // that then errors is rolled back with everything else. + record_trigger_target_answer(state, *source_id, *player, targets.as_slice()); + engine_stack::handle_trigger_target_selection_select_targets( + state, + *player, + target_slots, + target_constraints, + targets, + &mut events, + )? + } + ( + WaitingFor::TriggerTargetSelection { + player, source_id, .. + }, + GameAction::ChooseTarget { target }, + ) => { + // Same write authority and same before-the-handler reason as the `SelectTargets` + // arm above. `target: None` yields an empty slice, which the helper's + // `pins.is_empty()` guard refuses — the fail-closed reading of a no-target + // announcement. + record_trigger_target_answer(state, *source_id, *player, target.as_slice()); let waiting_for = state.waiting_for.clone(); engine_stack::handle_trigger_target_selection_choose_target( state, @@ -11542,14 +12293,14 @@ fn apply_action( // // BLAST RADIUS. Nothing this leaves in `state` survives to a consumer unrecomputed: // `finish_action_boundary` runs the SAME `sync_waiting_for` over `result.waiting_for` - // (`:1171`) and copies the outcome back into the result (`:1189`), and the reorder + // and copies the outcome back into the result, and the reorder // never changes `ActionResult.waiting_for` itself. That is an argument about // RE-DERIVATION, not reachability, because `apply_action_boundary` is not the only // route: `inject_pinned_answer`'s three dispatches and `drive_loop_action_iteration`'s // ten reach `apply_action` directly, and // `apply_interaction_pre_reconciliation_for_life_safety` returns `raw.result` without - // ever calling `finish_action_boundary` (`apply_action_boundary_core`'s own comment at - // `:1119` records it). All three drive a CLONE — `drive_one_shortcut_cycle`'s `work`, + // ever calling `finish_action_boundary` (`apply_action_boundary_core`'s own comment + // records it). All three drive a CLONE — `drive_one_shortcut_cycle`'s `work`, // the drive's `clone`, `preview_candidate_life_safety`'s `preview` — never the settled // board. MEASURED pre-reorder by an instrumented `debug_assert_eq!` census over the // full lib + integration corpus (per-site counts in PR #7005's history; one unit = @@ -14700,72 +15451,513 @@ mod shortcut_schema_tests { } } -/// PR-7 Combo-UI Stage 2: the mid-drive pin injector (item 4) + the drive-period seam (item 6). +/// item-4 C2b — `build_bounded_declaration`, the consumer that turns the window's observed +/// answers into the offer's own CR 732.2a declaration. +/// +/// TIER NOTE, stated because it is FORCED rather than chosen: rows D1 / D1-P / D1-P-sib drive +/// the real F4 dump through production `apply()` and live in +/// `crates/engine/tests/integration/fantastic_four_bounded_loop.rs`. The three rows HERE are the +/// ones no tracked board can reach — a `Decline`d CR 603.5 answer at a certifying offer, and a +/// point kind the bounded publisher cannot mint — so each states its own unreachability rather +/// than implying a wire row was available and skipped. #[cfg(test)] -mod stage2_injector_tests { - use super::*; +mod bounded_declaration_tests { + use super::{build_bounded_declaration, build_shortcut_schema}; use crate::analysis::decision_template::{ - DecisionGroupKey, DecisionKind, DecisionSlot, DecisionTemplate, IterationCount, - PinnedDecision, ReplayMode, TargetPin, TargetSchedule, + DecisionPoint, DecisionPointKind, DecisionSlot, IterationCount, LoopAnswer, + LoopAnswerValue, MayChoiceOption, PinnedDecision, ShortcutDecisionSchema, TargetPin, }; - use crate::game::scenario::GameScenario; - use crate::types::game_state::{LoopDetectionMode, YieldTarget}; - - const P0: PlayerId = PlayerId(0); - const P1: PlayerId = PlayerId(1); - const P2: PlayerId = PlayerId(2); - const TARGET_DRAIN: &str = "Whenever you gain life, target opponent loses that much life."; - const FEEDBACK: &str = "Whenever an opponent loses life, you gain that much life."; - const KICKOFF: &str = "You gain 1 life."; + use crate::types::ability::TargetRef; + use crate::types::game_state::{GameState, LoopDetectionMode, YieldTarget}; + use crate::types::identifiers::ObjectId; + use crate::types::mana::ManaColor; + use crate::types::player::PlayerId; - fn life(state: &GameState, p: PlayerId) -> i32 { - state.players.iter().find(|pl| pl.id == p).unwrap().life - } + const PROPOSER: PlayerId = PlayerId(0); + const AIMED: PlayerId = PlayerId(1); - fn this_object(id: ObjectId) -> YieldTarget { + /// A CR 400.7-stable source identity, built the way `object_decision_source` builds one. + fn source(id: u64) -> YieldTarget { YieldTarget::ThisObject { - source_id: id, - incarnation: None, + source_id: ObjectId(id), + incarnation: Some(1), trigger_description: None, } } - /// A template routing two distinct drainers to two distinct opponents by source identity. - fn two_drainer_template( - d0: ObjectId, - opp0: PlayerId, - d1: ObjectId, - opp1: PlayerId, - ) -> DecisionTemplate { - let s0 = this_object(d0); - let s1 = this_object(d1); - DecisionTemplate { - owner: P0, - decisions: vec![ - PinnedDecision::Targets { - slot: DecisionSlot { - source: s0.clone(), - index: 0, - }, - targets: vec![TargetPin::Player(opp0)], - }, - PinnedDecision::Targets { - slot: DecisionSlot { - source: s1.clone(), - index: 0, - }, - targets: vec![TargetPin::Player(opp1)], - }, - ], - replay: ReplayMode::Scheduled { - count: IterationCount::UntilLethal, - }, - key: DecisionGroupKey::from_sources(&[s0, s1], DecisionKind::LoopChoice), + /// A board whose journal ACCEPTS writes: `record_loop_answer` is gated on + /// `loop_detection.samples()`, so a default board would silently record nothing and every + /// row below would measure the "seat never answered" path instead of its own subject. + fn recording_state() -> GameState { + let mut state = GameState::new_two_player(7); + state.loop_detection = LoopDetectionMode::Interactive; + state + } + + fn targets_kind() -> DecisionPointKind { + DecisionPointKind::Targets { + legal_targets: vec![TargetRef::Player(AIMED)], + min_targets: 1, + max_targets: 1, + ordered: false, } } - /// Test F ⭐ (item 4 — per-source target routing, the two-authority claim): a 3p loop with - /// TWO targeted drainers raises a `TriggerTargetSelection` per drainer (two legal opponents + /// The two-kind schema the bounded publisher actually mints: one CR 603.5 `may` gate and one + /// CR 601.2c target slot, on two distinct sources. + fn may_and_target_schema() -> ShortcutDecisionSchema { + build_shortcut_schema( + vec![ + DecisionPoint { + slot: DecisionSlot::may(source(100)), + kind: DecisionPointKind::MayChoice, + }, + DecisionPoint { + slot: DecisionSlot::target(source(200)), + kind: targets_kind(), + }, + ], + IterationCount::Fixed(4), + 4, + ) + } + + /// **Row D1-P-may — the `MayChoice` pin FOLLOWS THE JOURNAL, not a constant.** + /// + /// CR 603.5: an optional trigger's answer is `Take` or `Decline`, and the declaration must + /// state the one the proposer actually gave. A consumer that hard-codes + /// `MayChoiceOption::Take` is indistinguishable from this one on every tracked board, which + /// is exactly the vacuity this row closes. + /// + /// # Why this tier is FORCED, and not a shortcut + /// + /// A wire-tier may-provenance drive is measured UNREACHABLE: answering every CR 603.5 prompt + /// `Decline` on the tracked F4 board reaches NO offer at all (declining Sue's token breaks + /// the chain to Reed, so the loop never certifies — the drive policy's own doc records it). + /// The residual is filed rather than hidden: it needs a bounded board on which the proposer + /// DECLINES and the loop still certifies, and the lane's real-fixtures rule bars + /// synthesizing one. + /// + /// # Non-vacuity + /// + /// The `Take` case is asserted in the SAME test from the SAME fixture one field apart, so a + /// consumer that returned `None` — or that dropped the may pin entirely — fails the positive + /// arm rather than passing the negative one by omission. + /// + /// REVERT-PROBE: hard-code `take: MayChoiceOption::Take` in the `(MayChoice, May)` arm ⇒ the + /// `Decline` arm's assertion flips (`Take != Decline`) while the `Take` arm stays green. + /// That asymmetry is the row. + /// + /// *What wrong implementation would still pass this row?* One that reads the journal for the + /// may axis but pins a CONSTANT target — D1-P and D1-P-sib cover that axis on the real dump. + #[test] + fn d1p_may_the_may_pin_follows_the_journal_not_a_constant() { + let schema = may_and_target_schema(); + let [may_point, target_point] = &schema.points[..] else { + panic!("the fixture publishes exactly two points"); + }; + + for answered in [MayChoiceOption::Decline, MayChoiceOption::Take] { + let mut state = recording_state(); + state.record_loop_answer( + may_point.slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(answered)), + ); + state.record_loop_answer( + target_point.slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player(AIMED)])), + ); + + // Reach-guard: the journal really holds the answer, under the PROPOSER's own key. + // Without this a gated-off `record_loop_answer` would make every arm below measure + // the "never answered" refusal instead. + assert_eq!( + state.loop_answer(&may_point.slot, PROPOSER), + Some(LoopAnswer::Uniform(LoopAnswerValue::May(answered))), + "reach-guard: the CR 603.5 answer must be journalled before the consumer runs" + ); + + let declaration = build_bounded_declaration(&state, PROPOSER, &schema) + .expect("both published points are answered, so the declaration is complete"); + assert_eq!( + declaration.decisions[0], + PinnedDecision::MayChoice { + slot: may_point.slot.clone(), + take: answered, + }, + "CR 603.5: the pinned option must be the one the proposer ANSWERED ({answered:?}), \ + not a constant" + ); + assert_eq!( + declaration.owner, PROPOSER, + "the declaration is the proposer's own, which is what the declare-time owner \ + firewall compares against" + ); + } + } + + /// **Row D3 — the consumer is TOTAL and FAIL-CLOSED over the four `DecisionPointKind`s the + /// bounded producer cannot mint.** + /// + /// CR 700.2 (`Mode`), CR 732.6 (`UnlessBreak`), CR 601.2h + CR 702.51a (`ConvokeTaps`) and + /// CR 608.2d + CR 605.3b (`ManaColor`) are real choice kinds with no observation-side answer + /// shape in `LoopAnswerValue`, so there is nothing to pin for them and the declaration must + /// refuse rather than guess. + /// + /// # ⚠ THE JOURNAL ENTRY ON THE FOUR-KIND POINT IS LOAD-BEARING, NOT DECORATION + /// + /// `build_bounded_declaration`'s body order is (1) empty check, (2) `state.loop_answer(..)?`, + /// (3) the `(kind, value)` match. An UNJOURNALLED four-kind point exits at step (2)'s `?` — + /// before control ever reaches the arm this row is about — and the reddening mutation returns + /// `None` there too, so real and mutant AGREE and nothing can red. Each case therefore + /// journals its own point and ASSERTS the entry is present before the consumer runs. + /// + /// # Unreachable today, and the row says so + /// + /// `bounded_cycle_pin_slots_for_window` constructs only `Targets` and `MayChoice` points. The + /// other four have one producer, `pinned_decisions_to_points`, which serves the two mints that + /// publish `declaration: None`. The row exists so a publisher relaxation gets a red test + /// instead of a silent pin. + /// + /// REVERT-PROBE: replace the four-kind arm with `_ => continue` ⇒ each case builds a + /// `Some(template)` with the four-kind point silently dropped ⇒ every `is_none()` flips while + /// the control stays green. + /// + /// *What wrong implementation would still pass this row?* One that returns `None` for + /// EVERYTHING — which the control arm (the same fixture with only mintable kinds) refuses. + #[test] + fn d3_the_consumer_fail_closes_on_every_kind_the_bounded_publisher_cannot_mint() { + let unmintable = [ + DecisionPointKind::Mode { + available_modes: vec![0, 1], + min_modes: 1, + max_modes: 1, + allow_repeats: false, + }, + DecisionPointKind::UnlessBreak, + DecisionPointKind::ConvokeTaps { + tappable: vec![ObjectId(31)], + }, + DecisionPointKind::ManaColor { + color: ManaColor::Blue, + }, + ]; + + // ── CONTROL, first: the same shape with only MINTABLE kinds yields `Some` ── + let control_schema = may_and_target_schema(); + let mut control = recording_state(); + control.record_loop_answer( + control_schema.points[0].slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + control.record_loop_answer( + control_schema.points[1].slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player(AIMED)])), + ); + assert!( + build_bounded_declaration(&control, PROPOSER, &control_schema).is_some(), + "CONTROL: a fully-answered two-kind schema DOES publish a declaration — without this \ + a consumer that refused everything would pass all four cases below" + ); + + for kind in unmintable { + let odd_slot = DecisionSlot::target(source(300)); + let schema = build_shortcut_schema( + vec![ + DecisionPoint { + slot: DecisionSlot::may(source(100)), + kind: DecisionPointKind::MayChoice, + }, + DecisionPoint { + slot: odd_slot.clone(), + kind: kind.clone(), + }, + ], + IterationCount::Fixed(4), + 4, + ); + let mut state = recording_state(); + // The OTHER point is answered, so the refusal cannot be attributed to it. + state.record_loop_answer( + schema.points[0].slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + // `LoopAnswerValue` has exactly two variants and `May` pairs with NONE of the four + // kinds under test, so this entry is answerable and still unpinnable — which is the + // fail-closed disposition the row measures. + state.record_loop_answer( + odd_slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + assert_eq!( + state.loop_answer(&odd_slot, PROPOSER), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "⚠ VACUITY GUARD for kind {kind:?}: an UNJOURNALLED point exits at the \ + `loop_answer(..)?` one step EARLIER, where the reddening mutation also returns \ + `None` — real and mutant would agree and this row could never fire" + ); + + assert!( + build_bounded_declaration(&state, PROPOSER, &schema).is_none(), + "CR 732.2a: {kind:?} has no `LoopAnswerValue` shape, so the declaration must \ + refuse rather than pin a guess or silently drop the point" + ); + } + } + + /// **Row D4 — an empty schema publishes NO declaration.** + /// + /// LOAD-BEARING, not tidiness: `predictability_gate` and `validate_pins` both live inside + /// `handle_declare_shortcut`'s `if !offer.schema.points.is_empty()` block, so a declaration + /// minted against an empty schema would travel the one declare path that runs NEITHER gate. + /// The invariant is also staged at fixture level by + /// `tests/integration/loop_shortcut.rs::r28_empty_schema_offer`, which passes + /// `declaration: None` for this reason. + /// + /// REVERT-PROBE: delete the `schema.points.is_empty()` early return ⇒ the loop body never + /// runs, step (4) builds a template with ZERO decisions ⇒ `is_none()` flips. + /// + /// *What wrong implementation would still pass this row?* One that also refuses a + /// fully-answered NON-empty schema — which D1-P-may's positive arm and D3's control refuse. + #[test] + fn d4_an_empty_schema_publishes_no_declaration() { + let empty = build_shortcut_schema(Vec::new(), IterationCount::Fixed(4), 4); + assert!( + empty.points.is_empty(), + "reach-guard: this fixture is the empty-schema case" + ); + let mut state = recording_state(); + // Journalled anyway: the refusal must be keyed on the EMPTY POINT SET, not on an empty + // journal, and a populated journal is the only way to tell those two apart. + state.record_loop_answer( + DecisionSlot::may(source(100)), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + assert!( + state.loop_answers_recorded() > 0, + "reach-guard: the journal is NON-empty, so the refusal below is the point set's" + ); + assert!( + build_bounded_declaration(&state, PROPOSER, &empty).is_none(), + "CR 732.2a: an offer that publishes no choice states no declaration" + ); + } + + /// **Row D8 — the PUBLISHER cannot publish what the HANDLER would refuse.** + /// + /// `ai_support::candidates` reads `declaration.is_some()` as *"`handle_declare_shortcut` + /// will accept this"* and hands the published template straight to `DeclareShortcut`. That + /// reading was unenforced: the publisher ran neither firewall half, and the two sides agreed + /// only because the publisher copies `legal_targets` from the same announcement the journal + /// answer came from. This row pins the implication itself. + /// + /// # The two halves are measured on DIFFERENT instruments, so this is not circular + /// + /// The "handler refuses it" half is measured by calling `validate_pins` DIRECTLY on the + /// template the pre-fix publisher would have emitted — the handler's own value-legality + /// firewall, at the range that handler validates a `Fixed(max_iterations)` declaration over. + /// Only then is the publisher asked. A row that asserted `is_none()` alone would pass on a + /// publisher that refuses for any unrelated reason. + /// + /// # Reach-guards, asserted BEFORE the claim + /// + /// The journal really holds the hostile answer (else the publisher exits one step earlier at + /// `loop_answer(..)?` and the refusal is not this one), and `predictability_gate` PASSES on + /// that template (both published slots are pinned) — so the refusal is attributable to the + /// VALUE half, not to coverage. + /// + /// # LATENT, not live — the row says so rather than implying a bug was shipped + /// + /// No tracked board reaches this: `record_trigger_target_answer` journals the seat it + /// ANNOUNCED, and the publisher's `legal_targets` come from that same announcement, so the + /// disagreement staged here is fixture-made. Reachability is NOT claimed. + /// + /// REVERT-PROBE: delete step (5)'s `declaration_conforms(..)` call (return `Some(template)`) + /// ⇒ the hostile arm's `is_none()` flips while the control stays green. + /// + /// *What wrong implementation would still pass this row?* One that validates COVERAGE only — + /// `predictability_gate` alone passes here, which is why the reach-guard asserts it. And one + /// that refuses everything, which the control arm refuses. + #[test] + fn d8_the_publisher_refuses_a_declaration_the_declare_handler_would_reject() { + use crate::analysis::decision_template::{ + declaration_conforms, predictability_gate, validate_pins, DecisionGroupKey, + DecisionKind, DecisionTemplate, ReplayMode, + }; + + let schema = may_and_target_schema(); + let [may_point, target_point] = &schema.points[..] else { + panic!("the fixture publishes exactly two points"); + }; + // CR 608.2b: the published legal set names AIMED only, so a pin naming PROPOSER is + // outside the offer's own legal set — an illegal pin VALUE at a legally exposed slot. + assert!( + !matches!(&target_point.kind, DecisionPointKind::Targets { legal_targets, .. } + if legal_targets.contains(&TargetRef::Player(PROPOSER))), + "reach-guard: PROPOSER must NOT be a published legal target, or the hostile pin \ + below is a conforming one and this row measures nothing" + ); + + for (label, pinned, expect_published) in [ + ("hostile", TargetPin::Player(PROPOSER), false), + ("control", TargetPin::Player(AIMED), true), + ] { + let mut state = recording_state(); + state.record_loop_answer( + may_point.slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + state.record_loop_answer( + target_point.slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![pinned.clone()])), + ); + assert_eq!( + state.loop_answer(&target_point.slot, PROPOSER), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + pinned.clone() + ]))), + "[{label}] reach-guard: the answer must be journalled, or the publisher exits at \ + `loop_answer(..)?` and the verdict below is the 'never answered' one" + ); + + // The template the UNVALIDATED publisher would have emitted, spelled out here so the + // handler-side half below is measured on it rather than on whatever the publisher + // now returns. + let as_published = DecisionTemplate { + owner: PROPOSER, + decisions: vec![ + PinnedDecision::MayChoice { + slot: may_point.slot.clone(), + take: MayChoiceOption::Take, + }, + PinnedDecision::Targets { + slot: target_point.slot.clone(), + targets: vec![pinned.clone()], + }, + ], + replay: ReplayMode::Scheduled { + count: schema.iteration_count.clone(), + }, + key: DecisionGroupKey::from_sources( + &schema + .points + .iter() + .map(|point| point.slot.source.clone()) + .collect::>(), + DecisionKind::LoopChoice, + ), + }; + let required: Vec<_> = schema.points.iter().map(|p| p.slot.clone()).collect(); + assert!( + predictability_gate(&as_published, &required).is_ok(), + "[{label}] reach-guard: COVERAGE passes on this template — every published slot \ + is pinned — so the handler's verdict below is the VALUE half's" + ); + + // ── HALF 1, on the handler's own instrument: would `handle_declare_shortcut` take + // it, at the range it validates the AI's `Fixed(max_iterations)` candidate over? + let handler_accepts = + validate_pins(&schema, &as_published, schema.max_iterations, &state).is_ok(); + assert_eq!( + handler_accepts, expect_published, + "[{label}] the declare-time pin firewall's verdict on the published shape" + ); + assert_eq!( + declaration_conforms(&schema, &as_published, schema.max_iterations, &state), + handler_accepts, + "[{label}] and the shared authority agrees with its own value half — it is the \ + conjunction of the two gates, not a third predicate" + ); + + // ── HALF 2: the PUBLISHER's verdict must be the same one ── + assert_eq!( + build_bounded_declaration(&state, PROPOSER, &schema).is_some(), + handler_accepts, + "[{label}] CR 732.2a: `declaration.is_some()` is read as 'the declare handler \ + will accept this'. A template the handler refuses must NOT be published, and a \ + template it accepts must be" + ); + } + } +} + +/// PR-7 Combo-UI Stage 2: the mid-drive pin injector (item 4) + the drive-period seam (item 6). +#[cfg(test)] +mod stage2_injector_tests { + use super::*; + use crate::analysis::decision_template::{ + ConcreteTarget, DecisionGroupKey, DecisionKind, DecisionSlot, DecisionTemplate, + IterationCount, PinnedDecision, ReplayMode, TargetPin, TargetSchedule, + }; + use crate::game::scenario::GameScenario; + use crate::types::game_state::{LoopDetectionMode, YieldTarget}; + + const P0: PlayerId = PlayerId(0); + const P1: PlayerId = PlayerId(1); + const P2: PlayerId = PlayerId(2); + const TARGET_DRAIN: &str = "Whenever you gain life, target opponent loses that much life."; + const FEEDBACK: &str = "Whenever an opponent loses life, you gain that much life."; + const KICKOFF: &str = "You gain 1 life."; + + fn life(state: &GameState, p: PlayerId) -> i32 { + state.players.iter().find(|pl| pl.id == p).unwrap().life + } + + fn this_object(id: ObjectId) -> YieldTarget { + YieldTarget::ThisObject { + source_id: id, + incarnation: None, + trigger_description: None, + } + } + + /// A template routing two distinct drainers to two distinct opponents by source identity. + fn two_drainer_template( + d0: ObjectId, + opp0: PlayerId, + d1: ObjectId, + opp1: PlayerId, + ) -> DecisionTemplate { + let s0 = this_object(d0); + let s1 = this_object(d1); + DecisionTemplate { + owner: P0, + decisions: vec![ + PinnedDecision::Targets { + slot: DecisionSlot { + source: s0.clone(), + index: 0, + }, + targets: vec![TargetPin::Player(opp0)], + }, + PinnedDecision::Targets { + slot: DecisionSlot { + source: s1.clone(), + index: 0, + }, + targets: vec![TargetPin::Player(opp1)], + }, + ], + replay: ReplayMode::Scheduled { + count: IterationCount::UntilLethal, + }, + key: DecisionGroupKey::from_sources(&[s0, s1], DecisionKind::LoopChoice), + } + } + + /// Test F ⭐ (item 4 — per-source target routing, the two-authority claim): a 3p loop with + /// TWO targeted drainers raises a `TriggerTargetSelection` per drainer (two legal opponents /// ⇒ not forced-unique) plus `OrderTriggers`. `inject_pinned_answer` matches EACH prompt's /// `source_id` to the pin for THAT drainer (not the first pin), so the two drainers hit /// DISTINCT opponents. Discriminator: P2 dropping proves per-source routing — a first-pin @@ -15009,107 +16201,649 @@ mod stage2_injector_tests { }, key: DecisionGroupKey::from_sources(std::slice::from_ref(&a), DecisionKind::LoopChoice), }; + // A ranking lives INSIDE a schedule step; `shortcut_drive_period` still counts STEPS, + // which is why this migration is type-only at the seam under test. + let rank = |src| { + crate::analysis::decision_template::Ranking::one( + crate::analysis::decision_template::AnnouncementSubject::Object(src), + ) + }; let constant = mk(vec![TargetPin::Player(P1)]); assert_eq!(shortcut_drive_period(Some(&constant)), 1, "Player pin ⇒ 1"); let rr = mk(vec![TargetPin::Scheduled(TargetSchedule::RoundRobin( - vec![a.clone(), b.clone(), c.clone()], + vec![rank(a.clone()), rank(b.clone()), rank(c.clone())], ))]); assert_eq!(shortcut_drive_period(Some(&rr)), 3, "RoundRobin(3) ⇒ 3"); let pw = mk(vec![TargetPin::Scheduled(TargetSchedule::Piecewise(vec![ - (0, a.clone()), - (5, b.clone()), + (0, rank(a.clone())), + (5, rank(b.clone())), ]))]); assert_eq!(shortcut_drive_period(Some(&pw)), 2, "Piecewise(2) ⇒ 2"); // CR 732.2a SAFETY LIMIT: an over-cap schedule clamps to MAX_SHORTCUT_CYCLES. // Revert-probe: restore `.max(1)` (drop the `.clamp`) ⇒ returns MAX+5 (1005) ≠ 1000. let oversized = mk(vec![TargetPin::Scheduled(TargetSchedule::RoundRobin( - vec![a.clone(); (MAX_SHORTCUT_CYCLES + 5) as usize], + vec![rank(a.clone()); (MAX_SHORTCUT_CYCLES + 5) as usize], ))]); assert_eq!( shortcut_drive_period(Some(&oversized)), MAX_SHORTCUT_CYCLES, "RoundRobin(MAX+5) clamps to MAX_SHORTCUT_CYCLES" ); - } + } + + /// Place a bare object in `zone` without touching the zone vectors — enough for the + /// identity/zone predicates under test, which read `state.objects` only. + fn place(state: &mut GameState, id: u64, zone: crate::types::zones::Zone) -> ObjectId { + let oid = ObjectId(id); + let mut o = crate::game::game_object::GameObject::new( + oid, + CardId(0), + P0, + "Emblem".to_string(), + zone, + ); + o.incarnation = 3; + state.objects.insert(oid, o); + oid + } + + /// Stand up the `WaitingFor::TriggerTargetSelection` prompt an announcement answers, with + /// `slot_count` announcement slot(s). + /// + /// `record_trigger_target_answer` reads the slot count off the prompt IN HAND — that is + /// production's own instrument, because both reducer arms run before the handler replaces + /// `waiting_for` — so a row that drives the writer has to stand the prompt up the way + /// production does rather than call the writer against a bare board. + fn stand_up_target_prompt( + state: &mut GameState, + player: PlayerId, + source: ObjectId, + slot_count: usize, + ) { + let slot = crate::types::game_state::TargetSelectionSlot { + legal_targets: vec![], + optional: false, + chooser: None, + effect_kind: crate::types::ability::EffectKind::NoOp, + effect_detail: crate::types::game_state::TargetEffectDetail::None, + }; + state.waiting_for = WaitingFor::TriggerTargetSelection { + player, + trigger_controller: None, + trigger_event: None, + trigger_events: vec![], + target_slots: vec![slot; slot_count], + mode_labels: vec![], + target_constraints: vec![], + selection: Default::default(), + source_id: Some(source), + description: None, + }; + } + + /// **Row T5.** CR 608.2b: an announcement one of whose members no longer resolves to a + /// live identity abandons the WHOLE journal write, rather than journalling a short + /// vector. + /// + /// This DIVERGES DELIBERATELY from the proliferate `record_loop_pin` site, which + /// `filter_map`s an unresolvable object away: there a short pin vector still drives, + /// while here a short vector would be journalled as a UNIFORM answer and then fail + /// `validate_pins` at declare time — a WRONG PIN rather than no offer. + /// + /// # Discrimination + /// + /// Replace `record_trigger_target_answer`'s `collect::>>()` with + /// `filter_map(..).collect::>()` (the `record_loop_pin` shape) ⇒ the negative + /// arm's `loop_answers_recorded()` rises to 1 with a one-pin vector and that assertion + /// flips. The mutation reds on the ASSERT, not on a compile error. + /// + /// # Paired positive / reach-guards + /// + /// The negative arm alone is satisfied by ANY no-op writer, so the positive arm runs + /// FIRST on the same state and asserts BOTH pins are journalled under the CR 601.2c + /// slot. The empty-announcement arm is the third case the helper's own guard names. + #[test] + fn c2a_row_t5_an_unresolvable_target_abandons_the_whole_journal_write() { + use crate::analysis::decision_template::{ + AnnouncementSubject, DecisionSlot, LoopAnswer, LoopAnswerValue, Ranking, TargetPin, + TargetSchedule, + }; + use crate::types::ability::TargetRef; + + let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); + state.loop_detection = LoopDetectionMode::Interactive; + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: the board starts with an EMPTY journal" + ); + let src = place(&mut state, 920, crate::types::zones::Zone::Battlefield); + let live = place(&mut state, 921, crate::types::zones::Zone::Battlefield); + let dead = ObjectId(922); + // The single-slot announcement this row is about — the writer refuses without the + // prompt it answers (see `stand_up_target_prompt`). + stand_up_target_prompt(&mut state, P0, src, 1); + assert!( + !state.objects.contains_key(&dead), + "reach-guard: the unresolvable member must genuinely be absent from `objects`, \ + else this row's negative arm tests nothing" + ); + let slot = DecisionSlot::target( + object_decision_source(&state, src).expect("the source object is live"), + ); + + // ── PAIRED POSITIVE: every member resolves ⇒ BOTH pins are journalled ── + record_trigger_target_answer( + &mut state, + Some(src), + P0, + &[TargetRef::Object(live), TargetRef::Player(P1)], + ); + assert_eq!( + state.loop_answers_recorded(), + 1, + "the fully-resolvable announcement must be journalled — without this the \ + negative arm below is satisfied by any no-op writer" + ); + assert_eq!( + state.loop_answer(&slot, P0), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + TargetPin::ByIdentity( + object_decision_source(&state, live).expect("the live target resolves") + ), + // CR 601.2c: an ANNOUNCED seat, so the TARGET-class spelling — not + // `TargetPin::Player`, which is the CR 115.10a choice class. + TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(P1) + ))), + ]))), + "CR 601.2c: the pins are journalled in ANNOUNCEMENT ORDER, and CR 400.7 binds \ + the object member to its current incarnation" + ); + + // ── THE ROW'S OWN CLAIM: one dead member abandons the whole write ── + let before = state.loop_answers_recorded(); + record_trigger_target_answer( + &mut state, + Some(src), + P1, + &[TargetRef::Object(dead), TargetRef::Player(P1)], + ); + assert_eq!( + state.loop_answers_recorded(), + before, + "CR 608.2b: an unresolvable member abandons the WHOLE write. Under a \ + `filter_map` this rises by one and journals a one-pin vector, which \ + `validate_pins` would later reject as an illegal pin value — a wrong pin \ + instead of no offer" + ); + + // ── the empty announcement the `ChooseTarget` arm's `target: None` produces ── + record_trigger_target_answer(&mut state, Some(src), P1, &[]); + assert_eq!( + state.loop_answers_recorded(), + before, + "an empty announcement is not an answer a pin can specify, so nothing is \ + journalled" + ); + } + + /// **Row F2.** CR 601.2c (reached for a triggered ability via CR 603.3d) + CR 608.2b: a + /// MULTI-SLOT announcement is refused outright, because `DecisionSlot::target` hard-codes + /// `index: 0` and would collapse every slot of it onto ONE journal key. + /// + /// # The value that makes this a defect rather than a rounding error + /// + /// Two slots taking DISTINCT targets latch `Conflicted` — fail-closed, harmless. Two slots + /// taking the SAME target — which CR 601.2c expressly permits ("if the spell uses the word + /// `target` in multiple places, the same object or player can be chosen once for each + /// instance") — stored `Uniform(Targets([one pin]))`: a TRUNCATED answer that satisfies + /// `LoopAnswerValue::Targets`' own contract ("the announced targets for ONE slot") only by + /// accident, and that a widened publisher would spend as a valid pin. Arm (c) below is that + /// exact shape. + /// + /// # Discrimination — and the axis it is keyed to + /// + /// Arms (a) and (b) differ in EXACTLY ONE fact, the prompt's `target_slots.len()`: the same + /// source, the same seat, the same single announced target. So the row cannot pass by + /// accident on the announcement's own shape. + /// + /// A `targets.len() > 1` guard — the plausible wrong reading, keyed to how many targets were + /// announced rather than how many slots were asked — passes (a) and FAILS (b) and (c), + /// because the `ChooseTarget` walk announces ONE target per beat no matter how many slots + /// the prompt carries. That is why (b)/(c) announce a single target against a two-slot + /// prompt rather than two targets at once. + /// + /// REVERT-PROBE (measured, in the fix report): delete the `announced_slots > 1` early return + /// ⇒ (b) and (c) FLIP TO FAILING, (c) with the truncated one-pin `Uniform` value named + /// above. Delete the `WaitingFor::TriggerTargetSelection` read instead ⇒ that is a compile + /// error, since `announced_slots` has no other source. + /// + /// # Reach-guard + /// + /// Arm (a) runs FIRST and asserts a POSITIVE write, so none of the refusals below is + /// satisfied by a writer that journals nothing at all. + #[test] + fn c2a_row_f2_a_multi_slot_announcement_is_refused_rather_than_collapsed() { + use crate::analysis::decision_template::{ + AnnouncementSubject, DecisionSlot, LoopAnswer, LoopAnswerValue, Ranking, TargetPin, + TargetSchedule, + }; + use crate::types::ability::TargetRef; + + let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); + state.loop_detection = LoopDetectionMode::Interactive; + let src = place(&mut state, 930, crate::types::zones::Zone::Battlefield); + let slot = DecisionSlot::target( + object_decision_source(&state, src).expect("the source object is live"), + ); + + // ── (a) POSITIVE CONTROL: one announcement slot ⇒ the answer is journalled ── + stand_up_target_prompt(&mut state, P0, src, 1); + record_trigger_target_answer(&mut state, Some(src), P0, &[TargetRef::Player(P1)]); + assert_eq!( + state.loop_answer(&slot, P0), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + // CR 601.2c TARGET class: an announced seat takes the ranked spelling. + TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(P1) + ))) + ]))), + "a single-slot announcement is exactly what this journal key describes" + ); + let after_positive = state.loop_answers_recorded(); + assert_eq!( + after_positive, 1, + "reach-guard: exactly one key exists so far" + ); + + // ── (b) THE CLAIM: the SAME announcement under a TWO-slot prompt is refused ── + stand_up_target_prompt(&mut state, P1, src, 2); + record_trigger_target_answer(&mut state, Some(src), P1, &[TargetRef::Player(P2)]); + assert_eq!( + state.loop_answer(&slot, P1), + None, + "CR 601.2c: two announcement slots are two choices, and `DecisionSlot::target`'s \ + `index: 0` can key only one of them — refuse rather than collapse" + ); + assert_eq!( + state.loop_answers_recorded(), + after_positive, + "and no key is created at all: the whole write is abandoned" + ); + + // ── (c) THE DANGEROUS SHAPE: two slots, the SAME target, answered slot by slot ── + // Pre-fix this stored `Uniform(Targets([Player(P2)]))` — a one-pin answer to a + // two-choice announcement, indistinguishable from a legitimate single-slot answer. + for _ in 0..2 { + record_trigger_target_answer(&mut state, Some(src), P2, &[TargetRef::Player(P2)]); + } + assert_eq!( + state.loop_answer(&slot, P2), + None, + "CR 601.2c permits the same target for each instance of `target`, so repeating it \ + must not read as a UNIFORM answer to the whole announcement" + ); + assert_eq!( + state.loop_answers_recorded(), + after_positive, + "(c) creates no key either" + ); + + // ── (d) no prompt in hand ⇒ no announcement to journal ── + state.waiting_for = WaitingFor::Priority { player: P0 }; + record_trigger_target_answer(&mut state, Some(src), P1, &[TargetRef::Player(P2)]); + assert_eq!( + state.loop_answers_recorded(), + after_positive, + "the writer is the `TriggerTargetSelection` reducer's authority; with no such \ + prompt there is no announced choice for it to record" + ); + } + + /// CR 114.4 + CR 608.2b: a pinned SLOT whose source is a command-zone emblem must match + /// the prompt that emblem raised; a graveyard or exile source must NOT. + /// + /// This is the zone predicate `inject_pinned_answer`'s `TriggerTargetSelection` arm + /// dispatches on, and it now has two further production callers on the drive side: + /// `pinned_targets_for_source` and `pinned_mana_color_for_source` ask the same question + /// through the same `slot_source_prompted` predicate. The shipped BATTLEFIELD arm is + /// exercised end-to-end by `injector_routes_pinned_targets_per_source` above and by the + /// `kilo_live_offer_from_real_dump` rows, and this row asserts that arm is unchanged. + /// + /// The zone disjuncts this row pins now live one call down, in + /// [`crate::analysis::decision_template::resolve_ability_instance`], which + /// `slot_source_prompted` delegates to — so all three probes below are run THERE. That + /// delegation is why this row is also the maintained-invariant row for the factoring: it + /// stays green unmodified, and a factoring that silently widened the zone set reds here. + /// (Measured: dropping the `Zone::Command` filter in that accessor fails the graveyard + /// assertion below.) + /// + /// REVERT-PROBES: (a) delete the command-zone disjunct ⇒ the + /// Command assertion FAILS (and `inject_pinned_answer` would `RecastAbort` on an + /// emblem-pinned drive); (b) widen the disjunct to accept any zone ⇒ the graveyard and + /// exile assertions FAIL; (c) drop the incarnation conjunct ⇒ the CR 400.7 assertion + /// FAILS. + #[test] + fn command_zone_sourced_slot_matches_and_graveyard_still_aborts() { + use crate::types::zones::Zone; + let mut state = GameScenario::new_n_player(2, 7).build().state().clone(); + let battlefield = place(&mut state, 900, Zone::Battlefield); + let emblem = place(&mut state, 901, Zone::Command); + let graveyard = place(&mut state, 902, Zone::Graveyard); + let exiled = place(&mut state, 903, Zone::Exile); + + let pin = |id: ObjectId, inc: Option| YieldTarget::ThisObject { + source_id: id, + incarnation: inc, + trigger_description: None, + }; + + // Shipped behaviour, unchanged: the battlefield arm still matches. + assert!( + slot_source_prompted(&state, &pin(battlefield, Some(3)), battlefield), + "the shipped CR 608.2b battlefield arm must be untouched" + ); + // CR 114.2 — an emblem lives in the command zone; CR 114.4 — its abilities function + // there, which is why the slot may prompt from there. + assert!( + slot_source_prompted(&state, &pin(emblem, Some(3)), emblem), + "CR 114.4: a command-zone emblem's slot must match the prompt it raised" + ); + // Fail-closed: every other off-battlefield zone still misses ⇒ `RecastAbort`. + assert!( + !slot_source_prompted(&state, &pin(graveyard, Some(3)), graveyard), + "a graveyard-sourced slot must NOT match — the drive aborts to manual" + ); + assert!( + !slot_source_prompted(&state, &pin(exiled, Some(3)), exiled), + "an exile-sourced slot must NOT match" + ); + // CR 400.7: the command arm re-binds ONE incarnation, exactly like the + // battlefield arm — a re-created emblem does not answer the old pin. + assert!( + !slot_source_prompted(&state, &pin(emblem, Some(2)), emblem), + "CR 400.7: a stale incarnation must not match even in the command zone" + ); + // A pin naming a DIFFERENT object never answers this prompt. + assert!( + !slot_source_prompted(&state, &pin(emblem, Some(3)), battlefield), + "the matcher is keyed on identity, not merely on zone" + ); + } + + /// **T1 — CR 608.2b + CR 114.4: the drive's tap-cost / proliferate seam matches a + /// COMMAND-zone slot source, and still refuses every other way of missing.** + /// + /// `pinned_targets_for_source` asks "which pinned `Targets` belongs to the ability + /// instance that raised this beat?" through [`slot_source_prompted`] — the predicate + /// `inject_pinned_answer` already used — instead of through the battlefield-only + /// `resolve_source`. CR 114.4 (CR 113.6p for the plane / scheme / conspiracy members of + /// the same class) is why an ability may prompt from the command zone at all; CR 608.2b + /// is why the pinned TARGETS stay battlefield-only, which row c pins. + /// + /// # Non-vacuity / discrimination + /// + /// Every row is ONE field from row b and comes out OPPOSITE it. An input that never + /// arrived cannot produce that table — it would answer the same way on both sides of the + /// field. Each negative row asserts its subject exists in the intended zone BEFORE the + /// negative assertion, so it provably fails for the stated reason rather than because the + /// object was never built. + /// + /// REVERT-PROBES: restore `resolve_source` at this seam ⇒ row **b** fails alone; widen + /// `resolve_source` to admit `Zone::Command` ⇒ row **c** fails; widen the accessor's + /// command disjunct to any zone ⇒ row **d** fails; drop the CR 400.7 incarnation + /// conjunct ⇒ row **e** fails; make the accessor answer "any command-zone object" ⇒ row + /// **f** fails. + #[test] + fn pinned_targets_for_source_matches_a_command_zone_slot_and_still_refuses_elsewhere() { + use crate::types::zones::Zone; + let mut state = GameScenario::new_n_player(2, 7).build().state().clone(); + let battlefield = place(&mut state, 900, Zone::Battlefield); + let command = place(&mut state, 901, Zone::Command); + let graveyard = place(&mut state, 902, Zone::Graveyard); + let other_command = place(&mut state, 904, Zone::Command); + let command_target = place(&mut state, 905, Zone::Command); + + let live_src = |id: ObjectId| object_decision_source(&state, id).expect("placed above"); + let bf_src = live_src(battlefield); + let cmd_src = live_src(command); + let gy_src = live_src(graveyard); + let stale_cmd_src = YieldTarget::ThisObject { + source_id: command, + incarnation: Some(2), + trigger_description: None, + }; + let bf_target = TargetPin::ByIdentity(live_src(battlefield)); + let cmd_target = TargetPin::ByIdentity(live_src(command_target)); + + let template = |slot_source: &YieldTarget, target: &TargetPin| DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot: DecisionSlot { + source: slot_source.clone(), + index: 0, + }, + targets: vec![target.clone()], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::UntilLethal, + }, + key: DecisionGroupKey::from_sources( + std::slice::from_ref(slot_source), + DecisionKind::LoopChoice, + ), + }; + + // row a — the shipped battlefield arm, unchanged. Also the control that proves the + // instrument can return targets at all. + assert_eq!( + pinned_targets_for_source(&template(&bf_src, &bf_target), 0, &state, battlefield) + .expect("row a: a battlefield-sourced slot still answers its own beat"), + vec![ConcreteTarget::Object(battlefield)], + "row a: control" + ); + + // row b — THE FIX. One field from row a: the slot source's ZONE. + assert_eq!( + pinned_targets_for_source(&template(&cmd_src, &bf_target), 0, &state, command) + .expect("row b: CR 114.4 — a command-zone ability instance's slot matches"), + vec![ConcreteTarget::Object(battlefield)], + "row b: the command-zone slot source answers the beat it raised" + ); + + // row c — one field from b: the TARGET's zone. CR 608.2b is not widened. + assert_eq!( + state + .objects + .get(&command_target) + .expect("reach-guard: row c's target object was built") + .zone, + Zone::Command, + "reach-guard: row c's TARGET is in the command zone, so its Err is about the \ + target path and not about a missing object" + ); + assert!( + pinned_targets_for_source(&template(&cmd_src, &cmd_target), 0, &state, command) + .is_err(), + "row c: CR 608.2b — a pinned TARGET off the battlefield stays illegal; widening \ + the SLOT question does not widen the TARGET question" + ); + + // row d — one field from b: the slot source's zone again, the other way. + assert_eq!( + state + .objects + .get(&graveyard) + .expect("reach-guard: row d's slot-source object was built") + .zone, + Zone::Graveyard, + "reach-guard: row d's slot source exists and is in the graveyard" + ); + assert!( + pinned_targets_for_source(&template(&gy_src, &bf_target), 0, &state, graveyard) + .is_err(), + "row d: the zone set is {{Battlefield, Command}} and nothing else — a graveyard \ + source aborts the drive to manual play" + ); - /// Place a bare object in `zone` without touching the zone vectors — enough for the - /// identity/zone predicates under test, which read `state.objects` only. - fn place(state: &mut GameState, id: u64, zone: crate::types::zones::Zone) -> ObjectId { - let oid = ObjectId(id); - let mut o = crate::game::game_object::GameObject::new( - oid, - CardId(0), - P0, - "Emblem".to_string(), - zone, + // row e — one field from b: the pinned CR 400.7 incarnation. + assert_ne!( + state + .objects + .get(&command) + .expect("reach-guard: row e's slot-source object was built") + .incarnation, + 2, + "reach-guard: the LIVE incarnation differs from the pinned one, so row e's Err \ + is about CR 400.7 and not about an absent object" + ); + assert!( + pinned_targets_for_source(&template(&stale_cmd_src, &bf_target), 0, &state, command) + .is_err(), + "row e: CR 400.7 — a re-created source is a new object and does not answer the \ + old pin, in the command zone exactly as on the battlefield" + ); + + // row f — one field from b: the identity being asked about. + for (id, label) in [(command, "the pinned"), (other_command, "the asking")] { + assert_eq!( + state + .objects + .get(&id) + .unwrap_or_else(|| panic!("reach-guard: {label} object was built")) + .zone, + Zone::Command, + "reach-guard: BOTH command-zone objects exist, so row f cannot pass by \ + absence — only by identity" + ); + } + assert!( + pinned_targets_for_source(&template(&cmd_src, &bf_target), 0, &state, other_command) + .is_err(), + "row f: the matcher is keyed on IDENTITY, not on 'some object in the command \ + zone' — a second command-zone source does not inherit this slot's answer" ); - o.incarnation = 3; - state.objects.insert(oid, o); - oid } - /// CR 114.2 + CR 608.2b: a pinned SLOT whose source is a command-zone emblem must match - /// the prompt that emblem raised; a graveyard or exile source must NOT. + /// **T2 — CR 608.2d + CR 114.4: the same migration at the mana-color seam.** /// - /// This is the zone predicate `inject_pinned_answer`'s `TriggerTargetSelection` arm - /// dispatches on. Its production drive lands with the bounded offer in a later commit, - /// so it is pinned here at the seam — the shipped BATTLEFIELD arm is exercised - /// end-to-end by `injector_routes_pinned_targets_per_source` above and by the - /// `kilo_live_offer_from_real_dump` rows, and this row asserts that arm is unchanged. + /// `pinned_mana_color_for_source` records the CR 608.2d color choice a mana ability + /// offered; which ability instance offered it is [`slot_source_prompted`]'s question, now + /// asked with the same spelling as at the tap-cost seam. Rows a/b/d/e/f mirror T1's; a + /// `ManaColor` pin carries no targets, so T1's target-legality row c has no analogue. /// - /// REVERT-PROBES: (a) delete the command-zone disjunct in `slot_source_prompted` ⇒ the - /// Command assertion FAILS (and `inject_pinned_answer` would `RecastAbort` on an - /// emblem-pinned drive); (b) widen the disjunct to accept any zone ⇒ the graveyard and - /// exile assertions FAIL; (c) drop the incarnation conjunct ⇒ the CR 400.7 assertion - /// FAILS. + /// # Non-vacuity / discrimination + /// + /// Same shape as T1: one field from row b, opposite verdict, every negative row reach- + /// guarded on its subject's existence and zone. + /// + /// REVERT-PROBES: restore `resolve_source` at this seam ⇒ row **b** fails alone; the + /// accessor-side probes (any-zone widening, dropped incarnation conjunct, zone-not- + /// identity matching) fail rows **d**, **e**, **f** respectively. #[test] - fn command_zone_sourced_slot_matches_and_graveyard_still_aborts() { + fn pinned_mana_color_for_source_matches_a_command_zone_slot_and_still_refuses_elsewhere() { + use crate::types::mana::ManaColor; use crate::types::zones::Zone; let mut state = GameScenario::new_n_player(2, 7).build().state().clone(); let battlefield = place(&mut state, 900, Zone::Battlefield); - let emblem = place(&mut state, 901, Zone::Command); + let command = place(&mut state, 901, Zone::Command); let graveyard = place(&mut state, 902, Zone::Graveyard); - let exiled = place(&mut state, 903, Zone::Exile); - - let pin = |id: ObjectId, inc: Option| YieldTarget::ThisObject { - source_id: id, - incarnation: inc, + let other_command = place(&mut state, 904, Zone::Command); + + let live_src = |id: ObjectId| object_decision_source(&state, id).expect("placed above"); + let bf_src = live_src(battlefield); + let cmd_src = live_src(command); + let gy_src = live_src(graveyard); + let stale_cmd_src = YieldTarget::ThisObject { + source_id: command, + incarnation: Some(2), trigger_description: None, }; - // Shipped behaviour, unchanged: the battlefield arm still matches. - assert!( - slot_source_prompted(&state, &pin(battlefield, Some(3)), battlefield), - "the shipped CR 608.2b battlefield arm must be untouched" + let template = |slot_source: &YieldTarget| DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::ManaColor { + slot: DecisionSlot { + source: slot_source.clone(), + index: 0, + }, + color: ManaColor::Blue, + }], + replay: ReplayMode::Scheduled { + count: IterationCount::UntilLethal, + }, + key: DecisionGroupKey::from_sources( + std::slice::from_ref(slot_source), + DecisionKind::LoopChoice, + ), + }; + + // row a — shipped battlefield arm + the control that the instrument returns a color. + assert_eq!( + pinned_mana_color_for_source(&template(&bf_src), 0, &state, battlefield) + .expect("row a: a battlefield-sourced slot still answers its own beat"), + ManaColor::Blue, + "row a: control" ); - // NEW: CR 114.2 — an emblem lives in the command zone and prompts from there. - assert!( - slot_source_prompted(&state, &pin(emblem, Some(3)), emblem), - "CR 114.2: a command-zone emblem's slot must match the prompt it raised" + + // row b — THE FIX, one field from a: the slot source's zone. + assert_eq!( + pinned_mana_color_for_source(&template(&cmd_src), 0, &state, command) + .expect("row b: CR 114.4 — a command-zone ability instance's slot matches"), + ManaColor::Blue, + "row b: the command-zone slot source answers the CR 608.2d choice it offered" ); - // Fail-closed: every other off-battlefield zone still misses ⇒ `RecastAbort`. - assert!( - !slot_source_prompted(&state, &pin(graveyard, Some(3)), graveyard), - "a graveyard-sourced slot must NOT match — the drive aborts to manual" + + // row d — one field from b: the slot source's zone, the other way. + assert_eq!( + state + .objects + .get(&graveyard) + .expect("reach-guard: row d's slot-source object was built") + .zone, + Zone::Graveyard, + "reach-guard: row d's slot source exists and is in the graveyard" ); assert!( - !slot_source_prompted(&state, &pin(exiled, Some(3)), exiled), - "an exile-sourced slot must NOT match" + pinned_mana_color_for_source(&template(&gy_src), 0, &state, graveyard).is_err(), + "row d: a graveyard source aborts the drive to manual play" + ); + + // row e — one field from b: the pinned CR 400.7 incarnation. + assert_ne!( + state + .objects + .get(&command) + .expect("reach-guard: row e's slot-source object was built") + .incarnation, + 2, + "reach-guard: the LIVE incarnation differs from the pinned one" ); - // CR 400.7: the command arm re-binds ONE incarnation, exactly like the - // battlefield arm — a re-created emblem does not answer the old pin. assert!( - !slot_source_prompted(&state, &pin(emblem, Some(2)), emblem), - "CR 400.7: a stale incarnation must not match even in the command zone" + pinned_mana_color_for_source(&template(&stale_cmd_src), 0, &state, command).is_err(), + "row e: CR 400.7 — a stale incarnation does not match in the command zone either" ); - // A pin naming a DIFFERENT object never answers this prompt. + + // row f — one field from b: the identity being asked about. + for (id, label) in [(command, "the pinned"), (other_command, "the asking")] { + assert_eq!( + state + .objects + .get(&id) + .unwrap_or_else(|| panic!("reach-guard: {label} object was built")) + .zone, + Zone::Command, + "reach-guard: BOTH command-zone objects exist, so row f cannot pass by absence" + ); + } assert!( - !slot_source_prompted(&state, &pin(emblem, Some(3)), battlefield), - "the matcher is keyed on identity, not merely on zone" + pinned_mana_color_for_source(&template(&cmd_src), 0, &state, other_command).is_err(), + "row f: identity, not zone, selects whose color this is" ); } @@ -15227,7 +16961,7 @@ mod stage2_injector_tests { /// row is what fails when ONE conjunct is dropped. /// /// Why each matters: `find_legal_targets` collapses a `Typed` filter to PLAYERS ONLY - /// when both `type_filters` and `properties` are empty (`targeting.rs:192-193`, issue + /// when both `type_filters` and `properties` are empty (issue /// #2004). A type- or property-bearing filter therefore falls through to OBJECT /// enumeration — publishing it would put a point whose legal set is object refs into /// player-pin machinery. `controller: You` does collapse to players, but to exactly ONE @@ -15557,7 +17291,7 @@ mod stage2_injector_tests { ); } - /// CR 114.2 + CR 608.2b, on a REAL restored 4p board: `inject_pinned_answer` accepts a + /// CR 114.4 + CR 608.2b, on a REAL restored 4p board: `inject_pinned_answer` accepts a /// pin whose slot source is the COMMAND-zone emblem (obj 541) that raised the prompt. /// /// This is the production-path row for [`slot_source_prompted`]. The seam is live @@ -15618,7 +17352,7 @@ mod stage2_injector_tests { let src = object_decision_source(&state, EMBLEM).expect("the emblem object exists"); // The control that makes this row non-vacuous: the shipped battlefield-only // `resolve_source` does NOT match this source, so an accept can only come from the - // CR 114.2 disjunct. + // CR 114.4 disjunct. assert_eq!( crate::analysis::decision_template::resolve_source(&src, &state), None, @@ -15644,7 +17378,7 @@ mod stage2_injector_tests { // ── ACCEPT: the command-zone pin answers the prompt on the real board ── let mut work = state.clone(); inject_pinned_answer(&mut work, Some(&template(src.clone())), 0, &prompt) - .expect("CR 114.2: the emblem's own pin must answer the prompt it raised"); + .expect("CR 114.4: the emblem's own pin must answer the prompt it raised"); assert_ne!( work.waiting_for, prompt, "the prompt was actually consumed, not silently skipped" @@ -15688,6 +17422,307 @@ mod stage2_injector_tests { ); } + /// **T3 — the slot-source VALUE is one a REAL board produces, and the migrated drive + /// seam accepts it** (CR 114.4 + CR 608.2b, on the restored 4p dellian board). + /// + /// Modelled on `a_command_zone_pin_answers_a_real_restored_boards_prompt` above, whose + /// structure — reach guards read off the loaded board, then a `resolve_source == None` + /// non-vacuity control, then the accept — is reused here at the OTHER consumer. + /// + /// # What this row does NOT claim + /// + /// It does not claim that a shipped card drives a command-zone-sourced tap-cost / + /// mana-color / proliferate beat *in a recorded loop period* today. It claims the + /// slot-source VALUE is one a real board produces and that `pinned_targets_for_source` + /// accepts it. + /// + /// # Non-vacuity / discrimination + /// + /// The control is load-bearing: `resolve_source` answers `None` for this very source on + /// this very board, so row a's `Ok` can only have come from the command-zone disjunct. + /// Rows b–e are each one field from row a and come out opposite. Every object is chosen + /// by PREDICATE off the loaded board (except `EMBLEM`, already a module const), so a + /// re-derived fixture cannot silently blank a row. + /// + /// REVERT-PROBES: restore `resolve_source` at this seam ⇒ row **a** fails; widen the + /// accessor to any zone ⇒ row **b** fails; drop the CR 400.7 conjunct ⇒ row **c** fails; + /// match on zone rather than identity ⇒ rows **d** and **e** fail. + #[test] + fn a_command_zone_slot_from_the_real_4p_board_answers_the_recast_beat() { + use crate::types::zones::Zone; + let state = load_dellian_dump(); + + // ── reach guards, all read off the loaded board ── + let emblem = state + .objects + .get(&EMBLEM) + .expect("reach-guard: dump B carries the emblem object"); + assert_eq!( + emblem.zone, + Zone::Command, + "reach-guard: CR 114.2 puts the emblem in the command zone" + ); + assert!( + emblem.is_emblem, + "reach-guard: CR 114.4 is the rule under test, so the object must really be an \ + emblem" + ); + let emblem_incarnation = emblem.incarnation; + + let lowest_battlefield = state + .objects + .values() + .filter(|o| o.zone == Zone::Battlefield) + .min_by_key(|o| o.id.0) + .map(|o| o.id) + .expect("reach-guard: the board has battlefield objects to target"); + let graveyard_object = state + .objects + .values() + .filter(|o| o.zone == Zone::Graveyard) + .min_by_key(|o| o.id.0) + .map(|o| o.id) + .expect("reach-guard: the board has a graveyard object for row b"); + + let src = object_decision_source(&state, EMBLEM).expect("the emblem object exists"); + // Non-vacuity control: the shipped battlefield-only `resolve_source` does NOT match + // this source, so an accept can only come from the CR 114.4 disjunct. + assert_eq!( + crate::analysis::decision_template::resolve_source(&src, &state), + None, + "CR 608.2b: `resolve_source` is battlefield-only and must stay so" + ); + + let template = |slot_source: YieldTarget| DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot: DecisionSlot { + source: slot_source.clone(), + index: 0, + }, + targets: vec![TargetPin::ByIdentity(object_decision_source_of( + &state, + lowest_battlefield, + ))], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::UntilLethal, + }, + key: DecisionGroupKey::from_sources(&[slot_source], DecisionKind::LoopChoice), + }; + + // row a — positive: the real board's command-zone source answers its own beat. + assert_eq!( + pinned_targets_for_source(&template(src.clone()), 0, &state, EMBLEM) + .expect("CR 114.4: the emblem's own slot answers the beat it raised"), + vec![ConcreteTarget::Object(lowest_battlefield)], + "row a: the drive seam accepts a slot source a REAL board produced" + ); + + // row b — one field: a graveyard source off the same board. + let gy_src = object_decision_source_of(&state, graveyard_object); + assert!( + pinned_targets_for_source(&template(gy_src), 0, &state, graveyard_object).is_err(), + "row b: the zone set is {{Battlefield, Command}}; a graveyard source still aborts" + ); + + // row c — one field: the pinned CR 400.7 incarnation. + let stale = YieldTarget::ThisObject { + source_id: EMBLEM, + incarnation: Some(emblem_incarnation + 1), + trigger_description: None, + }; + assert!( + pinned_targets_for_source(&template(stale), 0, &state, EMBLEM).is_err(), + "row c: CR 400.7 — a re-created emblem does not answer the old pin" + ); + + // row d — one field: the identity being asked about. + assert!( + pinned_targets_for_source(&template(src.clone()), 0, &state, lowest_battlefield) + .is_err(), + "row d: identity, not zone, selects whose slot this is" + ); + + // row e — MULTI-AUTHORITY hostile fixture. This board holds TWO command-zone + // objects, and the second is not an emblem: object 400's two TRIGGERS declare + // `trigger_zones == ["Battlefield"]` while its cost-reduction STATIC declares + // `active_zones ∋ Command`; one object, two abilities, two different zones of + // function (CR 113.6b). The accessor admits it by zone either way, so only identity + // can exclude it. + let commander = state + .objects + .values() + .filter(|o| o.zone == Zone::Command && o.id != EMBLEM) + .min_by_key(|o| o.id.0) + .map(|o| o.id) + .expect("reach-guard: dump B carries a SECOND command-zone object"); + assert!( + !state.objects[&commander].is_emblem, + "reach-guard: the second command-zone object is not an emblem, so row e is a \ + genuine multi-authority case and not a duplicate of row a" + ); + let commander_src = object_decision_source_of(&state, commander); + assert!( + pinned_targets_for_source(&template(commander_src), 0, &state, EMBLEM).is_err(), + "row e: a DIFFERENT command-zone ability instance's slot must not answer the \ + emblem's beat — zone admits both, identity separates them" + ); + } + + /// `object_decision_source` with the test's own existence guard folded in, so a row that + /// picks its object by predicate cannot silently degrade into `None`. + fn object_decision_source_of(state: &GameState, id: ObjectId) -> YieldTarget { + object_decision_source(state, id) + .unwrap_or_else(|| panic!("reach-guard: object {id:?} is on the loaded board")) + } + + /// **T6 — the capability-neutrality pin.** Five rows on ONE board with ONE template + /// value each, pinning what resolving a command-zone `Order` pin does and does not buy. + /// + /// Both sides of the Order-only claim return `Err(RecastAbort)` — a unit struct — so the + /// two abort points are not distinguishable by return value. The instrument therefore + /// observes the abort point INDIRECTLY, through a second pin in the same template, using + /// the mechanism `resolve` is built on: it is a per-pin `Result` collect, so one failing + /// pin discards every other pin's answer. + /// + /// | row | template | seam | expect | + /// |---|---|---|---| + /// | **R** (delivery) | `order_only` | `decision_template::resolve` | `Ok`, carrying the `Order` element | + /// | **N** (neutrality, `ok_or` half) | `order_only`, the SAME value | `pinned_targets_for_source` | `Err(RecastAbort)` | + /// | **N2** (neutrality, `find_map` half) | `order_only`, the SAME value | `inject_pinned_answer` | `Err(RecastAbort)` | + /// | **P** (poisoning removed) | `mixed` | `pinned_targets_for_source` | `Ok` | + /// | **P-minus** (omit-the-pin equivalence) | `mixed` minus its `Order` element | `pinned_targets_for_source` | `Ok`, EQUAL to P's | + /// + /// # Why no row is vacuous + /// + /// * **P vs N** differ by exactly one field — the presence of the `Targets` pin — and + /// come out OPPOSITE. + /// * **N and N2 are revert-INSENSITIVE on purpose, and that insensitivity IS the + /// measurement**: "the beat still fails closed" is the claim that the verdict does not + /// move. **R is their reach guard.** R calls the same `resolve` with the same + /// `(template, 0, &state)` triple those two seams call internally, so `R == Ok` proves + /// the input arrived and resolved; N's and N2's `Err` can then only be the + /// post-resolve fall-through, never "the input never arrived". **This is the + /// load-bearing condition of the whole test: R, N and N2 must use the byte-same + /// template VALUE on the same `&state` VALUE.** If R built its own template, N and N2 + /// would become indistinguishable from non-delivery and this row would read as proof + /// while measuring nothing. + /// * **P-minus** is the security row. Its discriminating assertion is the EQUALITY, not + /// the `Ok`: it fails the moment an `Order` element contributes anything to the answer. + /// `mixed_no_order` is `mixed` minus exactly its `Order` element, built from the same + /// `targets_pin` value on the same board — if the two templates were constructed + /// independently, the `assert_eq!` would measure two hand-written templates agreeing + /// instead of the element contributing nothing. + /// + /// # What this does NOT measure + /// + /// ACCEPTANCE. Whether `mixed_no_order` is *submittable* is `declaration_conforms`' + /// question; no row here asks it, and the answer is not always yes (an `Order` pin can be + /// the sole cover of a required point, in which case dropping it fails + /// `predictability_gate` — pre-existing and zone-independent). + /// + /// REVERT-PROBES: reverting the `Order` arm to `resolve_source` ⇒ rows **R** and **P** + /// fail; **N**, **N2** and **P-minus** are deliberately revert-insensitive. + #[test] + fn a_command_zone_order_pin_stops_poisoning_the_template_without_gaining_capability() { + use crate::types::zones::Zone; + let mut state = GameScenario::new_n_player(2, 7).build().state().clone(); + let battlefield = place(&mut state, 900, Zone::Battlefield); + let command = place(&mut state, 901, Zone::Command); + stand_up_target_prompt(&mut state, P0, command, 1); + let prompt = state.waiting_for.clone(); + + let order_source = object_decision_source(&state, command).expect("placed above"); + let order_pin = PinnedDecision::Order { + source: order_source.clone(), + pos: 0, + }; + // ONE template value, bound once, shared by rows R, N and N2 (the preservation + // condition above). + let order_only = DecisionTemplate { + owner: P0, + decisions: vec![order_pin.clone()], + replay: ReplayMode::Scheduled { + count: IterationCount::UntilLethal, + }, + key: DecisionGroupKey::from_sources( + std::slice::from_ref(&order_source), + DecisionKind::LoopChoice, + ), + }; + + // ── row R (delivery): the pin re-binds through the public `resolve`. ── + let resolved = crate::analysis::decision_template::resolve(&order_only, 0, &state) + .expect("row R: a command-zone Order pin re-binds to its live ability instance"); + assert!( + resolved.iter().any(|d| matches!( + d, + crate::analysis::decision_template::ConcreteDecision::Order { source, .. } + if *source == command + )), + "row R: the resolved vec carries THIS source's Order element — the delivery \ + this row exists to prove for N and N2" + ); + + // ── row N (neutrality, the trailing-`Err` half): the SAME value, same board. ── + assert!( + pinned_targets_for_source(&order_only, 0, &state, battlefield).is_err(), + "row N: an ORDER-only template still fails closed at this element reader. Row R \ + proved the internal `resolve` returned Ok on this exact value, so this Err is \ + the post-resolve fall-through — the abort MOVED, it was not avoided" + ); + + // ── row N2 (neutrality, the `find_map` half): the SAME value, cloned board. ── + let mut injector_work = state.clone(); + assert!( + inject_pinned_answer(&mut injector_work, Some(&order_only), 0, &prompt).is_err(), + "row N2: the injector beat still fails closed too — its `find_map` looks for a \ + `Targets` element and an `Order` element is not one" + ); + + // ── the mixed pair. `mixed_no_order` is `mixed` with the FIRST element dropped and + // NOTHING else changed: one `targets_pin` value, cloned into both. ── + let targets_pin = PinnedDecision::Targets { + slot: DecisionSlot { + source: object_decision_source(&state, battlefield).expect("placed above"), + index: 0, + }, + targets: vec![TargetPin::ByIdentity( + object_decision_source(&state, battlefield).expect("placed above"), + )], + }; + let mixed = DecisionTemplate { + decisions: vec![order_pin.clone(), targets_pin.clone()], + ..order_only.clone() + }; + let mixed_no_order = DecisionTemplate { + decisions: vec![targets_pin.clone()], + ..order_only.clone() + }; + + // ── row P (poisoning removed): the OTHER pin in the same template now answers. ── + let p = pinned_targets_for_source(&mixed, 0, &state, battlefield).expect( + "row P: with the Order pin re-binding and the Targets pin resolving, the seam \ + returns the Targets pin's own answer", + ); + assert_eq!( + p, + vec![ConcreteTarget::Object(battlefield)], + "row P: one field from row N (the second pin) and OPPOSITE it" + ); + + // ── row P-minus (omit-the-pin equivalence): the security row. ── + let p_minus = pinned_targets_for_source(&mixed_no_order, 0, &state, battlefield) + .expect("row P-minus: the Order-less template resolves at both revisions"); + assert_eq!( + p, p_minus, + "row P-minus: a re-binding `Order` element contributes NOTHING to this \ + consumer's answer — the equality is the discriminating assertion, and it \ + fails the moment any consumer starts reading `ConcreteDecision::Order`" + ); + } + // ───────────────────────── 5d U2 — the shape-(B) mint ───────────────────────── use crate::types::ability::ResolvedAbility; @@ -15910,6 +17945,29 @@ mod stage2_injector_tests { /// adds nothing below; (3) the total stays **37** and the partition stays **5/7/25**, so /// neither a producer nor a reader was gained or lost. Same set, one new line number ⇒ /// benign, re-baselined here. + /// + /// ⚠ **RE-ADJUDICATED BY C1 (the CR 603.5 may-answer journal), NOT RELAXED.** `37 ⇒ 38`, + /// partition `5/7/25 ⇒ 5/8/25`. The PRODUCER half is unchanged at **5** and four of the + /// five coordinates did not move at all. The `+1` READER is **`game/engine.rs:8626`** — + /// `apply_action`'s `(OptionalEffectChoice, DecideOptionalEffect)` arm, which C1 widened + /// from `{ .. }` to bind `player` and `source_id` so it can journal the answer under + /// `(DecisionSource, PlayerId)`. It READS the (cloned) `state.waiting_for` scrutinee and + /// never writes it, so it is a reader by this instrument's own rule, and it is the same + /// benign class as U4's `inject_pinned_answer` arm. Note WHY it became visible at all: + /// the instrument deliberately skips multi-line read destructures by excluding lines + /// containing `..`, and rustfmt puts `..` on the needle's own line only while the + /// pattern body is narrow — adding two bindings pushes it to the next line. The + /// exclusion is an approximation, and this is it losing one case, not a new prompt. + /// + /// The fifth producer's coordinate moved `engine.rs:11942 ⇒ :11977`, and the shift is + /// measured rather than assumed: `git diff -U0 HEAD -- game/engine.rs` has + /// exactly six hunks above it — five `+2` journal clears paired with the ring clears at + /// `:3274/:3951/:5130/:6334/:7139`, and `+25` for the reducer arm above — summing to + /// **+35**, so predicted `11942 + 35 = 11977` equals the observed coordinate exactly. + /// Identity re-established, not assumed: the line is **sha256-identical** + /// (`8a544e87…5cc7d63`) at the old coordinate in the pre-C1 tree and at the new one + /// here, and it is still inside `begin_pending_trigger_target_selection`. C1 adds no + /// line matching the needle in a producing position anywhere. #[test] fn the_cr_603_5_prompt_census_is_pinned_so_a_sixth_producer_is_a_counted_event() { /// Every `.rs` under the crate's `src`, and the `#[cfg(test)]`-attributed @@ -15960,6 +18018,15 @@ mod stage2_injector_tests { files.sort(); assert!(files.len() > 100, "reach-guard: the walker found the crate"); + // COMMENT TEXT IS NOT A CENSUS SITE — this instrument counts CODE, and it had no + // comment rule at all until now. The consequence was measured, not theorised: a doc + // comment that quoted the needle verbatim counted ITSELF, reading 39 against a pin of + // 38, and was worked around by deleting the brace from the quotation (`aa313f122`). + // That repaired one sentence and left the counter broken for the next one. The rule now + // has ONE home for the whole repository, `crate::source_census`, which the integration + // binary compiles from the same file. + use crate::source_census::code as code_of; + // The needle is ASSEMBLED so this row's own source cannot be counted by its own // instrument. `..` excludes multi-line READ destructures whose rest-pattern sits on // a later line — the inflation the raw grep suffers from. @@ -15981,20 +18048,23 @@ mod stage2_injector_tests { .replace('\\', "/"); let test_file = rel.trim_end_matches(".rs").ends_with("_tests"); for (n, line) in lines.iter().enumerate() { - if !line.contains(&needle) || line.contains("..") { + let code = code_of(line); + if !code.contains(&needle) || code.contains("..") { continue; } if test_file || spans.iter().any(|(a, b)| (*a..=*b).contains(&n)) { in_test += 1; - } else if line.contains("waiting_for = ") - || line.contains("Ok(Some(") + } else if code.contains("waiting_for = ") + || code.contains("Ok(Some(") // `install_direct_choice_frame` owns the actual // `state.waiting_for` write. Its typed prompt argument is // still a production mint, not a reader; the call sits - // within this bounded argument expression. + // within this bounded argument expression. Read through + // `code_of` as well, so prose naming the call cannot + // promote a reader to a producer. || lines[n.saturating_sub(32)..n] .iter() - .any(|prior| prior.contains(".install_direct_choice_frame(")) + .any(|prior| code_of(prior).contains(".install_direct_choice_frame(")) { producers.push(format!("{rel}:{}", n + 1)); } else { @@ -16005,7 +18075,7 @@ mod stage2_injector_tests { assert_eq!( producers.len() + readers.len() + in_test, - 37, + 38, "CR 603.5 prompt census drifted. A new PRODUCER must have its recipient bound \ somewhere — the mint's conjunct (a) covers exactly ONE of them. A new READER is \ the benign case (U4's own consumption arm was one): adjudicate it in this doc and \ @@ -16014,10 +18084,11 @@ mod stage2_injector_tests { ); assert_eq!( (producers.len(), readers.len(), in_test), - (5, 7, 25), - "the partition, not just the total: five PRODUCTION producers, seven PRODUCTION \ + (5, 8, 25), + "the partition, not just the total: five PRODUCTION producers, eight PRODUCTION \ readers (they read `state.waiting_for` and never write it — the seventh is U4's \ - `inject_pinned_answer` arm), 25 `#[cfg(test)]` lines.\nproducers={producers:#?}\n\ + `inject_pinned_answer` arm, the eighth is C1's journalling `apply_action` arm), \ + 25 `#[cfg(test)]` lines.\nproducers={producers:#?}\n\ readers={readers:#?}" ); assert_eq!( @@ -16438,28 +18509,344 @@ mod stage2_injector_tests { // card entry boundary. The producer remains byte-identical; only its coordinate moves. // // SET PRESERVATION: unchanged. Upstream adds no line matching the needle to this file and - // neither does this branch — total still 37, partition still 5/7/25. + // neither does this branch. + // + // REBASE — this coordinate has absorbed THREE independent shifts and all are folded + // here rather than each overwriting the last. From the merge base at `:12004`: + // upstream #7303 round 3: -1 (the `ReturnAsAuraTarget` resume arm's two raw + // attach calls became one call to the entering-Aura attachment authority, + // `-8 +7`, in a hunk ABOVE this producer) + // upstream #4155: +5 (seven lines for abandoned-cast finalization, less two + // removed by its deferred-resume cleanup — also entirely above this producer) + // lane C1 (CR 603.5 may-answer journal): +35 (five `+2` journal clears paired + // with the five ring clears, plus `+25` for the `DecideOptionalEffect` arm) + // No two of those hunks overlap, so the shifts compose: 12004 -1 +5 +35 = 12043. + // The value below is MEASURED in the rebased file by content digest, never computed + // from that sum; the sum is retained only as the prediction it agreed with, and it + // did agree. The offset from the enclosing fn is the control and is unchanged at 134. // - // #7303 fix round 3: `:12004 ⇒ :12003`, −1, and ONLY this entry moved. - // Re-derived, not assumed. `git diff -U0` on this file has exactly ONE hunk, - // `@@ -9943,8 +9943,7 @@` inside `apply_action` — the `ReturnAsAuraTarget` - // resume arm's two raw attach calls replaced by one call to the entering-Aura - // attachment authority plus its four-line rationale (`-8 +7`). It sits ABOVE - // this producer, and the whole-file delta is also `-1`, so nothing was - // inserted or removed below it. Predicted `12004-1` equals the observed - // coordinate exactly. IDENTITY re-established rather than assumed: the - // producer at its new coordinate is md5-identical to `a0bca5197:engine.rs` - // at its old one, and so is its ±6-line window (`4f7522fc…`) — the window is - // what discriminates here, since the same one-line mint text appears at - // several coordinates in the crate. The other four entries did not move and - // were re-read in place. SET PRESERVATION: the two asserts above this one ran - // FIRST and both fired GREEN on the run that caught this — total still 37, - // partition still 5/7/25. The change constructs no `WaitingFor` of any kind; - // it threads an attachment-legality authority through an existing call. - // #4155 adds seven lines above this producer for abandoned-cast - // finalization, while its deferred-resume cleanup removes two; - // the net +5 moves this coordinate to `:12008`. - "game/engine.rs:12012".to_string(), + // Producer identity re-established rather than assumed: the line at the new + // coordinate is byte-identical to the base's `:12004` and to upstream's `:12003` + // (`return Ok(Some(WaitingFor::OptionalEffectChoice {`), and it is still inside + // `begin_pending_trigger_target_selection`. THE OPENING BRACE IS QUOTED WHOLE + // AGAIN, and that is the point: it was dropped as a workaround because the census + // had no comment filter and this sentence counted ITSELF as the 39th hit against + // a pin of 38. Mutilating the prose repaired the sentence, not the counter — the + // next whole quotation anywhere in the crate would have broken it again. The + // counter now excludes comment text, so this line is a comment and is not a site; + // restoring the brace is what makes that repair MEASURED on the real tree rather + // than latent. Under the old rule this exact line reds the census at 39. + // + // TO BE UNAMBIGUOUS FOR THE NEXT READER: the `+1` in `apply_action`'s + // `DecideOptionalEffect` arm is a READER, NOT A SIXTH PRODUCER. It destructures the cloned `state.waiting_for` + // scrutinee to journal the answer and never assigns `state.waiting_for`; the + // producer count in this vec is still five and this branch mints no new prompt. + // + // ⚠ RE-ADJUDICATED BY C2a (the CR 608.2b target axis on the same journal), NOT + // RELAXED. `:11977 ⇒ :12052`, **+75**, and ONLY this entry moved — the three + // `effects/mod.rs` pins and `scoped_library_search.rs:452` are in files C2a does + // not touch and did not move at all, which is the set-preservation evidence. The + // total stays **38** and the partition **5/8/25**: both of those asserts ran and + // fired GREEN on the run that caught this, so no producer or reader was gained. + // The `+75` is fully accounted for by C2a's own hunks ABOVE this line, measured + // with `git diff -U0 70fcd851a -- game/engine.rs`: `-3` (`entry_publishes_pin_slots`'s + // may-slot literal collapsing into `DecisionSlot::may`), `+1` (its target-slot + // comment), `+53` (`record_trigger_target_answer` and its doc), `+6`/`-2` (the + // `DecideOptionalEffect` arm re-expressed over `DecisionSlot::may` + + // `LoopAnswerValue::May`), `+1` (`source_id` bound in the `SelectTargets` arm) and + // `+19` (both `TriggerTargetSelection` arms' journal calls and their comments) — + // summing to exactly `+75`, so predicted `11977 + 75 = 12052` equals the observed + // coordinate. EVERY OTHER HUNK IN THIS FILE IS BELOW THIS PRODUCER — row T5 and + // this comment block, both inside `mod stage2_injector_tests` — which is why the + // shift equals the sum above it exactly. (No whole-file total is quoted here on + // purpose: this comment is itself part of that total, so the number could not be + // stated without falsifying itself.) Identity + // re-established rather than assumed: the line is sha256-identical + // (`8a544e87…5cc7d63` — the SAME digest this doc already recorded above) and is + // still inside `begin_pending_trigger_target_selection` (`:11843 ⇒ :11918`, the + // same `+75`). The diff instrument discriminates: the NEW tree at the OLD + // coordinate `:11977` holds a bare `source_id,` struct-field line, which mints + // nothing. C2a adds NO line matching the needle in a producing position. + // + // ⚠ C2a FIX ROUND (round 2/3, closing an independent review's F1/F2): `:12052 ⇒ :12132`, + // **+80**. RE-ADJUDICATED BY THE ORCHESTRATOR, NOT BY THE IMPLEMENTER — the executor was + // instructed to REPORT the shift and leave the literal alone, precisely so the number could + // not be nudged until the row passed. It complied; this line is the orchestrator's. + // + // Located BY CONTENT FIRST, arithmetic afterwards as a CHECK, per the doctrine at the head of + // this log. The line whose sha256 (WITH trailing newline) is + // `8a544e878d3e77fb80391b95af8f74059540d5ce4ad6fb83559f364df5cc7d63` sits at `:12132`; that + // digest matches exactly ONE line under a whole-file scan, so the coordinate is unambiguous. + // It is still inside `begin_pending_trigger_target_selection`, which opens at `:11998` with no + // intervening `fn`. The checks, computed AFTER locating the line and never used as its source: + // `12052 + 80 = 12132` for the producer and `11918 + 80 = 11998` for the function's opening + // line — the SAME `+80`, which is what a set of hunks lying wholly above one producer requires. + // + // The `+80` is accounted for by six hunks above this producer: `+2` (`EntryPinSlots.target` + // doc), `+1` (`legal_targets` doc), `+6` (fn doc), `+37` (the forced-target withhold), `+26` + // (writer doc) and `+8` (the writer's multi-slot guard). The remaining hunks are inside + // `mod stage2_injector_tests` and therefore below it. + // + // SET PRESERVATION: unchanged, and this is the conjunct that makes the move a SHIFT rather + // than a census drift. The other four entries are byte-identical AND unmoved + // (`effects/mod.rs:6252/6329/9522`, `scoped_library_search.rs:452`) — this round touches + // neither file. The total (**38**) and partition (**5/8/25**) asserts both ran FIRST and fired + // GREEN; the panic was on the third assert alone. Withholding a published `Targets` point + // removes a DECISION POINT, not a prompt producer, so no line matching the needle is added or + // removed by this round. + // + // ⚠ C2a FIX ROUND 3 (the cap round, closing the CR 704.5a bound regression the round-2 review + // found): `:12132 ⇒ :12302`, **+170**. Orchestrator's adjudication; the executor reported the + // shift and left the literal alone, as instructed. + // + // PURELY POSITIONAL, and that is measured rather than asserted: every hunk this round adds + // sits above `engine.rs:3133` (the announcement/charging split — `entry_announces`, + // `AnnouncedTarget`/`TargetAnnouncement`/`EntryAnnouncement`, and + // `bounded_cycle_charged_targets_for_window`), and there is NO hunk between there and this + // producer. The other four entries are byte-identical AND unmoved. + // + // Located BY CONTENT FIRST, arithmetic afterwards as a CHECK. The line whose sha256 (WITH + // trailing newline) is `8a544e878d3e77fb80391b95af8f74059540d5ce4ad6fb83559f364df5cc7d63` + // sits at `:12425`, and that digest matches exactly ONE line under a whole-file scan. It is + // still inside `begin_pending_trigger_target_selection`, which opens at `:12291` with no + // intervening `fn`. Checks computed AFTER locating it: `12302 + 123 = 12425` for the producer + // and `12168 + 123 = 12291` for the function's opening line — the SAME `+123`. + // + // FOURTH re-derivation of this one coordinate (`:12052 → :12132 → :12302 → :12425`), + // and the reason it keeps moving is that it is a LINE NUMBER in the most-edited function + // of the most-edited file. Every move has been resolved BY CONTENT FIRST — the digest + // above has been this producer's identity since `a6d1a0e62` and has never itself changed + // — with arithmetic used only as a check that agrees afterwards. A coordinate re-derived + // four times to the same content is evidence the pin tracks the right line, not evidence + // the pin is fragile. + // + // SET PRESERVATION (C2a round 4): that round adds a withhold CONDITION, not a prompt + // producer. `entry_announces` reports an announcement; it does not assign + // `state.waiting_for`, so no line matching the needle is added or removed (grep-counted 0 + // on both the `+` and `-` sets). Total (38) and partition (5/8/25) both fire GREEN first; + // the panic was on the third assert alone, which is what makes it a coordinate shift + // rather than a population change. + // + // item-4 C2b (`WaitingFor::LoopShortcut.declaration`), base `1bc45bb8c`: `:12425 ⇒ :12552`, + // `+127`, and ONLY this entry moved — the other four live in `effects/` and + // `scoped_library_search.rs`, which this commit does not touch. LOCAL, not upstream, so the + // CI-vs-local diagnosis in the header does not apply. + // + // LOCATED BY CONTENT FIRST, as this log requires: the line at `:12552` is sha256-identical + // (`8a544e878d3e77fb80391b95…`, the digest this producer has carried since `a6d1a0e62`) to + // `1bc45bb8c:game/engine.rs:12425`, and it is still inside + // `begin_pending_trigger_target_selection`, which moved by the same `+127` (opens + // `:12291 ⇒ :12418`). Arithmetic afterwards as a CHECK: `git diff -U0` on this file has five + // hunks above the producer — `+4` and `+3` (the two `declaration: None` mints with their + // reasons, in `reconcile_terminal_result` and `interactive_loop_bridge`), `+5` (the mint + // wiring in `certified_bounded_cycle_offer`), `+110` (`build_bounded_declaration` and its + // doc), and `+5` (`apply_action`'s `declaration: _` discharge and its deferral note) — which + // sum to exactly `+127`. The file's remaining two hunks (`mod bounded_declaration_tests`, + // `+302`, and one `#[cfg(test)]` field, `+1`) sit BELOW it. + // + // SET PRESERVATION: this commit adds a FIELD to `WaitingFor::LoopShortcut` and one + // declaration consumer; neither assigns `state.waiting_for` to an + // `OptionalEffectChoice`, so no line matching the needle is added or removed. The total (38) + // and the partition (5/8/25) both fired GREEN on the run that caught this; the panic was on + // this third assert alone, which is what makes it a coordinate shift rather than a + // population change. + // + // item-4 C2b FIX ROUND (F1: `declaration_conforms`, the shared declare-legality + // authority), base `908720e6f`: `:12552 ⇒ :12582`, `+30`, and ONLY this entry moved — + // the other four live in `effects/` and `scoped_library_search.rs`, untouched here. + // LOCAL, not upstream, so the CI-vs-local diagnosis in the header does not apply. + // + // LOCATED BY CONTENT FIRST, as this log requires: the line at `:12582` is + // sha256-identical (`8a544e878d3e77fb80391b95…`, the digest this producer has carried + // since `a6d1a0e62`) to `908720e6f:game/engine.rs:12552`, and it is still inside + // `begin_pending_trigger_target_selection`, which moved by the same `+30` (opens + // `:12418 ⇒ :12448`). Arithmetic afterwards as a CHECK: `git diff -U0` on this file has + // five hunks above the producer — `+22` (`build_bounded_declaration`'s "PUBLISHED IS + // VALIDATED" doc section), `0` (the `Some(..)` tail rebound to `let template = ..`), + // `+10` (step (5)'s `declaration_conforms` call), `-2` (the `required` derivation + // DELETED from `handle_declare_shortcut`, now derived once inside the authority) and + // `0` (that site's condition rewritten in place) — summing to exactly `+30`. The + // file's remaining hunk (row D8 in `mod bounded_declaration_tests`, `+139`) is BELOW. + // + // SET PRESERVATION: this round adds one validation call and one `#[cfg(test)]` row; + // neither assigns `state.waiting_for` an `OptionalEffectChoice`, so no line matching + // the needle is added or removed. The total (38) and the partition (5/8/25) both fired + // GREEN on the run that caught this; the panic was on this third assert alone. + // + // ⚠ C3 (the stale-coordinate comment sweep), REBASED ONTO THE C2b FIX ROUND: + // `:12582 ⇒ :12590`, `+8`, LOCAL — measured at this tip, not carried. C3's own + // hunks above this producer are unchanged and have always summed to `+8`: `+1` in + // `shortcut_drive_period` and `+1` in `handle_declare_shortcut` (both replacing a + // measured-wrong "8 KB" WS frame cap with `phase-server`'s `MAX_WS_MESSAGE_BYTES`, + // 64 KB), `+2` on `reject_shortcut_declaration`'s doc (rotted `MagicCompRules.txt` line + // numbers dropped in favour of the CR numbers, which are the stable identifiers), and + // `+4` on `handle_decline_shortcut`'s doc (the twice-rotted `engine.rs:3006-3011` + // ring-clear coordinate replaced by a SYMBOL reference). `engine.rs`'s entire delta in + // C3 is COMMENT HUNKS and nothing else, so a comment round cannot mint a prompt. + // + // THIS ENTRY'S BASE HAS NOW BEEN RE-DERIVED SIX TIMES, and recording that is the point. + // C3 was authored against `70fcd851a` (`:11977 ⇒ :11985`); successive rebases moved its + // base to C2a's `:12052`, then `:12132`, then `:12302`, then C2a round 4's `:12425`, + // then C2b's `:12552`, and now the C2b fix round's `:12582`. Every stored number was + // correct only for the parent it was written against, and every time the CONTENT was + // unchanged. **A coordinate is a fact about a tree, not a property of this commit** — + // which is exactly why C3 replaces line coordinates with SYMBOL references everywhere + // else, and why this row's own pin is the one place that cannot take its own advice. + // + // Resolved BY CONTENT FIRST, arithmetic afterwards as a CHECK: the line whose sha256 + // (WITH trailing newline) is `8a544e878d3e77fb80391b95af8f74059540d5ce4ad6fb83559f364df5cc7d63`, + // which must match exactly ONE line under a whole-file scan and must still sit inside + // `begin_pending_trigger_target_selection` with no intervening `fn`. + // + // SET PRESERVATION (C3): unchanged. The other four entries live in `game/effects/mod.rs` + // and `game/effects/scoped_library_search.rs`, neither of which C3 touches, and a comment + // round adds no line matching the needle — total still 38, partition still 5/8/25. + // + // ⚠ item-4 R1 (the `Ranking` parameterization): `:12590 ⇒ :12575`, `-15`, LOCAL. + // Resolved BY CONTENT FIRST per the protocol above: the sha256 above matched exactly + // ONE line under a whole-file scan, at `:12575`, and the nearest preceding `fn` is + // still `begin_pending_trigger_target_selection` (`:12441`) with none intervening. + // Arithmetic CHECK afterwards: `git diff -U0` against the parent shows exactly four + // non-zero hunks above the old coordinate — `+2` and `+5` on `shortcut_drive_period`'s + // doc (Ruling B's dormancy REASON restated: the type now admits a seat subject, so the + // dormancy is a measured producer property rather than a structural one) and `-5`/`-17` + // for `slot_source_prompted`'s factoring into + // `analysis::decision_template::resolve_ability_instance` (a doc block and its two + // inlined zone arms, replaced by one delegating call and a pointer) — summing to `-15`. + // SET PRESERVATION: all four hunks are a doc block or a delegating call; none assigns + // `state.waiting_for` and none mints a prompt, and this round's remaining `engine.rs` + // hunks are inside `#[cfg(test)]` below this producer. The total (38) and the + // partition (5/8/25) both fired GREEN on the run that caught this; only this third + // assert panicked. + // + // ⚠ item-4 R2 (the seat-pin provenance split): `:12575 ⇒ :12606`, `+31`, LOCAL. + // Resolved BY CONTENT FIRST per the protocol above: the sha256 recorded there + // matched exactly ONE line under a whole-file scan, at `:12606`, and the nearest + // preceding `fn` is still `begin_pending_trigger_target_selection` (`:12472`) with + // none intervening. Arithmetic CHECK afterwards: `git diff -U0` against the parent + // shows exactly four hunks above the old coordinate — `+5` on `entry_announces`' + // withhold rationale (a comment), `+12` on `shortcut_drive_period`'s dormancy doc + // (a comment), and `+1`/`+13` inside `record_trigger_target_answer` (its `use` + // list and the `TargetRef::Player` arm re-spelled to + // `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(..))))`) — summing to + // `+31`, and `12575 + 31 = 12606` exactly. SET PRESERVATION: two of the four hunks + // are pure comment; the other two are a `use` list and ONE expression inside a + // `LoopAnswerValue::Targets` mapping, which assigns no `state.waiting_for` and + // mints no `OptionalEffectChoice` prompt. This round's remaining `engine.rs` hunks + // are all inside `#[cfg(test)] mod stage2_injector_tests`, BELOW this producer. The + // total (38) and the partition (5/8/25) both fired GREEN on the run that caught + // this — only this third assert (`:17342`) panicked. + // + // R2b (the slot-question accessor migration at the last three call sites): + // `:12606 ⇒ :12622`, +16. LOCAL, not upstream — the CI-vs-local diagnosis in the + // header does not apply. Arithmetic CHECK: `git diff -U0` against the parent has + // NINE hunks above the old coordinate, netting exactly `+16`, and + // `12606 + 16 = 12622`. SET PRESERVATION: every one of those nine is either a + // doc/comment rewrite (the CR 114.2 → CR 114.4 / CR 113.6p sweep, the two + // `pinned_*` headers, `slot_source_prompted`'s header, `bounded_cycle_pin_slots`' + // class list) or ONE of the two production call-site swaps + // (`resolve_source(&slot.source, clone) == Some(source_id)` ⇒ + // `slot_source_prompted(clone, &slot.source, source_id)`), which changes which + // predicate answers a slot question and assigns no `state.waiting_for` — it mints + // no `OptionalEffectChoice` prompt. This round's remaining `engine.rs` hunks are + // inside `#[cfg(test)] mod stage2_injector_tests`, BELOW this producer. The total + // (38) and the partition (5/8/25) both fired GREEN on the run that caught this — + // only this third assert panicked. Identity re-established rather than assumed: + // line `:12622` is byte-identical by sha256 + // (`8a544e878d3e77fb…5cc7d63`, the SAME hash this log recorded for `:11549` and + // `:11583`) to `10e80db9c:engine.rs:12606`, and it is still inside + // `begin_pending_trigger_target_selection`, which moved by the same +16 (opens + // `:12472 ⇒ :12488`). + // + // ⚠ item-4 R3 (the drive-end seam's CR 732.2a doc amendment): `:12622 ⇒ :12646`, + // `+24`. LOCAL, and a COMMENT-ONLY round. Resolved BY CONTENT FIRST per the + // protocol above: the sha256 this log already records for this producer + // (`8a544e878d3e77fb…5cc7d63`) matches EXACTLY ONE line under a whole-file scan + // of the new tree, at `:12646` — and exactly one in the parent, at `:12622` — and + // it is still inside `begin_pending_trigger_target_selection`, which moved by the + // same +24 (opens `:12488 ⇒ :12512`). Arithmetic CHECK afterwards, never as the + // source: `git diff -U0` against the parent shows exactly ONE hunk ABOVE this + // producer, `@@ -4452,0 +4453,24 @@` inside `materialize_fixed_shortcut` — the + // CR 732.2a episode-boundary amendment — and `12622 + 24 = 12646` exactly. (The + // file carries a SECOND hunk, this very comment block; it is BELOW the producer + // and so contributes nothing to the coordinate. Counting whole-file hunks instead + // of hunks-above-the-producer is the arithmetic slip to avoid here.) + // SET PRESERVATION: all 24 inserted lines are `//` comments, so no + // `waiting_for = ` or `Ok(Some(` line was added and a comment round cannot mint a + // prompt. R3's `crates/engine/src` diff is comment-only APART FROM THIS PIN + // STRING: with comment lines stripped, `analysis/decision_template.rs` is + // byte-identical to the parent and `game/engine.rs` differs in exactly one line — + // the pin literal directly below. The total (38) and the partition + // (5/8/25) both fired GREEN on the run that caught this — only this third assert + // panicked. + // + // ⚠ item-4 R3 FIX-ROUND 3 (reword of that same block's abort-entry PROBE-PINNED + // clause, which called the window "equally live" while reporting `answers=0`): + // `:12646 ⇒ :12651`, `+5`. LOCAL, COMMENT-ONLY again, same protocol: the recorded + // sha256 (`8a544e878d3e77fb…5cc7d63`, verbatim line + trailing newline) matches + // EXACTLY ONE line under a whole-file scan of the new tree, at `:12651` — and + // exactly one in the parent, at `:12646` — and it is still inside + // `begin_pending_trigger_target_selection`, which moved by the same +5 (opens + // `:12512 ⇒ :12517`). Arithmetic CHECK afterwards, never as the source: `git diff + // -U0` shows exactly ONE hunk ABOVE this producer, `@@ -4469,2 +4469,7 @@` inside + // `materialize_fixed_shortcut` (2 comment lines ⇒ 7), and `12646 + 5 = 12651`. + // The other hunk is this very block plus the pin below it — BELOW the producer, + // contributing nothing, the same slip the entry above flags. SET PRESERVATION + // holds identically: all 5 net inserted lines are `//` comments, so no + // `waiting_for = ` or `Ok(Some(` line was added, and the pin below is once more + // the ONLY non-comment line in this round's `crates/engine/src` diff. + // ⚠ RE-REBASE onto upstream `7127326673`: `:12712 ⇒ :12717`, the **+5** that + // upstream #4155 inserts above this producer (seven lines for abandoned-cast + // finalization, less two removed by its deferred-resume cleanup). LOCATED BY + // CONTENT DIGEST, never by arithmetic: the line whose sha256 is + // `8a544e87…5cc7d63` matches exactly ONE line under a whole-file scan and is + // still inside `begin_pending_trigger_target_selection`, at the invariant offset + // 134. `12712 + 5` is the CHECK that agreed, not the derivation. + // + // ⚠ item-4 C2 (the manual declare path honours the offer's own published + // declaration): `:12717 ⇒ :12759`, `+42`. LOCAL, not upstream. LOCATED BY + // CONTENT DIGEST, never by arithmetic: the line whose sha256 is + // `8a544e87…5cc7d63` — the digest this log has carried since `a6d1a0e62` — + // matches EXACTLY ONE line under a whole-file scan of the new tree, at `:12759`, + // and exactly one in the parent, at `:12717`. It is still inside + // `begin_pending_trigger_target_selection` (`:12625`) with no intervening `fn`, + // at the INVARIANT OFFSET 134 — `12759 - 12625`, and the parent's + // `12717 - 12583`. Arithmetic CHECK afterwards, never as the source: `git diff + // -U0` against the parent shows FOUR hunks, ALL above this producer — `+4` + // (`LoopShortcutOffer`'s new `declaration` field and its doc), `+35` + // (`handle_declare_shortcut`'s `or_else` and the placement rationale above it), + // `+2` net (`apply_action`'s `declaration: _` discharge rewritten as a bind, + // `-5`/`+7`) and `+1` (`declaration: declaration.as_ref(),` in the struct + // literal) — summing to exactly `+42`, and `12717 + 42 = 12759`. + // + // DERIVED TWICE, ACROSS A REBASE, AND THAT IS THE ENTRY'S POINT. This value was + // first measured pre-rebase against `b51e45c59`, then DISCARDED unused and + // re-derived from scratch against the rebased tree rather than carried — the + // discipline the entry six above states as *"a coordinate is a fact about a + // tree, not a property of this commit"*. The two derivations agreeing is a + // result, not a shortcut that was taken. (The rebase moved this file's OTHER + // stale element for us: upstream `d11529d0c` re-pinned + // `game/effects/mod.rs:9922 ⇒ :9932`, which arrived through the rebase already + // correct and is not this commit's to touch.) + // + // SET PRESERVATION: C2 adds ONE production statement (an `Option::or_else`) and + // one struct field, and rewrites a match-arm binding from `declaration: _` to a + // bind. None of the three assigns `state.waiting_for`, so no line matching the + // needle is added or removed and no `OptionalEffectChoice` prompt can be minted. + // Confirmed by the failure shape rather than by inspection alone: the total (38) + // and the partition (5/8/25) both fired GREEN on the run that caught this, and + // the panic was on this third assert alone — which is what makes it a coordinate + // shift rather than a population change. + // ⚠ REBASE onto upstream `635c51ec4` (#7382, pre-entry opponent controller): + // `:12759 ⇒ :12763`, +4 from a hunk at `apply_action` `@@ -9867,0 +9868,4 @@`, + // entirely above this producer. MEASURED in the rebased file, never computed: the + // offset from `begin_pending_trigger_target_selection` is the control and is STILL + // 134, which is what re-establishes identity — the same mint text occurs at several + // coordinates in this crate, so the offset discriminates where the text cannot. + // This rebase raised the literal as a CONFLICT twice and then drifted it SILENTLY a + // third time at the tip; only the offset control caught the silent one. That is the + // drift class FU-4 (content-hash coordinate anchor) exists to end. + "game/engine.rs:12763".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ @@ -16473,15 +18860,15 @@ mod stage2_injector_tests { let effects_src = std::fs::read_to_string(root.join("game/effects/mod.rs")) .expect("readable effects module"); let authority = format!("{}_prompt_player", "optional"); - // CODE LINES ONLY. A whole-file `matches()` also counted PROSE, and this PR's C1 adds a - // doc link to the authority in `upfront_optional_gate`'s comment — a mention that is - // neither a definition nor a call. Excluding `//` lines makes the instrument STRICTLY - // MORE specific to the thing it names (a second CALL) rather than less: the pinned - // count is unchanged at 2, and a real second call still trips it because a call cannot - // live on a comment line. + // CODE ONLY, and now the CODE HALF of each line rather than only non-comment lines: + // a whole-file `matches()` counted PROSE, and this PR's C1 adds a doc link to the + // authority in `upfront_optional_gate`'s comment — a mention that is neither a + // definition nor a call. `crate::source_census::code` is the shared rule; the pinned + // count is unchanged at 2 (re-measured), and a real second call still trips it because + // a call cannot live in comment text. let authority_code_hits = effects_src .lines() - .filter(|l| !l.trim_start().starts_with("//")) + .map(crate::source_census::code) .filter(|l| l.contains(&authority)) .count(); assert_eq!( @@ -16767,9 +19154,11 @@ mod stage2_injector_tests { /// dispatched" from "the injector returned `Ok(())` having done nothing": an empty board /// would answer `Ok(())` just as happily. /// - /// The pinned source is a BATTLEFIELD object because `resolve_source` is battlefield-only - /// (CR 400.7 incarnation binding) — on any other zone `slot_source_prompted` would refuse - /// every arm below for a reason none of them is about. + /// The pinned source is a BATTLEFIELD object because `slot_source_prompted` asks + /// `resolve_ability_instance`, whose zone set is {Battlefield, Command} at the pinned + /// CR 400.7 incarnation — on a graveyard / exile / hand source it would refuse every arm + /// below for a reason none of them is about. (A command-zone source would be admitted; + /// the battlefield one is chosen because these rows are not about the zone at all.) fn u4_may_board(asked: PlayerId) -> (GameState, ObjectId) { use crate::types::ability::{Effect, QuantityExpr, TargetFilter}; let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); @@ -17080,6 +19469,7 @@ mod stage2_injector_tests { per_cycle: None, }, schema: ShortcutDecisionSchema::default(), + declaration: None, }; } @@ -17511,7 +19901,7 @@ mod kilo_interruptibility_tests { /// combo-interruptibility-acceptance-criterion). A declined `Counters`/`Life` axis leaves its /// ∞ capability marker in `unbounded_resources` intentionally (CR 732.2b never forces a /// shortcut). This test guards the MEASURED retirement path (a) documented at the boundary - /// seam: the empty-stack offer hook `try_offer_object_growth_shortcut` (engine.rs:472) is NOT + /// seam: the empty-stack offer hook `try_offer_object_growth_shortcut` is NOT /// gated by existing ∞ marks, so a later genuine re-detection RE-OFFERS the loop and can /// re-collapse the declined axis once the observer is gone. /// @@ -18366,11 +20756,13 @@ mod bounded_offer_conjunct_tests { /// Code lines (comments excluded, per R8's ruling: a comment reads nothing) of an extent /// that contain `needle`, as absolute line indices. + /// + /// "Comments excluded" means the shared `crate::source_census::code` rule — whole-line AND + /// trailing — not a private `starts_with("//")` test. #[cfg(test)] fn engine_code_hits(lines: &[&str], extent: (usize, usize), needle: &str) -> Vec { (extent.0..=extent.1) - .filter(|i| !lines[*i].trim_start().starts_with("//")) - .filter(|i| lines[*i].contains(needle)) + .filter(|i| crate::source_census::code(lines[*i]).contains(needle)) .collect() } @@ -18800,9 +21192,9 @@ mod bounded_offer_conjunct_tests { .replace('\\', "/"); let test_file = rel.trim_end_matches(".rs").ends_with("_tests"); for (n, line) in lines.iter().enumerate() { - if line.trim_start().starts_with("//") { - continue; - } + // The shared comment rule, not a private one: comment text declares no + // predicate and calls none. + let line = crate::source_census::code(line); if test_file || spans.iter().any(|(a, b)| (*a..=*b).contains(&n)) { continue; } diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 08d81b18bc..581e480b4a 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -10715,14 +10715,10 @@ mod tests { // EXIT-AXIS BINDING — the half the two assertions above cannot supply, because they compare // `possible_hold` against a transcription of itself. Counting unit and decomposition are the // ones stated on `BoundaryHold`: 4 control-flow statements = 1 push + 2 item-level non-push - // + 1 inner per-growth skip. Comment lines are stripped so prose ABOUT `continue`/`return` - // cannot inflate the count. - let code: String = boundary_apply_loop_region() - .lines() - .map(str::trim_start) - .filter(|line| !line.starts_with("//")) - .collect::>() - .join("\n"); + // + 1 inner per-growth skip. `crate::source_census::code_lines` is the shared rule: + // whole-line AND trailing comment text removed, so prose ABOUT `continue`/`return` + // cannot inflate the count from either position. + let code: String = crate::source_census::code_lines(boundary_apply_loop_region()); // The counters read raw text, and a string literal is not a comment, so one carrying the // word `break` (or a `?`) would be counted as control flow — a red no reader could act on. // There are none in the loop today; keep it that way, or teach the counters to skip them. diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index ff13948e27..2f638e1b70 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -14280,11 +14280,11 @@ mod characteristic_read_classification_tests { let mut carriers = Vec::new(); let mut current: Option<&str> = None; for line in body.lines() { + // Doc comments name `ControllerRef` in prose; they declare nothing — and neither + // does a TRAILING comment on a field line, which the shared + // `crate::source_census::code` rule removes too. + let line = crate::source_census::code(line); let trimmed = line.trim_start(); - // Doc comments name `ControllerRef` in prose; they declare nothing. - if trimmed.starts_with("//") { - continue; - } // A variant header is the only thing at one indent level that opens // with an uppercase letter; its fields sit one level deeper. if let Some(header) = line diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 30ebeadbc1..633c15b219 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -13,8 +13,8 @@ use crate::ai_support::{ FilterPipeline, TacticalClass, }; use crate::analysis::decision_template::{ - predictability_gate, validate_pins, DecisionGroupKey, DecisionKind, DecisionTemplate, - IterationCount, PinnedDecision, ReplayMode, TargetPin, + declaration_conforms, AnnouncementSubject, DecisionGroupKey, DecisionKind, DecisionTemplate, + IterationCount, PinnedDecision, Ranking, ReplayMode, TargetPin, TargetSchedule, }; use crate::types::ability::{ AggregateFunction, ChoiceType, ChooseFromZoneConstraint, Comparator, CounterCostSelection, @@ -49,7 +49,8 @@ use crate::types::interaction::{ InteractionRelationConstraint, InteractionRelationSourceConstraint, InteractionResponse, InteractionResponseSpec, InteractionRoleCode, InteractionSessionId, InteractionShortcutCountSpec, InteractionShortcutDecision, InteractionShortcutPoint, - InteractionShortcutPointKind, InteractionShortcutReply, InteractionShortcutResponseCode, + InteractionShortcutPointKind, InteractionShortcutPreview, InteractionShortcutPreviewEntry, + InteractionShortcutPreviewFamily, InteractionShortcutReply, InteractionShortcutResponseCode, InteractionSlotKind, InteractionSubmission, InteractionSummaryCode, InteractionWaitingForCode, InteractionWaitingForKind, InteractionZoneCode, SelectionConstraint, SimultaneousDecisionKind, ViewerInteraction, MAX_INTERACTION_LIST_LEN, @@ -63,6 +64,7 @@ use crate::types::player::PlayerId; use crate::types::zones::Zone; use super::combat::AttackTarget; +use super::derived_views::{family_of, payload_seat, UnboundedFamily}; use super::dungeon::DungeonId; use super::engine::{ apply_interaction, apply_interaction_for_simulation, EngineError, MAX_SHORTCUT_CYCLES, @@ -1115,6 +1117,7 @@ struct LoopShortcutPointProjection { #[derive(Debug, Clone)] struct LoopShortcutProjection { count: InteractionShortcutCountSpec, + preview: Option, points: Vec, candidates: Vec, } @@ -2430,12 +2433,96 @@ fn number_projection(waiting_for: &WaitingFor) -> Option { } } +/// Rename `game::derived_views::UnboundedFamily` into its projection-layer code. +/// +/// A pure rename on purpose: `family_of` stays the SINGLE authority for which axis groups +/// into which family, so this function makes no grouping decision of its own and cannot +/// drift from it. Exhaustive with no wildcard — a new family must choose a code here. +/// +/// Mirrors `comparator_dto` above: the engine owns the fact, and the projection layer owns +/// the name it crosses the wire under. +fn preview_family(family: UnboundedFamily) -> InteractionShortcutPreviewFamily { + match family { + UnboundedFamily::Mana => InteractionShortcutPreviewFamily::Mana, + UnboundedFamily::Life => InteractionShortcutPreviewFamily::Life, + UnboundedFamily::Damage => InteractionShortcutPreviewFamily::Damage, + UnboundedFamily::Mill => InteractionShortcutPreviewFamily::Mill, + UnboundedFamily::Counters => InteractionShortcutPreviewFamily::Counters, + UnboundedFamily::Tokens => InteractionShortcutPreviewFamily::Tokens, + UnboundedFamily::Cards => InteractionShortcutPreviewFamily::Cards, + UnboundedFamily::Casts => InteractionShortcutPreviewFamily::Casts, + UnboundedFamily::Combats => InteractionShortcutPreviewFamily::Combats, + UnboundedFamily::Turns => InteractionShortcutPreviewFamily::Turns, + UnboundedFamily::Triggers => InteractionShortcutPreviewFamily::Triggers, + } +} + +/// CR 732.2a: the finished magnitude of repeating `count` cycles of a measured per-period +/// delta — "the predictable results of the sequence of choices", stated per display family +/// and per affected seat. +/// +/// **This is arithmetic over the certificate's `per_cycle.delta`, and nothing else.** It +/// applies no game action, resolves nothing, and touches no `GameState`: the multiplication +/// `n × δ` is the whole computation. In particular it is NOT +/// `interaction::preview_interaction`, which answers a different question (is this response +/// submittable) by cloning the state and applying to the clone. A clone-apply cannot answer +/// this one anyway — the count may be up to `MAX_SHORTCUT_CYCLES`, and the point of a CR +/// 732.2a shortcut is that the sequence is *not* played out. +/// +/// The fold is over families, not axes: `ResourceVector` distinguishes mana by color and +/// counters by `(kind, bearer class)`, and summing those into one labelled magnitude per seat +/// is the aggregation the display layer is forbidden to do for itself. Losses are included +/// (signed), which is why this reads `axis_components()` rather than `unbounded_components()` — +/// the latter reports only what a cycle accrues, so a lethal drain would preview as nothing. +/// +/// ponytail: magnitudes clamp to `i32`, so a per-cycle delta above ~2.1M would be reported +/// short. No such delta exists — one period's delta is a difference of two game-state +/// readings — and `i32` is exact in the JS number the binding generates, which `i64` is not. +fn shortcut_preview_entries( + delta: &crate::analysis::resource::ResourceVector, + count: u32, +) -> Vec { + let mut per_cycle_totals: BTreeMap<(InteractionShortcutPreviewFamily, Option), i64> = + BTreeMap::new(); + for (axis, magnitude) in delta.axis_components() { + // Both halves of the key are `derived_views`' decisions, not this layer's: `family_of` + // owns the grouping and `payload_seat` owns the seat. The seat in particular is NOT + // keyed from the proposer — a drain's magnitude belongs to the player LOSING the life + // — and sharing the authority with `attribution_player` is what keeps the offer from + // attributing a seat the HUD badge does not. + let key = ( + preview_family(family_of(axis)), + payload_seat(axis).map(|player| player.0), + ); + let total = per_cycle_totals.entry(key).or_insert(0); + *total = total.saturating_add(magnitude); + } + per_cycle_totals + .into_iter() + .filter_map(|((family, player), per_cycle)| { + // Families that cancel to zero across their axes (a cycle that gains and spends + // the same mana) state nothing and are dropped rather than shown as `0`. + let amount = per_cycle.saturating_mul(i64::from(count)); + (amount != 0).then_some(InteractionShortcutPreviewEntry { + family, + player, + amount: amount.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32, + }) + }) + .collect() +} + fn loop_shortcut_projection( waiting_for: &WaitingFor, ) -> Result { use crate::analysis::decision_template::DecisionPointKind; - let WaitingFor::LoopShortcut { schema, .. } = waiting_for else { + let WaitingFor::LoopShortcut { + schema, + certificate, + .. + } = waiting_for + else { return Err(InteractionReasonCode::UnsupportedResponse); }; if schema.points.len() > MAX_INTERACTION_LIST_LEN { @@ -2514,6 +2601,35 @@ fn loop_shortcut_projection( InteractionShortcutCountSpec::UntilLethal } }; + // CR 732.2a: state what the offer's own count DOES, so the picker's number carries its + // consequence instead of standing alone. Two authorities have to agree before there is + // anything to state, and both are the offer's own: + // + // * `per_cycle` — published only by the producer that measured a per-period signature + // (`certified_bounded_cycle_offer`). Every other mint carries `None`, and so does + // every save written before the field existed. + // * a FINITE count — `UntilLethal` names no number to multiply by. It is the + // determinate-drain mode, where the count is the drain's own arithmetic, not a + // player's choice. + // + // Those two coincide by construction rather than by luck: the bounded producer is the + // one that both narrows `max_iterations` and mints `Fixed(max_iterations)`, so a preview + // exists exactly on the offers whose count is worth picking. + // + // `suggested` is the stated count, and the ONLY count these magnitudes describe — which + // is why it travels with them in `InteractionShortcutPreview.count` rather than being + // left for a renderer to assume. + let preview = match (&count, &certificate.per_cycle) { + (InteractionShortcutCountSpec::Fixed { suggested, .. }, Some(periodic)) => { + let entries = shortcut_preview_entries(&periodic.delta, *suggested); + (!entries.is_empty()).then_some(InteractionShortcutPreview { + count: *suggested, + entries, + }) + } + (InteractionShortcutCountSpec::Fixed { .. }, None) + | (InteractionShortcutCountSpec::UntilLethal, _) => None, + }; let mut candidates = Vec::new(); let mut points = Vec::with_capacity(schema.points.len()); for point in &schema.points { @@ -2667,6 +2783,7 @@ fn loop_shortcut_projection( } Ok(LoopShortcutProjection { count, + preview, points, candidates, }) @@ -7019,6 +7136,7 @@ fn opportunity_for_slot( count: projection.count, points, allow_decline: true, + preview: projection.preview.clone(), confirm: ConfirmSemantics::Explicit, }, candidates, @@ -7831,7 +7949,9 @@ fn bound_outbound_spec( } } } - InteractionResponseSpec::Shortcut { points, .. } => { + InteractionResponseSpec::Shortcut { + points, preview, .. + } => { budget.list(points.len())?; for point in points { budget.list(point.candidate_ids.len())?; @@ -7839,6 +7959,12 @@ fn bound_outbound_spec( budget.string(candidate_id.as_str())?; } } + // The preview's entries are a published outbound list like every other + // list on this spec (at most one per display family per seat), so they are charged + // to the same ceiling rather than crossing uncounted. + if let Some(preview) = preview { + budget.list(preview.entries.len())?; + } } InteractionResponseSpec::Select { .. } | InteractionResponseSpec::AssignAmounts { .. } @@ -8829,8 +8955,18 @@ fn materialize_loop_shortcut_response( let targets = candidate_indices .iter() .map(|index| match &projection.candidates[*index] { + // CR 601.2c: the HUMAN ingress of the SAME point kind, so it emits + // the SAME spelling as the engine's own producer + // (`game::engine::record_trigger_target_answer`). A candidate on a + // `Targets` point is an announced TARGET, so the seat is judged by + // CR 702.11c hexproof / CR 702.18a shroud / CR 702.16b protection + // through the announcement-subject arm — never by existence alone. + // Emitting `TargetPin::Player` here instead would select the + // authority by WHO SUBMITTED the answer rather than by WHAT IT IS. LoopShortcutCandidateValue::Target(TargetRef::Player(player)) => { - Ok(TargetPin::Player(*player)) + Ok(TargetPin::Scheduled(TargetSchedule::Constant( + Ranking::one(AnnouncementSubject::Seat(*player)), + ))) } LoopShortcutCandidateValue::Target(TargetRef::Object(object_id)) => { let object = authoritative_state @@ -8919,26 +9055,30 @@ fn materialize_loop_shortcut_response( key: DecisionGroupKey::from_sources(&sources, DecisionKind::LoopChoice), }); if let Some(template) = &template { - let required = authoritative_schema - .points - .iter() - .map(|point| point.slot.clone()) - .collect::>(); // TRAP REMOVAL, NOT A BUG FIX — recorded so the next reader does not "correct" this // literal into `shortcut_validated_range(..)` and then wonder what changed. This - // decoder emits only `Player` and `ByIdentity` pins, both of which resolve - // INDEPENDENTLY of `iteration`, so validating at index 0 alone is correct by - // construction here: a wider range would re-resolve the same pin to the same value. + // decoder emits only ITERATION-INVARIANT pins, so validating at index 0 alone is + // correct by construction here: a wider range would re-resolve the same pin to the + // same value. That is the property doing the work, and it is stated as the property + // rather than as a list of variant names — the list has already moved once. Today + // the emitted set is `ByIdentity` (never reads `iteration` at all) and + // `Scheduled(TargetSchedule::Constant(..))`, whose arm in + // `decision_template::evaluate_schedule` selects its `Ranking` without consulting + // `iter` (unlike the `RoundRobin` / `Piecewise` arms beside it, which this decoder + // does not emit). Emitting a genuinely iteration-VARYING pin here would invalidate + // the literal, not just this comment. // It is also strictly weaker than the declare-path firewall rather than a second // hole — `1` is a prefix of any range that path validates. It cannot mint a // `Fixed(0)` either: the count-spec projection's `Fixed` arm hard-codes `min: 1` // beside its `debug_assert!(schema.max_iterations >= 1, ..)` and its clamp. // ⚠ Navigation trap: `shortcut_drive_period`'s doc enumerates its own consumers, and - // this site consumes `validate_pins` WITHOUT consuming that helper, so it is + // this site consumes the pin firewall WITHOUT consuming that helper, so it is // invisible from there. - if predictability_gate(template, &required).is_err() - || validate_pins(authoritative_schema, template, 1, authoritative_state).is_err() - { + // + // The `required` slot list is no longer derived here: `declaration_conforms` derives + // it from the SAME `authoritative_schema` this site already passed, so the coverage + // half and the value half can no longer drift apart per call site. + if !declaration_conforms(authoritative_schema, template, 1, authoritative_state) { return Err(InteractionReasonCode::ConstraintUnsatisfied); } } @@ -9456,3 +9596,141 @@ pub fn submit_interaction( )?; Ok(AppliedInteraction { action, result }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// F4 — the preview's entry list is budgeted like every other outbound list on the + /// shortcut spec. + /// + /// `bound_outbound_spec` counted `points` and each point's `candidate_ids` but not + /// `preview.entries`, so the one list added by the CR 732.2a preview crossed the boundary + /// uncounted. It is bounded small in practice (at most one entry per display family per + /// seat), so this is a CONSISTENCY row and not a live payload-exhaustion row — which is + /// why it drives the budget to its last free slot rather than building a giant preview. + /// + /// PAIRED CONTROL FIRST: the same spec at the same starting budget WITHOUT a preview must + /// fit. Without it, the failure below could come from the spec's other lists, or from a + /// budget that was already over before the preview was ever read. + /// + /// WHAT WRONG IMPLEMENTATION WOULD STILL PASS THIS ROW? One that budgets the preview's + /// entries but not a future second list added to the same spec — the row pins the field it + /// names, not "every field is budgeted". One that charged the entries to the STRING budget + /// instead would fail here, because the control proves the LIST budget is what moved. + /// + /// REVERT-PROBE, RUN: drop the `preview` budget call ⇒ the second assertion gets `Ok`. + #[test] + fn the_shortcut_preview_entry_list_is_counted_against_the_outbound_budget() { + let spec = |preview| InteractionResponseSpec::Shortcut { + count: InteractionShortcutCountSpec::Fixed { + min: 1, + max: 3, + suggested: 3, + }, + points: Vec::new(), + allow_decline: true, + preview, + confirm: ConfirmSemantics::Explicit, + }; + let preview = InteractionShortcutPreview { + count: 3, + entries: vec![ + InteractionShortcutPreviewEntry { + family: InteractionShortcutPreviewFamily::Life, + player: Some(1), + amount: -6, + }, + InteractionShortcutPreviewEntry { + family: InteractionShortcutPreviewFamily::Mana, + player: None, + amount: 9, + }, + ], + }; + let at_last_free_slot = || OutboundBudget { + entries: MAX_INTERACTION_LIST_LEN - 1, + string_bytes: 0, + }; + + let mut budget = at_last_free_slot(); + assert!( + bound_outbound_spec(&spec(None), &mut budget).is_ok(), + "control: with one slot free and no preview, this spec's own lists fit — so the \ + refusal below is the preview being counted, not the spec being oversized" + ); + + let mut budget = at_last_free_slot(); + assert_eq!( + bound_outbound_spec(&spec(Some(preview)), &mut budget), + Err(InteractionReasonCode::PayloadTooLarge), + "CR 732.2a: the preview's entries are published outbound, so they are charged to \ + the same ceiling as every other list on the spec" + ); + } + + /// F5 — the offer channel and the HUD channel must SPELL each display family identically. + /// + /// `InteractionShortcutPreviewFamily` is `rename_all = "camelCase"`; its grouping authority + /// `derived_views::UnboundedFamily` is `rename_all = "lowercase"`. All eleven variants are + /// single words today, so both spell `mana`, `life`, ... and the agreement reads as design + /// when it is coincidence. A future two-word family would cross as `extraTurns` on the + /// offer and `extraturns` on the HUD — one grouping published in two wire vocabularies, + /// and THIS row is what catches it. Nothing on the client does: no client code reads the + /// preview's `family` today (the generated `InteractionShortcutPreviewFamily` in + /// `client/src/adapter/generated/interaction/index.ts` has no consumer), and the HUD's + /// family-keyed lookups (`UNBOUNDED_FAMILY_GLYPH` / `UNBOUNDED_FAMILY_LABEL_KEY`, both + /// `Record` in `client/src/components/hud/HudBadges.tsx`) are keyed by + /// the SEPARATELY declared hand-written `UnboundedFamily` union — so a future consumer that + /// crossed the two would break as a TypeScript type error, not miss silently. + /// + /// `preview_family`'s exhaustive match pins the GROUPING, not the STRING — a new family + /// build-breaks it, a renamed WIRE STRING does not. This row pins the string. + /// + /// It takes its family list from `unbounded-family-tags.json`, the same golden the client's + /// `Record` is checked against, so one chain now runs + /// engine grouping ⇒ HUD string ⇒ offer string. That also makes the list forced rather than + /// hand-maintained: an 18th `ResourceAxis` reds `family_tag_table_matches_the_client_golden` + /// until the golden is regenerated, and a regenerated golden carries the new family here. + /// + /// THIS ROW PASSES TODAY BY CONSTRUCTION, AND THAT IS THE POINT — it is written to fail on + /// a two-word variant, which is the only way the divergence can ship. + /// + /// WHAT WRONG IMPLEMENTATION WOULD STILL PASS THIS ROW? One that mis-GROUPS an axis (that + /// is `family_tag_table_matches_the_client_golden`'s question, not this one), and one that + /// adds a family reachable from no `ResourceAxis` at all, which the golden cannot see and + /// no client lookup can receive. + /// + /// REVERT-PROBE, RUN: rename the `Turns` variant of BOTH enums to `ExtraTurns` and + /// regenerate the golden ⇒ `extraturns` vs `extraTurns` ⇒ this row FAILS. + #[test] + fn every_preview_family_spells_the_same_wire_string_as_its_unbounded_family() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../client/src/test/fixtures/unbounded-family-tags.json" + ); + let golden: BTreeMap = + serde_json::from_str(&std::fs::read_to_string(path).expect("committed family golden")) + .expect("the family golden parses as tag -> UnboundedFamily"); + let families: std::collections::BTreeSet = + golden.values().copied().collect(); + assert_eq!( + families.len(), + 11, + "reach-guard: every display family must be reachable from the golden, else this \ + row silently checks a subset of the wire surface" + ); + + for family in families { + let hud = serde_json::to_string(&family).expect("UnboundedFamily serializes"); + let offer = + serde_json::to_string(&preview_family(family)).expect("preview family serializes"); + assert_eq!( + hud, offer, + "the shortcut offer and the HUD badge must cross the wire under the SAME \ + string for this display family — the client keys both into one \ + `Record`, so a divergence is a silent lookup miss" + ); + } + } +} diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 7734860a56..725c008bc1 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -8709,7 +8709,7 @@ pub(crate) fn filter_consumed_trigger_events( /// CR 724 end-the-turn / end-the-combat-phase EFFECTS, and by elimination /// — NOT by the turn boundary. /// * R2 — deserialized states bypass the recorder. -/// `PersistedGameState::into_game_state` (`types/game_state.rs:9024`) +/// `PersistedGameState::into_game_state` (`types/game_state.rs`) /// reconstructs `ZoneChanged` straight into live buffers with /// `#[serde(default)]` indices, so a restored state can carry index `0` on /// distinct occurrences. Pre-existing and out of scope here. diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 0f293a0bf6..5c91ce0d87 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -97,6 +97,119 @@ pub(crate) fn capture_library_search_card_view( } } +/// CR 732.2b: the responder's right is to name a place where they will make "a choice that's +/// different than what's been proposed", so the proposal they see must be the whole proposal or +/// none of it. A partially-redacted pin set is a LIE about what was proposed — it would show a +/// shortened sequence the proposer never suggested — so this is ALL-OR-NOTHING: one pin naming an +/// object this viewer may not see drops the entire pin vector. +/// +/// THE SINGLE AUTHORITY for that decision. It is keyed on `[PinnedDecision]` rather than on +/// `DecisionTemplate` because this engine has THREE viewer-visible carriers of that same vector, +/// and a per-carrier copy of the predicate is exactly what let them drift before: +/// +/// 1. `WaitingFor::LoopShortcut.declaration.decisions` — the proposer-facing offer. +/// 2. `WaitingFor::RespondToShortcut.proposal.template.decisions` — the responder-facing copy. +/// `game::engine::handle_declare_shortcut` moves the identical template verbatim onto +/// `ShortcutProposal.template` one state transition later, where every responder and spectator +/// reads it. +/// 3. `GameState::last_loop_action_sequence[].pins` — the recorded loop period. It is serialized +/// whenever non-empty (`skip_serializing_if = "Vec::is_empty"`, not `skip`) and has no other +/// redaction seam. Its three writers (the `game::engine::record_loop_pin` call sites: a +/// mana-ability tap cost, a mana-color choice, a proliferate target) can only name battlefield +/// permanents and seats today, so that call redacts nothing on any board the engine currently +/// mints — it is wired so a fourth writer cannot open the leak silently. +/// +/// `GameState::decision_templates` is the fourth carrier and deliberately does NOT route here: it +/// is redacted wholesale by the private-access retain +/// (`filtered.decision_templates.retain(|t| can_view_private_for_player(t.owner))` — CR 723.4, the +/// SAME predicate carriers 1 and 2 apply), so a template this viewer may not privately view is +/// REMOVED entirely and there is nothing left for this predicate to answer about it. +/// +/// A SEAT needs no redaction, and that is an ENGINE property rather than a CR one — no rule makes +/// seat identity public. This projection hides card identities and hidden-zone contents; the seat +/// list itself is never per-viewer filtered (`filtered.players[..]` is redacted in place, never +/// removed), so a `PlayerId` names something every viewer already has. CR 115.2 is cited for the +/// narrower thing it actually says: a spell or ability may target a player when it specifies so, +/// which is what makes a seat a legal pin value at all. +/// +/// STATED ABOUT THE SEAT RATHER THAN ABOUT ONE SPELLING, because a seat now has two of them: +/// `TargetPin::Player` (the CR 115.10a CHOICE class) and `AnnouncementSubject::Seat` inside a +/// `Ranking` (the CR 601.2c TARGET class). Redaction asks "does this name an identity this viewer +/// may not see", a question the CHOICE/TARGET split does not bear on at all — which is why both +/// arms below answer `false` for this ONE reason rather than two. It is also why the split cannot +/// quietly open a leak here: the `AnnouncementSubject` match is wildcard-free, so a future subject +/// kind that DOES name a hidden identity gets a compile error instead of a `false`. +/// +/// `target_hidden` is passed in rather than re-derived so that the declaration's object identities +/// and the offer schema's legal targets are answered by ONE hidden-info authority; two derivations +/// could disagree about the same object. +fn pins_name_hidden_source( + pins: &[crate::analysis::decision_template::PinnedDecision], + target_hidden: &dyn Fn(ObjectId) -> bool, +) -> bool { + use crate::analysis::decision_template::{ + AnnouncementSubject, DecisionSource, PinnedDecision, Ranking, TargetPin, TargetSchedule, + }; + let source_hidden = |source: &DecisionSource| match source { + crate::types::game_state::YieldTarget::ThisObject { source_id, .. } => { + target_hidden(*source_id) + } + // A card identity, not a live object: it names no zone occupant to hide. + crate::types::game_state::YieldTarget::AllCopies { .. } => false, + }; + // A `Scheduled` step carries a whole `Ranking`, so the walk descends one level further + // than the pin: EVERY subject in every step is inspected, not just the head the current + // episode would resolve. The tail is a pre-declaration the responder receives now (it is + // part of the proposal they accept or shorten under CR 732.2b), so a hidden identity in + // the tail is a leak on exactly the same footing as one in the head. + // + // Wildcard-free over `AnnouncementSubject`: a future subject kind gets a compile-time + // visit here. The `Seat` arm is `false` for the SEAT reason given above — seat identity is + // public in this engine — which is stated about the seat rather than about either spelling + // precisely so both arms can cite it once. + let subject_hidden = |subject: &AnnouncementSubject| match subject { + AnnouncementSubject::Object(source) => source_hidden(source), + AnnouncementSubject::Seat(_) => false, + }; + let ranking_hidden = |ranking: &Ranking| ranking.iter().any(&subject_hidden); + let pin_hidden = |pin: &TargetPin| match pin { + TargetPin::ByIdentity(source) => source_hidden(source), + TargetPin::Player(_) => false, + TargetPin::Scheduled(schedule) => match schedule { + TargetSchedule::Constant(ranking) => ranking_hidden(ranking), + TargetSchedule::RoundRobin(rankings) => rankings.iter().any(&ranking_hidden), + TargetSchedule::Piecewise(steps) => { + steps.iter().any(|(_, ranking)| ranking_hidden(ranking)) + } + }, + }; + // Wildcard-free over `PinnedDecision`, so a future variant that carries an object + // identity gets a compile-time visit here instead of leaking silently. + // + // `slot` is deliberately not inspected, and the honest reason is PER-CARRIER rather than + // global. Carrier 1 co-publishes the identical `DecisionSlot` unredacted as + // `schema.points[].slot` (the `LoopShortcut` arm below), so redacting it here would hide + // nothing that arm hands over anyway. Carriers 2 and 3 publish NO schema, and for them the + // claim is narrower and measured rather than structural: every `DecisionSlot.source` this + // engine mints today is either `YieldTarget::AllCopies { card_id }` (a card identity, which + // occupies no zone) or a `ThisObject` that `game::engine::object_decision_source` built from + // a stack object or a battlefield permanent — never a hand/library occupant. That is a + // property of today's PRODUCERS (`build_recast_template` and the `record_loop_pin` call + // sites feed carriers 2/3; a proposer-declared template's slots come from the offer schema, + // whose sources are stack entries), not of `DecisionSlot` itself, whose constructors accept + // any object. A producer that slots a hidden-zone source would leak it through carriers 2/3, + // and `source_hidden` above is already the function to call on `slot.source` when one exists. + pins.iter().any(|pin| match pin { + PinnedDecision::Targets { targets, .. } => targets.iter().any(&pin_hidden), + PinnedDecision::Order { source, .. } => source_hidden(source), + PinnedDecision::Mode { .. } + | PinnedDecision::MayChoice { .. } + | PinnedDecision::UnlessBreak { .. } + | PinnedDecision::ConvokeTaps { .. } + | PinnedDecision::ManaColor { .. } => false, + }) +} + /// Returns a filtered copy of the game state for the given viewer. /// Hides all opponents' hand contents and all library contents except where the /// viewer is explicitly allowed to see them. @@ -736,6 +849,22 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState } } + // A target object is hidden from this viewer iff it sits in a private zone whose + // owner the viewer can't privately view AND it isn't otherwise revealed/peeked. + // Hoisted above the CR 732.2a/b blocks below because all THREE pin carriers + // (`LoopShortcut.declaration`, `RespondToShortcut.proposal.template`, + // `last_loop_action_sequence[].pins`) must answer "may this viewer see that object?" the + // same way; a per-arm copy is what let the first two drift apart. + let target_hidden = |id: ObjectId| -> bool { + state.objects.get(&id).is_some_and(|obj| { + matches!(obj.zone, Zone::Hand | Zone::Library) + && !can_view_private_for_player(obj.owner) + && !is_visible_revealed_card(state, viewer, id) + && !state.viewer_knows_card_identity(viewer, id) + && !private_look_visible.contains(&id) + }) + }; + // CR 732.2a: redact hidden-info legal targets in a `LoopShortcut` OFFER for a viewer who is // NOT the schema's proposer. The schema is built for the offer's public declaration; this // is the SOLE seam that removes a hidden-zone (hand/library) legal target from a viewer who @@ -748,6 +877,7 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState predicted_winner, ref certificate, ref schema, + ref declaration, } = state.waiting_for { if !can_view_private_for_player(proposer) { @@ -755,17 +885,6 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState DecisionPoint, DecisionPointKind, ShortcutDecisionSchema, }; use crate::types::ability::TargetRef; - // A target object is hidden from this viewer iff it sits in a private zone whose - // owner the viewer can't privately view AND it isn't otherwise revealed/peeked. - let target_hidden = |id: ObjectId| -> bool { - state.objects.get(&id).is_some_and(|obj| { - matches!(obj.zone, Zone::Hand | Zone::Library) - && !can_view_private_for_player(obj.owner) - && !is_visible_revealed_card(state, viewer, id) - && !state.viewer_knows_card_identity(viewer, id) - && !private_look_visible.contains(&id) - }) - }; let points: Vec = schema .points .iter() @@ -829,10 +948,18 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState _ => None, }) .sum(); + // CR 732.2b, ALL-OR-NOTHING: one pin naming an object this viewer may not see drops + // the entire declaration. The predicate itself is `pins_name_hidden_source` (this + // file), the single authority shared with the `RespondToShortcut` projection below, + // which receives this very template verbatim one state transition later. + let declaration = declaration + .clone() + .filter(|template| !pins_name_hidden_source(&template.decisions, &target_hidden)); filtered.waiting_for = WaitingFor::LoopShortcut { proposer, predicted_winner, certificate: certificate.clone(), + declaration, schema: ShortcutDecisionSchema { iteration_count: schema.iteration_count.clone(), // CR 732.2a: the count bound is derived from PUBLIC board state (life, @@ -847,6 +974,37 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState } } + // CR 732.2b: the RESPONDER-facing copy of the very declaration redacted above. + // `game::engine::handle_declare_shortcut` moves the proposer's template verbatim onto + // `ShortcutProposal.template` and installs it here, so without this arm every identity the + // `LoopShortcut` block drops is public to every responder and spectator one transition later. + // Same authority, same all-or-nothing: the template is dropped whole, never trimmed. + // + // Guarded on the PROPOSER's private access (`proposal.proposer`), not on the responder + // (`player`), because the offer's declaration is the proposer's hidden information and every + // seat but theirs — the current responder, the queued ones, and spectators — receives this + // same projection. + if let WaitingFor::RespondToShortcut { proposal, .. } = &mut filtered.waiting_for { + if !can_view_private_for_player(proposal.proposer) + && proposal + .template + .as_ref() + .is_some_and(|t| pins_name_hidden_source(&t.decisions, &target_hidden)) + { + proposal.template = None; + } + } + + // CR 732.2a: the THIRD carrier of the same pin vector — the recorded loop period, which + // serializes whenever non-empty and has no other redaction seam. All-or-nothing per recorded + // step, for the reason spelled on `pins_name_hidden_source`: a half-shown period states a + // sequence that was never played. + for step in &mut filtered.last_loop_action_sequence { + if pins_name_hidden_source(&step.pins, &target_hidden) { + step.pins.clear(); + } + } + if let WaitingFor::DigChoice { player, library_owner, @@ -1437,7 +1595,17 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState filtered .may_trigger_auto_choices .retain(|record| record.key.player == viewer); - filtered.decision_templates.retain(|t| t.owner == viewer); + // CR 723.4: "If information about an object in the game would be visible to the player + // being controlled, it's visible to both that player and the player controlling them." + // The pin vector's other carriers already answer "may this viewer see it" with this same + // predicate (the `LoopShortcut` and `RespondToShortcut` blocks above), so carrier 4 uses + // it too rather than a strict owner-equality that would deny a controlling player a + // template they are entitled to. Absent turn control (and absent a latched + // search-decision authority) this is exactly owner-equality — see + // `turn_control::authorized_submitter_for_player` for the full arm set. + filtered + .decision_templates + .retain(|t| can_view_private_for_player(t.owner)); filtered.priority_yields.retain(|y| y.player == viewer); filtered .lands_tapped_for_mana @@ -2800,7 +2968,13 @@ mod tests { } /// CR 603.3b: saved trigger-ordering templates are per-player private preference - /// state — a viewer sees only their own, never an opponent's saved orderings. + /// state — a viewer sees only the ones they may privately view. + /// + /// The REASON is CR 723.4 private access, not opponent-ness: a player controlling another + /// player is normally an opponent and DOES see the controlled seat's templates (row R1-j + /// asserts that direction). This board has no turn control and no latched search-decision + /// authority, so `can_view_private_for_player` is exactly owner-equality here, which is + /// why this row is unmodified by the unification. #[test] fn filters_other_players_decision_templates() { use crate::analysis::decision_template::{ @@ -2838,6 +3012,97 @@ mod tests { assert_eq!(filtered.decision_templates[0].owner, PlayerId(0)); } + /// **Row R1-j — carrier 4 answers "may this viewer see it" with the SAME predicate as + /// carriers 1 and 2.** CR 723.4: "If information about an object in the game would be + /// visible to the player being controlled, it's visible to both that player and the + /// player controlling them." A controlling player is normally an opponent, so strict + /// owner-equality denied them a template they are entitled to — while the + /// `LoopShortcut` / `RespondToShortcut` carriers directly above already used + /// `can_view_private_for_player`. One question, one predicate. + /// + /// # Non-vacuity / discrimination + /// + /// BOTH directions ride ONE instrument on ONE board: turn control in effect ⇒ RETAINED; + /// the identical board with the control record removed ⇒ DROPPED. Without the second + /// half the change would read as "everyone now sees everything". The shipped row + /// `filters_other_players_decision_templates` above is the third guard: a plain + /// non-owner with no control still loses the template, and that row is unmodified. + /// + /// REVERT-PROBE: restore `retain(|t| t.owner == viewer)` ⇒ the controller loses the + /// controlled seat's template ⇒ the RETAINED assertion FAILS, while the DROPPED + /// assertion and the shipped row stay green. + #[test] + fn r1j_a_controlling_player_sees_the_controlled_seats_decision_template() { + use crate::analysis::decision_template::{ + DecisionGroupKey, DecisionKind, DecisionTemplate, PinnedDecision, ReplayMode, + }; + use crate::types::game_state::YieldTarget; + + let controller = PlayerId(0); + let controlled = PlayerId(1); + let src = YieldTarget::AllCopies { + card_id: CardId(100), + trigger_description: None, + }; + let template = DecisionTemplate { + owner: controlled, + decisions: vec![PinnedDecision::Order { + source: src.clone(), + pos: 0, + }], + replay: ReplayMode::Static, + key: DecisionGroupKey::from_sources(&[src], DecisionKind::TriggerOrdering), + }; + + let mut state = GameState::new_two_player(42); + state.set_trigger_order_template(template); + assert_eq!( + state.decision_templates.len(), + 1, + "reach-guard: the unprojected state really carries the template" + ); + + // ── DROPPED: no turn control, viewer != owner (the pre-unification behaviour, which + // the unification preserves) ── + assert!( + filter_state_for_viewer(&state, controller) + .decision_templates + .is_empty(), + "with no control in effect a non-owner still loses it — the predicate did not \ + become a pass-through" + ); + + // ── RETAINED: the SAME board with `controller` taking `controlled`'s turn. The + // control is scoped to the ACTIVE player's decisions — see + // `turn_control::effective_authority_for_player`, which is the authority the + // reach-guard below reads rather than restating. ── + let mut controlled_state = state.clone(); + controlled_state.active_player = controlled; + controlled_state.turn_decision_controller = Some(controller); + assert_eq!( + turn_control::authorized_submitter_for_player(&controlled_state, controlled), + controller, + "reach-guard: the control record really is in effect, so the retain below is \ + keyed to CR 723.4 and not to the fixture" + ); + let projected = filter_state_for_viewer(&controlled_state, controller); + assert_eq!( + projected.decision_templates.len(), + 1, + "CR 723.4: the controlling player sees the controlled seat's template" + ); + assert_eq!(projected.decision_templates[0].owner, controlled); + + // And the controlled player still sees their own — the widening is additive. + assert_eq!( + filter_state_for_viewer(&controlled_state, controlled) + .decision_templates + .len(), + 1, + "the owner never lost their own copy" + ); + } + /// CR 117.3d: priority yields are private preference state — a viewer sees /// only their own, never an opponent's. #[test] @@ -5835,4 +6100,404 @@ mod tests { assert_eq!(back.name, "Back Face"); assert_eq!(back.printed_ref, Some(back_ref)); } + + // ── item-4 C2b row D5-h — the offer's own `declaration` never crosses the viewer boundary + // carrying an object identity the viewer may not see ── + + const D5H_PROPOSER: PlayerId = PlayerId(0); + const D5H_VIEWER: PlayerId = PlayerId(1); + + /// One `LoopShortcut` offer whose declaration carries whatever `decisions` builds from the + /// HIDDEN card's id and the fixture's slot. The card sits in the PROPOSER's hand, so the + /// non-proposer viewer cannot privately view its owner and `target_hidden` answers `true` + /// for it. + /// + /// EVERY arm of D5-h and D5-h/2 mints through here, so the fixture spells the + /// `WaitingFor::LoopShortcut` anchor exactly once (this is a counted site in + /// `tests/integration/loop_shortcut_offer_writer_census.rs`, which pins the per-file + /// multiset — a second literal in this file would fail that row). + fn d5h_offer_decisions( + decisions: impl FnOnce( + ObjectId, + &crate::analysis::decision_template::DecisionSlot, + ) -> Vec, + ) -> GameState { + use crate::analysis::decision_template::{ + DecisionGroupKey, DecisionKind, DecisionPoint, DecisionPointKind, DecisionSlot, + DecisionTemplate, IterationCount, ReplayMode, ShortcutDecisionSchema, + }; + let mut state = GameState::new_two_player(42); + let hidden = create_object( + &mut state, + CardId(4242), + D5H_PROPOSER, + "Secret Card".to_string(), + Zone::Hand, + ); + let slot = DecisionSlot::target(crate::types::game_state::YieldTarget::ThisObject { + source_id: ObjectId(777), + incarnation: Some(1), + trigger_description: None, + }); + state.waiting_for = WaitingFor::LoopShortcut { + proposer: D5H_PROPOSER, + predicted_winner: None, + certificate: crate::analysis::loop_check::LoopCertificate { + unbounded: vec![], + win_kind: crate::analysis::loop_check::WinKind::LethalDamage, + mandatory: false, + residual_board_delta: crate::analysis::resource::BoardDelta::default(), + per_cycle: None, + }, + schema: ShortcutDecisionSchema { + iteration_count: IterationCount::Fixed(3), + max_iterations: 3, + points: vec![DecisionPoint { + slot: slot.clone(), + kind: DecisionPointKind::Targets { + legal_targets: vec![crate::types::ability::TargetRef::Player(D5H_VIEWER)], + min_targets: 1, + max_targets: 1, + ordered: false, + }, + }], + convoke_tappable_count: 0, + }, + declaration: Some(DecisionTemplate { + owner: D5H_PROPOSER, + decisions: decisions(hidden, &slot), + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(3), + }, + key: DecisionGroupKey::from_sources(&[slot.source], DecisionKind::LoopChoice), + }), + }; + state + } + + /// The ONE-pin shorthand D5-h uses: a single `PinnedDecision::Targets` carrying `pins`. + fn d5h_offer( + pins: impl FnOnce(ObjectId) -> Vec, + ) -> GameState { + use crate::analysis::decision_template::PinnedDecision; + d5h_offer_decisions(|hidden, slot| { + vec![PinnedDecision::Targets { + slot: slot.clone(), + targets: pins(hidden), + }] + }) + } + + /// The declaration AS PROJECTED for `viewer`. Both arms of D5-h read through here, so the + /// read also spells the census anchor exactly once. + fn d5h_projected_declaration( + state: &GameState, + viewer: PlayerId, + ) -> Option { + match filter_state_for_viewer(state, viewer).waiting_for { + WaitingFor::LoopShortcut { declaration, .. } => declaration, + other => panic!("the fixture parks on the CR 732.2a offer, got {other:?}"), + } + } + + /// **Row D5-h — a `ByIdentity` pin naming a hidden object drops the WHOLE declaration for a + /// non-proposer viewer.** + /// + /// CR 732.2b gives each other player the right to "shorten [the proposal] by naming a place + /// where they will make a game choice that's different than what's been proposed" — so what + /// they receive must be the whole proposal or none of it. A partially-redacted pin set would + /// show a sequence the proposer never suggested, which is why this is ALL-OR-NOTHING rather + /// than a per-pin filter. A `TargetPin::Player` travels unredacted because seat identity is + /// public IN THIS ENGINE — no CR rule states that, and the redaction comment says so; CR + /// 115.2 only establishes that a seat can be a targeted (hence pinnable) value. + /// + /// # This path is UNREACHABLE through today's publisher, and the row says so + /// + /// `record_trigger_target_answer` mints `ByIdentity` only for `TargetRef::Object`, and the + /// bounded publisher's own conjuncts (`TargetAnnouncement::Chosen`, and player-valued legal + /// sets on every tracked board — measured `any ByIdentity pin? false` on all five drives) + /// keep object pins out of a published slot today. The row exists so a publisher relaxation + /// cannot silently open the leak; it is not evidence that the leak is live. + /// + /// # Non-vacuity + /// + /// The all-`Player` arm is the paired positive from the SAME fixture one pin apart: a + /// redactor that dropped EVERY declaration would satisfy the hidden arm and fail this one. + /// The proposer's own projection is asserted too, so "drop it for everybody" fails twice. + /// + /// REVERT-PROBE: make the redaction's `TargetPin::ByIdentity(_)` arm answer `false` (pass + /// through unfiltered) ⇒ the hidden arm's `is_none()` flips while both positives stay green. + /// + /// *What wrong implementation would still pass this row?* One that redacts the declaration + /// but leaks the same identity through `schema.points` — that surface has its own row, + /// `loop_shortcut_schema_redacts_hidden_targets_for_non_controller`. + #[test] + fn d5h_a_hidden_object_pin_drops_the_whole_declaration_for_a_non_proposer() { + use crate::analysis::decision_template::TargetPin; + + // ── the hidden arm ── + let hidden_state = d5h_offer(|hidden| { + vec![ + TargetPin::ByIdentity(crate::types::game_state::YieldTarget::ThisObject { + source_id: hidden, + incarnation: Some(1), + trigger_description: None, + }), + TargetPin::Player(D5H_VIEWER), + ] + }); + // Reach-guards: the UNPROJECTED offer really carries a declaration (else `is_none()` + // below would be satisfied by a fixture that never had one), and the viewer really is a + // non-proposer (else the redaction block never runs at all). + assert!( + d5h_projected_declaration(&hidden_state, D5H_PROPOSER).is_some(), + "reach-guard + positive: the PROPOSER's own projection keeps the declaration, so the \ + drop below is keyed to the viewer boundary rather than to the fixture" + ); + assert_ne!(D5H_VIEWER, D5H_PROPOSER); + assert!( + d5h_projected_declaration(&hidden_state, D5H_VIEWER).is_none(), + "CR 732.2b: one pin naming an object this viewer may not see drops the ENTIRE \ + declaration — a partial pin set would state a proposal that was never made" + ); + + // ── the paired positive: every pin is a seat, which carries no hidden identity ── + let public_state = d5h_offer(|_hidden| vec![TargetPin::Player(D5H_VIEWER)]); + assert_eq!( + d5h_projected_declaration(&public_state, D5H_VIEWER), + d5h_projected_declaration(&public_state, D5H_PROPOSER), + "an all-seat declaration is public and reaches the opponent UNCHANGED — without this \ + arm a redactor that dropped everything would pass the hidden arm above" + ); + assert!( + d5h_projected_declaration(&public_state, D5H_VIEWER).is_some(), + "and it is genuinely present, not two matching `None`s" + ); + } + + /// **Row D5-h/2 — the ACROSS-PIN axis: a declaration whose FIRST pin is public and whose + /// SECOND names a hidden object still drops WHOLE.** + /// + /// D5-h above and both integration rows build a ONE-element `decisions` vector, and on a + /// one-element vector `pins.iter().any(..)` and `pins.iter().all(..)` are the same function — + /// so the PIN-LEVEL quantifier of `pins_name_hidden_source` was discriminated by no row in the + /// tree (measured: flipping the OUTER `any` to `all` left lib and integration fully green). + /// CR 732.2b is all-or-nothing across the WHOLE pin set, not within one pin: a declaration + /// that survives because only *some* of its pins name hidden objects states a proposal that + /// was never made. + /// + /// # The multi-pin shape is the ORDINARY production shape, not an exotic one + /// + /// `game::engine::record_loop_pin` appends up to three pins onto ONE `LoopActionContext.pins` + /// in temporal order — a mana-ability tap-cost `Targets` pin (`index: 0`), a `ManaColor` pin + /// (`index: 1`), then a proliferate `Targets` pin — and `game::engine::build_recast_template` + /// clones that very vector (`decisions = ctx.pins.clone()`) into the offer's declaration + /// before pushing a `ConvokeTaps` pin. A public pin sitting ahead of a hidden one is therefore + /// exactly what those producers mint; this row builds `[ManaColor, Targets{hidden}]`, i.e. + /// pins 2 and 3 of that production sequence. + /// + /// # Non-vacuity / discrimination + /// + /// The PUBLIC pin is FIRST, so an implementation that stops at the first pin — `all(..)`, or a + /// `decisions.first()` peek — keeps the declaration and fails the negative below. Paired + /// positives: the proposer's own projection keeps it, and an all-public TWO-pin declaration + /// reaches the non-proposer unchanged, so a redactor that dropped every multi-pin declaration + /// fails here. The pin count and the first pin's variant are asserted on the projected + /// proposer copy, so a fixture that silently built one pin (or a hidden first pin) cannot + /// satisfy the negative for the wrong reason. + /// + /// REVERT-PROBE (measured both directions, `item4-run/t-r0-fold/REPORT.md`): outer + /// `pins.iter().any` -> `.all` in `pins_name_hidden_source` ⇒ this row FAILS while every other + /// row in `game::visibility::tests` stays green; restored ⇒ it passes. + #[test] + fn d5h2_a_public_pin_ahead_of_a_hidden_one_still_drops_the_whole_declaration() { + use crate::analysis::decision_template::{PinnedDecision, TargetPin}; + use crate::types::mana::ManaColor; + + // ── the hostile arm: pin 1 carries no identity, pin 2 names the hidden hand card ── + let hidden_state = d5h_offer_decisions(|hidden, slot| { + vec![ + PinnedDecision::ManaColor { + slot: slot.clone(), + color: ManaColor::Blue, + }, + PinnedDecision::Targets { + slot: slot.clone(), + targets: vec![TargetPin::ByIdentity( + crate::types::game_state::YieldTarget::ThisObject { + source_id: hidden, + incarnation: Some(1), + trigger_description: None, + }, + )], + }, + ] + }); + let proposer_copy = d5h_projected_declaration(&hidden_state, D5H_PROPOSER) + .expect("reach-guard + positive: the PROPOSER's own projection keeps the declaration"); + assert_eq!( + proposer_copy.decisions.len(), + 2, + "reach-guard: the fixture really carries TWO pins — `any` and `all` are the same \ + function on a one-pin vector, which is why this row exists" + ); + assert!( + matches!(proposer_copy.decisions[0], PinnedDecision::ManaColor { .. }), + "reach-guard: the FIRST pin carries no hidden identity, so a check that stops at \ + `decisions[0]` must look further to answer correctly" + ); + assert!( + d5h_projected_declaration(&hidden_state, D5H_VIEWER).is_none(), + "CR 732.2b: ONE pin naming an object this viewer may not see drops the ENTIRE \ + declaration, however many public pins precede it" + ); + + // ── the paired positive: the SAME two-pin shape with no hidden identity travels whole ── + let public_state = d5h_offer_decisions(|_hidden, slot| { + vec![ + PinnedDecision::ManaColor { + slot: slot.clone(), + color: ManaColor::Blue, + }, + PinnedDecision::Targets { + slot: slot.clone(), + targets: vec![TargetPin::Player(D5H_VIEWER)], + }, + ] + }); + assert_eq!( + d5h_projected_declaration(&public_state, D5H_VIEWER), + d5h_projected_declaration(&public_state, D5H_PROPOSER), + "a two-pin declaration with no hidden identity reaches the opponent UNCHANGED — \ + without this arm a redactor that dropped every multi-pin declaration would pass the \ + negative above" + ); + assert!( + d5h_projected_declaration(&public_state, D5H_VIEWER).is_some(), + "and it is genuinely present, not two matching `None`s" + ); + } + + /// **Row R1-k — the WITHIN-RANKING axis: a public subject ahead of a hidden one inside + /// ONE `Scheduled` pin still drops the WHOLE declaration.** + /// + /// This is `d5h2`'s shape one level further down. `d5h2` is a public *pin* ahead of a + /// hidden one; this is a public *subject* ahead of a hidden one inside a single pin — + /// which the `Ranking` parameterization newly makes possible. The redaction walk must + /// descend into every subject of every step, not stop at the head the current episode + /// would resolve: the tail is a pre-declaration the responder receives NOW, as part of + /// the proposal CR 732.2b lets them accept or shorten, so a hidden identity there leaks + /// on exactly the same footing as one in the head. + /// + /// # Coverage this row creates rather than repeats + /// + /// `TargetPin::Scheduled` has exactly ONE occurrence in this file — the production arm + /// inside `pins_name_hidden_source` — and zero in its tests, so before this row NOTHING + /// in the tree failed for either mutation below. + /// + /// # Non-vacuity / discrimination + /// + /// The PUBLIC subject is FIRST, so a walk that reads only `head()` keeps the declaration + /// and fails the negative. Paired positives: the proposer's own projection keeps it in + /// the hidden arm, and an ALL-PUBLIC two-subject ranking on the same board reaches the + /// non-proposer unchanged — so a redactor that dropped every ranked declaration fails + /// here. The head's publicness is asserted structurally on the proposer's copy, so a + /// fixture that silently built a hidden head cannot satisfy the negative for the wrong + /// reason. + /// + /// REVERT-PROBES: (a) walk only `ranking.head()` instead of `iter()` ⇒ the hidden TAIL is + /// never seen ⇒ the declaration survives for the non-proposer ⇒ FAILS; (b) write + /// `AnnouncementSubject::Object(_) => false` (mirroring the `Seat => false` line directly + /// above it) ⇒ FAILS. Both leave every other row in this module green. + /// + /// This row mints through `d5h_offer_decisions` and reads through + /// `d5h_projected_declaration`, so it adds NO new `WaitingFor::LoopShortcut {` literal — + /// `tests/integration/loop_shortcut_offer_writer_census.rs` pins this file's production + /// multiset at 2 and would red on a third. + #[test] + fn r1k_a_public_subject_ahead_of_a_hidden_one_in_a_ranking_still_drops_the_declaration() { + use crate::analysis::decision_template::{ + AnnouncementSubject, PinnedDecision, Ranking, TargetPin, TargetSchedule, + }; + use crate::types::game_state::YieldTarget; + + // A card identity occupies no zone, so `source_hidden` answers `false` for it by an + // explicit production arm — a head that is public BY RULE, not by absence. + let public_subject = AnnouncementSubject::Object(YieldTarget::AllCopies { + card_id: CardId(4242), + trigger_description: None, + }); + let ranked_pin = + |ranking: Ranking, slot: &crate::analysis::decision_template::DecisionSlot| { + vec![PinnedDecision::Targets { + slot: slot.clone(), + targets: vec![TargetPin::Scheduled(TargetSchedule::Constant(ranking))], + }] + }; + + // ── the hostile arm: subject 1 is public, subject 2 names the hidden hand card ── + let hidden_state = d5h_offer_decisions(|hidden, slot| { + let ranking = Ranking::new(vec![ + public_subject.clone(), + AnnouncementSubject::Object(YieldTarget::ThisObject { + source_id: hidden, + incarnation: Some(1), + trigger_description: None, + }), + ]) + .expect("two distinct subjects"); + ranked_pin(ranking, slot) + }); + let proposer_copy = d5h_projected_declaration(&hidden_state, D5H_PROPOSER) + .expect("reach-guard + positive: the PROPOSER's own projection keeps the declaration"); + match &proposer_copy.decisions[0] { + PinnedDecision::Targets { targets, .. } => { + match &targets[0] { + TargetPin::Scheduled(TargetSchedule::Constant(ranking)) => { + assert_eq!( + ranking.iter().count(), + 2, + "reach-guard: the ranking really carries TWO subjects — on a \ + one-subject ranking `head()` and `iter()` are the same function, \ + which is why this row exists" + ); + assert_eq!( + ranking.head(), + &public_subject, + "reach-guard: the HEAD carries no hidden identity, so a walk that \ + stops at the head must look further to answer correctly" + ); + } + other => panic!("the fixture pins a Constant ranking, got {other:?}"), + }; + } + other => panic!("the fixture pins one Targets decision, got {other:?}"), + } + assert!( + d5h_projected_declaration(&hidden_state, D5H_VIEWER).is_none(), + "CR 732.2b: ONE subject naming an object this viewer may not see drops the ENTIRE \ + declaration, however many public subjects precede it in the ranking" + ); + + // ── the paired positive: the SAME two-subject shape with no hidden identity ── + let public_state = d5h_offer_decisions(|_hidden, slot| { + let ranking = Ranking::new(vec![ + public_subject.clone(), + AnnouncementSubject::Seat(D5H_VIEWER), + ]) + .expect("two distinct subjects"); + ranked_pin(ranking, slot) + }); + assert_eq!( + d5h_projected_declaration(&public_state, D5H_VIEWER), + d5h_projected_declaration(&public_state, D5H_PROPOSER), + "a two-subject ranking with no hidden identity reaches the opponent UNCHANGED — \ + without this arm a redactor that dropped every ranked declaration would pass the \ + negative above" + ); + assert!( + d5h_projected_declaration(&public_state, D5H_VIEWER).is_some(), + "and it is genuinely present, not two matching `None`s" + ); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 93a368bbe4..b5d2a798ac 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,12 @@ pub mod util; #[cfg(test)] mod test_support; +// The shared comment rule for every source census in this repository. Also compiled into the +// integration binary through a `#[path]` declaration in `tests/integration/main.rs`, so the two +// venues share ONE implementation rather than `test_support.rs`'s twin-sync PAIR. +#[cfg(test)] +mod source_census; + // Re-export `im` so downstream crates can construct persistent containers // without declaring their own dependency. Keeps the backing-container choice // (im vs rpds vs dashmap) centralized here. diff --git a/crates/engine/src/source_census.rs b/crates/engine/src/source_census.rs new file mode 100644 index 0000000000..4560fff170 --- /dev/null +++ b/crates/engine/src/source_census.rs @@ -0,0 +1,320 @@ +#![cfg(test)] +//! THE SINGLE AUTHORITY ON "WHICH PART OF THIS LINE IS CODE", for every guard test in this +//! repository that counts needles in Rust source. +//! +//! # Why this file exists at all +//! +//! The engine carries a family of source censuses — tests that read `.rs` text and pin how many +//! times a construct is written, so that a producer added or deleted without adjudication is a +//! counted event. Every one of them had its OWN comment policy, and they disagreed: some rejected +//! a line only when the WHOLE line opened with `//`, one had no comment rule at all. Both shapes +//! fail in the direction that matters. A needle written after a trailing `//` was counted as a +//! real site, so: +//! +//! * a pure PROSE edit moved a pinned multiset with no code change (measured: a doc comment that +//! quoted a census's own needle counted ITSELF, reading 39 against a pin of 38); and +//! * deleting a real construction while naming the same spelling in a trailing comment HELD the +//! count — the substitution class those censuses exist to catch. +//! +//! The first repair of that 39-vs-38 failure edited the PROSE (dropping a brace from a +//! quotation) rather than the counter. That fixed one sentence and left the next author to +//! rediscover the bug. Seven counters each carrying a private comment policy IS the defect; this +//! module is the producer-level fix, and +//! [`tests::no_source_reading_file_carries_a_private_comment_policy`] is what stops an eighth +//! private policy from appearing silently. +//! +//! # Venue +//! +//! `src/` unit tests reach this as `crate::source_census`; the integration binary reaches THE +//! SAME FILE through a `#[path]` module declaration in `tests/integration/main.rs`. That is +//! deliberate, and it is why the module is `#![cfg(test)]` rather than `pub`: `cfg(test)` holds +//! in both venues (an integration target is built with `--test`), so one source file serves both +//! without shipping guard infrastructure in a release build. The repo's older answer to this +//! problem is `test_support.rs`'s "TWIN-SYNC: keep the fixture path here in lockstep with +//! `tests/integration/support.rs`" — two copies and a comment asking a human to keep them equal. +//! This module is the shape that does not need the comment. +//! +//! Each venue compiles its own instance, so [`tests`] runs in both. That is a feature, not +//! duplication to remove: the copy is validated where it is used. + +/// The byte range of `line` that is CODE: a LEADING `/* … */` comment and a TRAILING `//` comment +/// are excluded. +/// +/// Both exclusions only ever REMOVE text, and each is guarded so that it cannot remove code: +/// +/// * The leading form fires only when the TRIMMED line starts with `/*`, and drops exactly up to +/// and including the first `*/` — so `/*count=*/ state.last_created_token_ids.push(id)` keeps +/// its call. +/// * The trailing form fires only when no `"` precedes the `//` in the remaining code — so +/// `let u = "http://x"; state.last_created_token_ids.push(id);` keeps its call. A naive +/// `split("//")` truncates at the URL and UNDER-counts, silently dropping a real site, which is +/// the one direction a census must never fail in. +/// +/// Offsets rather than a substring, because a caller may need to index back into the ORIGINAL +/// line (`battlefield_entry_authority_census::classify_container_tails` resolves a call tail from +/// an absolute offset). [`code`] is the substring form and is what most callers want. +/// +/// Whatever survives both guards — a `//` after a quote, a `/* … */` opened mid-line, a string +/// literal quoting the defect, or the interior lines of a multi-line block comment — is still +/// scanned, and would ADD a hit. That direction is fail-CLOSED: spurious red, never a missed +/// site. What is closed here are the two shapes a census's own prose is most likely to take. +pub(crate) fn code_span(line: &str) -> (usize, usize) { + let trimmed = line.trim_start(); + let mut lo = 0usize; + if trimmed.starts_with("/*") { + let start = line.len() - trimmed.len(); + lo = match line[start..].find("*/") { + Some(end) => start + end + 2, + None => line.len(), + }; + } + let mut hi = line.len(); + if let Some(slash) = line[lo..].find("//") { + if !line[lo..lo + slash].contains('"') { + hi = lo + slash; + } + } + (lo, hi) +} + +/// The CODE half of one line — [`code_span`] applied. A whole-line comment yields `""`, so a +/// caller needs no separate `starts_with("//")` rule. +pub(crate) fn code(line: &str) -> &str { + let (lo, hi) = code_span(line); + &line[lo..hi] +} + +/// A whole source text with every line's comment half removed, line structure preserved. +/// +/// For the censuses that count over a REGION rather than per line. Derived from [`code`] so the +/// region form and the line form cannot drift apart. +pub(crate) fn code_lines(src: &str) -> String { + src.lines().map(code).collect::>().join("\n") +} + +#[cfg(test)] +mod tests { + use super::{code, code_lines, code_span}; + + /// THE discrimination arm for the shared rule — one test on the authority, rather than a + /// near-duplicate in each of its callers. + /// + /// SYNTHETIC INPUT BY NECESSITY, not by preference. Measured when this rule was extracted: no + /// line in any walked root carried a needle after a trailing `//`, so every census that scans + /// the real tree passes byte-identically with the rule and without it. A repo-scanning + /// assertion for this property is vacuous BY CONSTRUCTION; only planted input separates the + /// two rules. + /// + /// # The arms, and what each is under the whole-line-only rule this replaced + /// + /// 1. SUBSTITUTION, the arm that matters and therefore asserted FIRST — the construction is + /// DELETED and only a trailing-comment mention survives: 0, not 1. Under the old rule a + /// census counted a removed producer as still present. + /// 2. INFLATION — a real construction plus a mention of the same spelling in a trailing + /// comment on that line: 1, not 2. Under the old rule a pure PROSE edit moved a pinned + /// multiset with no code change. + /// 3. FAIL-CLOSED — a `//` inside a STRING LITERAL preceding a real construction: 1, not 0. + /// A naive `split("//")` truncates there and UNDER-counts. + /// 4. Whole-line and leading-block comments, and the offsets contract [`code_span`] owes its + /// one offset-taking caller. + #[test] + fn the_code_half_rule_separates_a_trailing_comment_from_the_code_beside_it() { + // Assembled, so this test's own source cannot be counted by a census that walks `src/`. + let needle = format!("{}::{}(", "TargetPin", "Player"); + let count = |line: &str| code(line).matches(&needle).count(); + + assert_eq!( + count(&format!(" let a = 0; // was {needle}PlayerId(0))")), + 0, + "SUBSTITUTION: the construction is gone and only a trailing-comment mention \ + survives, so the count must fall to 0. Counting the whole line makes this 1 — a \ + DELETED producer reported as still present." + ); + assert_eq!( + count(&format!( + " let a = {needle}PlayerId(0)); // also {needle}PlayerId(9))" + )), + 1, + "INFLATION: a needle in a trailing comment is prose; the code half holds exactly ONE \ + construction. Counting the whole line makes this 2." + ); + assert_eq!( + count(&format!( + " let u = \"http://x\"; let a = {needle}PlayerId(0));" + )), + 1, + "FAIL-CLOSED: a `//` inside a string literal is not a comment opener. A naive \ + `split(\"//\")` truncates at the URL and reports 0 — a real construction silently \ + dropped." + ); + + assert_eq!( + count(&format!(" // {needle}PlayerId(0))")), + 0, + "whole-line comment" + ); + assert_eq!( + count(&format!(" /// {needle}PlayerId(0))")), + 0, + "doc comment" + ); + assert_eq!( + count(&format!("/*n=*/ let a = {needle}PlayerId(0));")), + 1, + "a LEADING block comment is dropped up to `*/` and the code after it survives" + ); + + // The offsets contract: `code` is exactly the span, and the span indexes the ORIGINAL + // line, which is what the one offset-taking caller relies on. + let line = format!(" let a = {needle}PlayerId(0)); // tail"); + let (lo, hi) = code_span(&line); + assert_eq!(&line[lo..hi], code(&line), "the substring form IS the span"); + assert!(hi < line.len(), "the trailing comment is outside the span"); + + // The region form is the line form, applied line-wise. + assert_eq!( + code_lines(&format!("let a = 1; // {needle}\n// {needle}\nlet b = 2;")), + "let a = 1; \n\nlet b = 2;", + "code_lines preserves line structure and strips both comment shapes" + ); + } + + /// THE PRODUCER GUARD — no file that reads Rust source may carry its own comment policy. + /// + /// Sweeping the seven known counters fixes seven instances and leaves the eighth author to + /// re-invent the bug. This row is what makes the change a producer fix: a NEW file that reads + /// `.rs` text and counts in it must either route through this module or be adjudicated into + /// [`EXEMPT`] with a stated reason. Neither is possible to do silently. + /// + /// The population predicate is mechanical, not a judgement: a file READS RUST SOURCE if it + /// `include_str!`s a `.rs` path, or walks for `.rs` files (`rs_files(` / an `== "rs"` + /// extension test). Every such file must carry a path-qualified `source_census::` call or be + /// EXEMPT. + /// + /// Both halves of the predicate read the file through [`code_lines`] — this census obeys its + /// own rule, so a file that merely QUOTES `include_str!("x.rs")` or the marker in prose is + /// neither pulled into the population nor credited with routing. + /// + /// # Discrimination + /// + /// Delete the `source_census::` import from any routed census ⇒ that file is neither routed + /// nor exempt ⇒ this row reds naming it. Verified by revert-probe on + /// `loop_shortcut_seat_pin_census.rs`. + #[test] + fn no_source_reading_file_carries_a_private_comment_policy() { + /// Files that read Rust source but do NOT count needles in code, each with the measured + /// reason. An entry here is an ADJUDICATION, not a suppression: it says "this file's + /// matching is not the comment-blind counting class", and the reason has to survive a + /// reader who disagrees. + const EXEMPT: &[(&str, &str)] = &[ + ( + "src/bin/rules_audit.rs", + "INVERSE POLARITY BY DESIGN: it scans ONLY comment lines, because CR annotations \ + live in comments. Stripping comments would leave it nothing to read.", + ), + ( + "tests/integration/cr_annotations.rs", + "Same inverse polarity: the asserted subject IS comment text (CR annotations).", + ), + ( + "tests/integration/loop_shortcut.rs", + "Parses with `syn` rather than by substring, so comments are excluded by \ + construction — the stronger instrument this module approximates.", + ), + ( + "src/game/ability_scan.rs", + "SECOND WAVE, disclosed not routed: whole-FILE boolean signals \ + (`src.contains(..)`), not per-line counts. Same comment-blind class, different \ + shape; `code_lines` is the routing tool when it is adjudicated.", + ), + ( + "src/game/turns.rs", + "SECOND WAVE, disclosed not routed: region slices of a source string.", + ), + ( + "src/parser/oracle_condition.rs", + "SECOND WAVE, disclosed not routed: whole-file `body.contains(..)` family probe.", + ), + ( + "tests/integration/interaction_contract.rs", + "SECOND WAVE, disclosed not routed: whole-file signature/arithmetic probes.", + ), + ( + "tests/integration/deterministic_game_state_serde.rs", + "SECOND WAVE, disclosed not routed: parses serde ATTRIBUTE regions, not needles.", + ), + ( + "tests/integration/no_top_level_test_binaries.rs", + "SECOND WAVE, disclosed not routed: collects `mod` registrations from main.rs.", + ), + ]; + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let mut files: Vec = Vec::new(); + let mut stack = vec![root.join("src"), root.join("tests/integration")]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("read {dir:?}: {e}")) { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + files.sort(); + assert!(files.len() > 100, "reach-guard: the walk found the crate"); + + // Assembled so this file's own text cannot satisfy the predicate it defines. + let walks_rs = [format!("rs_files{}", '('), format!("== {}rs{}", '"', '"')]; + // THE ROUTING MARKER IS THE PATH-QUALIFIED CALL, `source_census::`, NOT the bare module + // name — measured, after the bare name gave a FALSE PASS. `source_census` is already an + // unrelated engine identifier: `ability_rw.rs` declares a `SourceCensus` type and a + // `source_census` field, and `triggers_ordering_parity_tests.rs` calls + // `source_census_overlaps_filter`. None of those is a comment rule, and a counter added + // to one of those files would have been accepted as routed on the strength of a + // coincidence. The `::` form appears only in a use/call of THIS module. + let marker = format!("source_census{}", "::"); + let mut unrouted: Vec = Vec::new(); + let mut routed = 0usize; + for path in &files { + let rel = path + .strip_prefix(root) + .expect("under the crate root") + .to_string_lossy() + .replace('\\', "/"); + if rel == "src/source_census.rs" { + continue; + } + let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {rel}: {e}")); + let src = super::code_lines(&text); + let reads_rust_source = src.contains(".rs\")") && src.contains("include_str!(") + || walks_rs.iter().any(|w| src.contains(w.as_str())); + if !reads_rust_source { + continue; + } + if src.contains(marker.as_str()) { + routed += 1; + } else if !EXEMPT.iter().any(|(f, _)| *f == rel) { + unrouted.push(rel); + } + } + + assert!( + unrouted.is_empty(), + "these files read Rust source but neither route through `source_census` nor carry an \ + adjudicated EXEMPT reason. A source census that brings its own comment policy is the \ + defect this module exists to remove: a needle written after a trailing `//` counts \ + as a real site, so prose alone can move a pinned number and a deleted producer can \ + hide behind a comment that names it. Route the file through `source_census::code` / \ + `code_lines`, or add it to EXEMPT with the reason it is not a counter.\n\ + unrouted: {unrouted:#?}" + ); + assert!( + routed >= 7, + "reach-guard: the seven repaired counters must still be visible to this census as \ + ROUTED, or the predicate above stopped matching and the assertion is vacuous. \ + routed={routed}" + ); + } +} diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 6b12490bed..52348a570b 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -3614,7 +3614,7 @@ pub enum PersistentAxisMaterialization { /// (the `TokensCreated` axis). Carries NO per-cycle count because the per-cycle /// fodder count k is STRUCTURALLY ≡ 1: this stash is only registered when /// `materialize_object_growth_shortcut`'s `derived_fodder_class` - /// (engine.rs:1991-2005) found EXACTLY one new battlefield object per period + /// found EXACTLY one new battlefield object per period /// (a two+-object period returns `None` ⇒ no `Tokens` stash), so the boundary /// mint of `count: amount` == k·amount is EXACT. (Contrast `Counters`/`Life`, /// which carry a measured `per_cycle_delta` to handle k>1.) @@ -9562,6 +9562,7 @@ fn reject_zero_bound_shortcut_offer(state: &GameState) -> Result<(), String> { if let WaitingFor::LoopShortcut { schema, certificate, + proposer, .. } = &state.waiting_for { @@ -9572,6 +9573,73 @@ fn reject_zero_bound_shortcut_offer(state: &GameState) -> Result<(), String> { .to_string(), ); } + // THE PAIR NO PRODUCER MINTS. `is_bounded()` says the offer's producer NARROWED the + // repetition bound below `MAX_SHORTCUT_CYCLES`; `loop_period_controller()` says a driving + // period belonging to THIS proposer is recorded. The engine's three `LoopShortcut` mints + // partition that cross-product and none of them lands in this cell: + // + // * the object-growth mint (`reconcile_terminal_result`, schema from + // `try_offer_object_growth_shortcut`) and the Path A drain mint + // (`interactive_loop_bridge`) both hand `build_shortcut_schema` the global + // `MAX_SHORTCUT_CYCLES` verbatim, so neither is EVER `is_bounded()` — the growth mint + // is the one that REQUIRES its proposer's own period, and it is unbounded by + // construction; + // * the bounded mint (`certified_bounded_cycle_offer`) is `is_bounded()` by construction + // — it refuses `NoNarrowedLegalCount` unless `(1..MAX_SHORTCUT_CYCLES)` contains the + // bound — but its caller's gate (1b) (`bounded_cycle_offer`) returns + // `BoundedOfferRefusal::ProposerHasDrivingPeriod` while that seat's own period is + // accumulating, so it can never mint INTO this cell; + // * `visibility.rs`'s per-viewer re-wrap copies `max_iterations` verbatim off an offer + // one of the three already minted. + // + // No live beat can join the two afterwards either. Nothing assigns `schema` or + // `max_iterations` in place anywhere in the engine, so an unbounded offer cannot ACQUIRE a + // narrowed bound; and every writer that GROWS `last_loop_action_sequence` is priority-side + // — the `TapLandForMana` / `ActivateManaSource` / `ActivateAbility` `WaitingFor::Priority` + // arms (`accumulate_loop_action_step` and the token-creating `vec![step]` beside it) and + // the cast finalize. A pending offer reaches none of them: its only reducer arms are + // `DeclareShortcut` and `DeclineShortcut`. + // + // WHAT IT COSTS TO ACCEPT IT: `materialize_fixed_shortcut` (SITE C) dispatches on period + // ownership ALONE and early-returns the accepted proposal into + // `materialize_object_growth_shortcut`, committing ZERO of the agreed cycles — the silent + // misroute gate (1b)'s own doc block exists to prevent, entering through the restore door + // instead of the producer door. + // + // ⚠ DELIBERATELY CARRIES NO `CR` ANNOTATION, and the measurement for that absence travels + // with it so a later reader does not "fix" the omission. CR 732.2a's own Example is a + // proposer repeating THEIR OWN activation a SPECIFIED 999,999 more times — bounded, own + // period — so this state class is LEGAL AT THE TABLE and the rules license nothing here to + // enforce. What is violated is a producer-reachability fact about this engine, not a rule. + // Same call, same reason, same file family as `handle_declare_shortcut`'s IMPLEMENTATION + // BUDGET BOUND note: "a maintainer applying the CR 732.2a iff to a branch that wears a CR + // number will either trust it wrongly or delete it wrongly." + // + // BOTH conjuncts are required. Own period ALONE is the object-growth route's own admission + // condition, so rejecting it would refuse every legitimate growth capture; a narrowed bound + // ALONE is the ordinary bounded offer. + // + // ⚠ AFTER THE ZERO-BOUND CHECK, DELIBERATELY: `0 < MAX_SHORTCUT_CYCLES`, so a zero bound is + // ALSO `is_bounded()` and hoisting this block would relabel a corrupt zero with the wrong + // invariant. Observed, not assumed — see the zero-bound-plus-own-period arm of + // `a_wire_bounded_offer_carrying_the_proposers_own_period_fails_the_load`. + // + // ⚠ THIS BLOCK COVERS ONE OF THE HARM'S TWO WIRE HOSTS, and unlike the zero-bound sibling + // above the residual is NOT empty. A persisted `WaitingFor::RespondToShortcut { proposal }` + // whose `proposal.proposer` owns the recorded period reaches the SAME SITE C misroute via + // `apply_confirmed_shortcut`. No bound-keyed conjunct can see it — `ShortcutProposal` + // carries no `schema`/`max_iterations` at all (the scoping note on the zero-bound guard + // above). The candidate discriminator on that host is `proposal.per_cycle.is_some()`; it is + // filed rather than shipped because "`per_cycle: Some` ⟺ the bounded mint" is not yet + // measured per branch, and a guard on an inherited marker is what this seam must not carry. + if schema.is_bounded() && state.loop_period_controller() == Some(*proposer) { + return Err( + "persisted LoopShortcut offer narrows its repetition bound while recording the \ + proposer's own driving period; no producer mints that pair, and accepting it \ + routes the agreed cycles to the object-growth materializer, committing none" + .to_string(), + ); + } // The SIBLING wire zero. `max_iterations` says how many repetitions there are; // `frames_per_period` says what one repetition IS, and a wire-supplied 0 corrupts the // second question exactly as a 0 bound corrupts the first. @@ -9875,6 +9943,32 @@ impl PersistedGameState { // one (it is lowered to the activator at creation), so this only sanitizes // corrupt/forged snapshots and closes the fail-open path in both consumers. state.drop_unresolved_source_controller_restrictions(); + // Issue #5466: `rng` is `#[serde(skip)]`, so a decode leaves the LIVE ChaCha20 stream at + // word 0 while the serialized high-water `rng_word_pos` keeps its saved offset. A + // `capture_rng_word_pos` on that state — the production library shuffle performs one, see + // `game/library.rs` — then `.expect`-panics `HighWaterRegression { current: , + // requested: 0 }`, and any shuffle before it replays entropy the game already consumed. + // + // Rehydrating HERE means every caller of the chokepoint inherits the repair instead of + // owing its own. It does NOT make the load paths equivalent: what each caller does + // AFTERWARDS is that caller's own policy, and they differ. + // * `engine-wasm`'s `restore_game_state` still calls `rehydrate_rng` itself, so that + // path now runs it twice. Harmless — `rehydrate_rng` is idempotent, both of its + // statements being absolute assignments from persisted fields. + // * `engine-wasm`'s `resume_multiplayer_host_state` deliberately re-seeds and sets + // `rng_word_pos = 0` so a resumed host does not replay saved randomness. That is a + // resume policy choice, not a bug, and it leaves live position and high-water agreed. + // * `server-core`'s `GameSession::from_persisted` re-seeds `rng` from fresh entropy and + // zeroes `rng_word_pos` in the same step, so it lands on the same agreed live-0 / + // high-water-0 pair as the host resume above. It therefore DISCARDS the position this + // call just restored, deliberately: a resumed server game must not replay randomness + // the pre-save game already consumed. That makes this call's rehydrate inert on the + // server path — the chokepoint hands every caller a coherent stream, and a caller + // wanting a different one overwrites BOTH halves rather than half of one. Re-seeding + // WITHOUT zeroing the offset was the earlier server bug: it left live 0 / high-water + // , so the next `capture_rng_word_pos` `.expect`-panicked `HighWaterRegression`. + // Offline tooling (`phase-ai`'s `load_saved_game_state`) simply inherits the repair. + state.rehydrate_rng(); state } } @@ -11043,6 +11137,39 @@ pub enum WaitingFor { /// forward-compatible deserialization of pre-schema snapshots. #[serde(default)] schema: crate::analysis::decision_template::ShortcutDecisionSchema, + /// CR 732.2a: the declaration the ENGINE itself can already specify from what this + /// window's proposer actually did — one pin per published point of `schema`, read out + /// of the `(DecisionSlot, PlayerId)` answer journal at offer construction, or `None` + /// when any published point has no single answer under the proposer's own key. + /// Built by `game::engine::build_bounded_declaration`; `None` on every other mint. + /// + /// LATCHED at offer time, not a live view: it is a snapshot of the answers the + /// detection window observed. Latching the pin SET latches no per-iteration OUTCOME, + /// because no pin kind stores one: a SEAT designation is state-independent under + /// either spelling — the CR 115.10a `TargetPin::Player` and the CR 601.2c + /// `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(..))))` alike — and + /// `TargetPin::ByIdentity` re-resolves live per iteration inside + /// `analysis::decision_template::resolve` (CR 608.2b). The ranked spelling is + /// re-resolved live too, and it is the STRONGER case rather than a new exposure: its + /// arm re-asks CR 702.11c hexproof / CR 702.18a shroud / CR 702.16b protection at + /// every iteration, so a latched TARGET-class seat can stop resolving mid-drive where + /// a latched CHOICE-class one could not. + /// + /// `#[serde(default)]` follows `schema`'s precedent on this same variant. Consequence, + /// chosen rather than discovered: a pre-declaration save decodes with `None`, which is + /// exactly today's refusal — fail-closed. ⚠ MEASURED: the attribute is BELT-AND-BRACES + /// on an `Option` field, not the mechanism — `serde_derive` routes a missing field + /// through `missing_field`, whose deserializer answers `deserialize_option` with + /// `visit_none`, so removing it changes no decode today. It is kept because it states + /// the intent at the field and becomes load-bearing the moment this stops being an + /// `Option`. Either way this field IS a client INGRESS — a restored save can carry a + /// hostile declaration, and the AI generator hands whatever it finds straight to + /// `GameAction::DeclareShortcut`. That is safe today only + /// because `handle_declare_shortcut` runs the owner firewall, `predictability_gate` + /// and `validate_pins` on EVERY declaration regardless of origin, and nothing here + /// adds a path around them. + #[serde(default)] + declaration: Option, }, /// CR 732.2b/c: the APNAP accept-or-shorten window. After the proposer declares the /// shortcut, each other living player is prompted in turn order (drain-one-advance @@ -14485,6 +14612,68 @@ declare_game_state! { /// dedup on semantically-identical positions is unaffected. #[serde(skip, default)] pub loop_detect_ring: std::collections::VecDeque>, + /// CR 603.5 + CR 608.2b + CR 732.2a: the answers given to published decision SLOTS + /// during the window `loop_detect_ring` is sampling, so the CR 732.2a declaration can + /// pin the choice each iteration actually made instead of guessing one. Both published + /// axes ride ONE journal: the CR 603.5 "may" gate and the CR 601.2c target + /// announcement, distinguished by the value's own kind ([`LoopAnswerValue`]) and by the + /// slot's sub-index — a parallel target journal would double the eight ring-clear + /// sites and widen their census for no capability this field lacks. + /// + /// KEYED BY THE PAIR `(slot, seat)`, not by the source alone. The SUB-INDEX half is + /// what keeps the two slots one source can publish apart: `entry_publishes_pin_slots` + /// binds `source` ONCE and builds both `DecisionSlot::target(source)` (CR 601.2c) and + /// `DecisionSlot::may(source)` (CR 603.5) from it, and the shipped unit test + /// `bounded_cycle_pin_slots_publishes_the_may_gate_of_an_optional_trigger` asserts an + /// optional targeted trigger publishes both. Under a source-only key those two writes + /// would land in ONE entry with different values and latch `Conflicted`. NON-CLAIM, + /// measured: no board in this lane exercises that collision — every published point on + /// the three tracked F4 dumps carries a distinct source — so the sub-index is adopted + /// because it aligns the journal's identity with the engine's own published one + /// (`DecisionPoint.slot`, which is what the consumer looks up), never because a + /// measured board needs it. + /// + /// The SEAT half: CR 603.5 routes a + /// "may" to whichever seat the effect names, and `game::effects` really does prompt + /// several seats for ONE source inside one window (the scoped-search acceptance + /// cascade). A seat can therefore only ever answer for itself: no seat's answer can + /// fill another's slot, and no two seats can manufacture a false disagreement. The + /// seat authority lives in the key type rather than in a consumer-side guard. + /// + /// STATED SO IT IS NOT OVER-READ: this is DEFENSE IN DEPTH PLUS A CODE DELETION, NOT + /// A LIVE-BUG FIX. Both harms a source-only key could produce are already firewalled + /// downstream — the pin injector reads the recipient off the prompt in hand and aborts + /// the replay when it does not match the template owner (`game::engine`'s + /// `WaitingFor::OptionalEffectChoice` arm, `if *player != template.owner`). NO BOARD + /// HAS BEEN MEASURED on which the seat component changes an offer; the multi-seat + /// board that does exist journals two seats and mints no offer at all + /// (`tests/integration/natural_balance.rs`). The claim this key earns is "cannot be + /// worse, and removes a runtime guard", not "prevents a reachable wrong declaration". + /// + /// TRANSIENT DERIVED STATE with `loop_detect_ring`'s exact treatment — same + /// `#[serde(skip, default)]`, same omission from `impl PartialEq for GameState` + /// (rebuilt from play; comparing it would break AI-search dedup on + /// semantically-identical positions), and cleared at every one of the ring's clear + /// sites on the same receiver. `#[serde(skip)]` is also load-bearing rather than + /// merely tidy: a `BTreeMap` with a tuple key has no JSON object form and + /// [`LoopAnswer`] AND [`LoopAnswerValue`] both deliberately derive no `Serialize`, so a + /// future attempt to persist this fails at compile time instead of silently emitting a + /// stale window. + /// + /// `Option>` for `game_state_size.rs`'s stated reason — "box it if it is a + /// large rarely-populated one" — costing 8 B inline like the `life_safety_probe` + /// neighbour below. NOTHING TESTS THE BOXING: at the current ceiling an unboxed + /// `BTreeMap` (24 B inline) also fits, so this rationale is a convention here, not a + /// guarded invariant. + #[serde(skip, default)] + pub(crate) loop_answer_journal: Option< + Box< + std::collections::BTreeMap< + (crate::analysis::decision_template::DecisionSlot, PlayerId), + crate::analysis::decision_template::LoopAnswer, + >, + >, + >, /// Live-only authority for the finite pre-cast shortcut. It is absent from /// raw/public serialization; trusted persistence uses the explicit codec /// envelope in `game::precast_copy_shortcut`. @@ -19935,6 +20124,7 @@ impl GameState { static_source_index: StaticSourceIndex::default(), static_mode_presence: crate::types::statics::StaticModePresence::all_present(), loop_detect_ring: std::collections::VecDeque::new(), + loop_answer_journal: None, precast_shortcut_runtime: PrecastShortcutRuntime::default(), life_safety_probe: Box::default(), next_timestamp: 1, @@ -20831,6 +21021,9 @@ impl GameState { // the live ring → recursive/quadratic growth. Cleared ⇒ every stored snapshot // has clone depth 1. Does not affect any comparison (the ring is eq-excluded). clone.loop_detect_ring.clear(); + // CR 603.5: the "may"-answer journal belongs to the LIVE window, not to a stored + // position sample. Cleared with the ring, on this same receiver. + clone.loop_answer_journal = None; // Private shortcut capabilities are live interaction state, never part // of a CR 104.4b position sample. clone.precast_shortcut_runtime = PrecastShortcutRuntime::default(); @@ -21021,11 +21214,15 @@ impl GameState { /// /// The ring clear is mandatory and is `normalize_for_loop`'s own reason: samples are /// produced from the live state, so without it each stored sample would carry a clone - /// of the live ring ⇒ recursive/quadratic growth. **Nothing else is touched** — every - /// other field is what makes this half the evaluable one. + /// of the live ring ⇒ recursive/quadratic growth. The CR 603.5 `loop_answer_journal` + /// is cleared alongside it for a DIFFERENT reason — not recursion, but ownership: the + /// journal records the live window's answers, and a stored sample must not carry them. + /// **Nothing else is touched** — every other field is what makes this half the + /// evaluable one. pub(crate) fn loop_detect_live_sample(&self) -> GameState { let mut clone = self.clone(); clone.loop_detect_ring.clear(); + clone.loop_answer_journal = None; clone } @@ -21067,7 +21264,7 @@ impl GameState { /// shorter slice, so a short snapshot would silently skip tail seats and RETAIN the /// ring — the one direction the "clearing can only SHRINK the prior set" guarantee /// forbids. One comparison keeps that guarantee structural instead of contractual. - /// (CR 119.3, `MagicCompRules.txt:1065`, is the rule the life comparison implements.) + /// (CR 119.3 is the rule the life comparison implements.) pub(crate) fn invalidate_loop_ring_on_unobserved_life_move(&mut self, lives_before: &[i32]) { if self.players.len() != lives_before.len() || self @@ -21077,9 +21274,102 @@ impl GameState { .any(|(p, &before)| p.life != before) { self.loop_detect_ring.clear(); + // CR 603.5: the answers belong to the window the ring just lost. + self.loop_answer_journal = None; + } + } + + /// CR 603.5 + CR 608.2b: record ONE seat's answer to ONE published decision SLOT for + /// the current loop-detection window. A second, DIFFERENT answer from THE SAME SEAT for + /// THE SAME SLOT latches [`LoopAnswer::Conflicted`] (see that type — an + /// engine-capability refusal, not a CR mandate). A different seat, or the same source's + /// OTHER slot, occupies a DIFFERENT KEY and can neither conflict with, nor be read in + /// place of, this entry. + /// + /// The latch arm is axis-agnostic by construction: it compares whole + /// [`LoopAnswer`] values, so widening the value to carry CR 601.2c target vectors added + /// no second conflict rule. Equality for `Targets` is derived `Vec` + /// equality — order-sensitive, which is STRICTER than set equality and therefore + /// fail-closed (strictly fewer offers, never a wrong pin). Order-sensitivity cannot bite + /// on the BOUNDED-CYCLE schema, whose producer + /// (`game::engine::bounded_cycle_pin_slots_for_window`) hard-codes `min_targets: 1, + /// max_targets: 1, ordered: false` — a one-pin answer has no order to disagree about. + /// That is the SCOPE of the claim, not a whole-value-space one: a published `Targets` + /// point has a SECOND production producer, `game::engine::pinned_decisions_to_points`, + /// which emits `ordered: true` with `min/max = targets.len()` from a carried pin list; + /// and the writer (`game::engine::record_trigger_target_answer`) can journal a MULTI-PIN + /// vector for one slot. That stays FAIL-CLOSED on the stricter-than-set-equality reading + /// above rather than unreachable. + /// + /// ⚠ THE MECHANISM NAMED HERE WAS WRONG, and is corrected rather than dropped because the + /// CONCLUSION above survives it. The claim used to be that the writer "journals whatever + /// the announcement carried, which for one `multi_target` slot is several pins" — true on + /// ONE of its two reducer arms only. `GameAction::SelectTargets` hands the whole announced + /// vector over at once, so a multi-pin `Targets` value really is written in a single call. + /// `GameAction::ChooseTarget` does not: `engine_stack`'s + /// `handle_trigger_target_selection_choose_target` re-raises the SAME + /// `WaitingFor::TriggerTargetSelection` with the same one-element `target_slots` and only + /// `selection` advanced, and the writer reads its slot count off the prompt in hand — so a + /// two-pick single slot arrives as `Targets([A])` and then `Targets([B])` on ONE key, and + /// the latch above answers `Conflicted` rather than storing an ordered pair. Both routes + /// are fail-closed; only the second one is a conflict rather than an order comparison. + /// + /// Gated exactly like `game::engine::record_loop_pin` + /// (`samples() && !in_simulation_probe()`), so the #4603-Off build never records and + /// the detection/materialize drive replays without re-recording. + pub(crate) fn record_loop_answer( + &mut self, + slot: crate::analysis::decision_template::DecisionSlot, + player: PlayerId, + answer: crate::analysis::decision_template::LoopAnswer, + ) { + use crate::analysis::decision_template::LoopAnswer; + use std::collections::btree_map::Entry; + if !self.loop_detection.samples() || crate::game::engine::in_simulation_probe() { + return; + } + match self + .loop_answer_journal + .get_or_insert_default() + .entry((slot, player)) + { + Entry::Vacant(v) => { + v.insert(answer); + } + Entry::Occupied(mut o) => { + if *o.get() != answer { + o.insert(LoopAnswer::Conflicted); + } + } } } + /// The observed answer for one published SLOT AS ANSWERED BY `player`. `None` = that + /// seat never answered this slot in this window ⇒ a declaration must refuse, exactly as + /// [`LoopAnswer::Conflicted`] does. + pub fn loop_answer( + &self, + slot: &crate::analysis::decision_template::DecisionSlot, + player: PlayerId, + ) -> Option { + // `BTreeMap` keys by the owned tuple and no `Borrow` shape spans a tuple, so the + // key is built. This is the seam's own idiom — `entry_publishes_pin_slots` builds + // its slot with `source: source.clone()`. One clone per published point at + // declaration-build time, never per iteration. `.cloned()` rather than `.copied()` + // because `LoopAnswer` gave up `Copy` when its value grew a `Vec`. + self.loop_answer_journal + .as_ref()? + .get(&(slot.clone(), player)) + .cloned() + } + + /// How many distinct (slot, seat) pairs this window has answered. `None` and an + /// empty map are indistinguishable here BY DESIGN — no caller may branch on the + /// `Option`. + pub fn loop_answers_recorded(&self) -> usize { + self.loop_answer_journal.as_ref().map_or(0, |m| m.len()) + } + /// CR 732.2a: record that an unbounded (net-progress) loop under `controller` /// pumps `axes`. The single write authority for `unbounded_resources` — /// every producer routes through here, never mutating the map inline. Two @@ -21627,6 +21917,13 @@ fn _gamestate_partition_is_total(s: &GameState) { static_source_index: _, static_mode_presence: _, loop_detect_ring: _, + // CR 603.5 + CR 732.2a "may"-answer journal: EXCLUDED from `impl PartialEq for + // GameState` for `loop_detect_ring`'s reason — transient derived state rebuilt + // from play, and comparing it would split two semantically-identical positions + // in AI-search dedup. It cannot become a hidden per-cycle accumulator riding a + // covering pair: `project_out_resources` opens with `normalize_for_loop`, which + // is one of the ring-clear sites this field follows, so the projection clears it. + loop_answer_journal: _, precast_shortcut_runtime: _, life_safety_probe: _, next_timestamp: _, @@ -21893,9 +22190,11 @@ fn _gamestate_partition_is_total(s: &GameState) { // legitimate loop; a heterogeneous/reordered period is correctly caught and rejected). last_loop_action_sequence: _, // - `resolution_source_relatch` (CR 400.7j self-move re-latch): EXCLUDED-REQUIRED (measured - // by ordering trace, not doc-trust). The clear at stack.rs:194 fires at the START of the + // by ordering trace, not doc-trust). The `stack.rs` clears (`resolution_source_relatch = + // None`, one at each resolution-start site) fire at the START of the // NEXT resolution, while `record_loop_detect_sample` fires at the Priority window AFTER - // this resolution's self-move SET it (zones.rs:610) — so at the sample beat it HOLDS this + // this resolution's self-move SET it (`zones::record_resolution_source_relatch`) — so at + // the sample beat it HOLDS this // iteration's `current_incarnation`, which bumps every iteration. COMPARING it would make // every self-moving loop compare UNEQUAL (a false-negative — it would make the 4d // Sprout-Swarm buyback loop undetectable). It is an incarnation/timestamp identity, and @@ -22676,6 +22975,7 @@ mod forced_cascade_window_tests { predicted_winner: Some(PlayerId(0)), certificate: certificate(), schema: Default::default(), + declaration: None, }, ), ( @@ -30061,4 +30361,363 @@ mod tests { "an observed life move on a full-length snapshot still clears the ring" ); } + + // ───────────────────────────────────────────────────────────────────────────────── + // C2a — the loop-answer journal's STORAGE CONTRACT on the widened `(slot, seat)` key. + // + // ⚠ TIER, stated rather than implied: these three rows are IN-CRATE STORAGE-CONTRACT + // rows, NOT wire rows. They assert what the map does with the keys and values it is + // handed; that the PRODUCTION reducer arms hand it the right ones is asserted at the + // wire tier by `tests/integration/fantastic_four_bounded_loop.rs` (the CR 603.5 arm + // and the CR 608.2b `ChooseTarget` arm) and `tests/integration/loop_shortcut.rs` (the + // CR 608.2b `SelectTargets` arm). + // ───────────────────────────────────────────────────────────────────────────────── + + /// A `GameState` whose detector SAMPLES, which is `record_loop_answer`'s own gate. A + /// state built without this records nothing and every row below would pass vacuously + /// on an empty map — which is why each row asserts a CARDINALITY first. + fn journal_state() -> GameState { + let mut state = GameState::new_two_player(42); + state.loop_detection = LoopDetectionMode::Interactive; + state + } + + fn journal_source(id: u64) -> crate::analysis::decision_template::DecisionSource { + YieldTarget::ThisObject { + source_id: ObjectId(id), + incarnation: Some(1), + trigger_description: None, + } + } + + /// **Row T2 — the SUB-INDEX half of the key.** CR 601.2c and CR 603.5 are two choices + /// of ONE ability instance, and `entry_publishes_pin_slots` publishes both from a + /// single bound `source`. They must occupy TWO journal entries. + /// + /// # Discrimination + /// + /// Collapse the key to `(slot.source, player)` in `record_loop_answer`'s `entry(..)` + /// and `loop_answer`'s `get(..)` (keep both signatures) ⇒ the two writes land in ONE + /// entry, the second differs from the first, and the `Entry::Occupied` arm latches + /// `Conflicted`: the cardinality assertion reads 1 and both value lookups read + /// `Conflicted`. + /// + /// # Reach-guard + /// + /// The cardinality is asserted FIRST and is value-independent, so an unsampled + /// detector — an empty map — fails this row before any content assertion can pass on + /// nothing. + /// + /// # NON-CLAIM, measured and shipped in the row rather than in a report + /// + /// NO REAL BOARD IN THIS LANE EXERCISES THIS COLLISION: every published point on all + /// three tracked F4 dumps carries a DISTINCT source, so collapsing the sub-index + /// leaves those declarations intact. The card class is real (Scryfall: ≥8 cards + /// phrased "you may have target opponent …", e.g. Disciple of the Vault) and the + /// two-slots-from-one-source publisher shape is asserted in-tree by + /// `game::engine`'s `bounded_cycle_pin_slots_publishes_the_may_gate_of_an_optional_trigger`, + /// but the end-to-end board is UNMEASURED. This row is the storage invariant only. + #[test] + fn c2a_row_t2_one_source_two_sub_indices_occupy_two_journal_entries() { + use crate::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, MayChoiceOption, TargetPin, + }; + + let mut state = journal_state(); + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: a fresh board starts with an EMPTY journal, so every entry below \ + is one this row wrote" + ); + let source = journal_source(910); + let seat = PlayerId(0); + + state.record_loop_answer( + DecisionSlot::may(source.clone()), + seat, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + state.record_loop_answer( + DecisionSlot::target(source.clone()), + seat, + LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player(PlayerId( + 1, + ))])), + ); + + assert_eq!( + state.loop_answers_recorded(), + 2, + "CR 601.2c and CR 603.5 are two choices of ONE ability instance: the sub-index \ + must keep them in two entries. A source-only key holds 1" + ); + assert_eq!( + state.loop_answer(&DecisionSlot::may(source.clone()), seat), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "the CR 603.5 gate's own answer survives the CR 601.2c write on the same source" + ); + assert_eq!( + state.loop_answer(&DecisionSlot::target(source), seat), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + TargetPin::Player(PlayerId(1)) + ]))), + "and the CR 601.2c announcement's own answer survives the CR 603.5 write" + ); + } + + /// **Row T3 — the LATCH, on the target axis.** CR 732.2a: two observed iterations of + /// one (slot, seat) pair that DISAGREE latch [`LoopAnswer::Conflicted`], and the latch + /// is idempotent and seat-local. + /// + /// # Discrimination + /// + /// Narrow the `Entry::Occupied` arm to the may axis — + /// `if *o.get() != answer && !matches!(answer, LoopAnswer::Uniform(LoopAnswerValue::Targets(_)))` + /// ⇒ this row reds while C1's existing may-conflict row + /// (`c1_row2b_one_seat_answering_one_source_two_ways_latches_conflicted`) stays GREEN. + /// The mutation is therefore discriminating rather than shared. + /// + /// # Paired positive / reach-guards + /// + /// The first write is read back as `Uniform` BEFORE the differing one lands, so a + /// writer that recorded nothing cannot satisfy this row; and the sibling seat's entry + /// is asserted still `Uniform` afterwards, so a latch that fired globally fails too. + /// + /// # NON-CLAIM, measured and shipped in the row + /// + /// THE TARGET LATCH HAS NO MEASURED WIRE-TIER REACHABILITY: driving the three tracked + /// F4 dumps under an ALTERNATING-target policy through production `apply()` reaches NO + /// OFFER AT ALL (a varying player target moves a different seat's resources, so the + /// period's signature never repeats and certification refuses upstream), while + /// constant P1, constant P2 and constant P3 all certify. It is the VARIATION, not the + /// seat, that blocks certification. This latch is defence in depth. + #[test] + fn c2a_row_t3_a_differing_target_answer_latches_conflicted_idempotently_and_seat_locally() { + use crate::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, TargetPin, + }; + + let mut state = journal_state(); + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: a fresh board starts with an EMPTY journal" + ); + let slot = DecisionSlot::target(journal_source(911)); + let (seat, other_seat) = (PlayerId(0), PlayerId(1)); + let aimed_at_1 = LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player( + PlayerId(1), + )])); + let aimed_at_0 = LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player( + PlayerId(0), + )])); + + state.record_loop_answer(slot.clone(), seat, aimed_at_1.clone()); + state.record_loop_answer(slot.clone(), other_seat, aimed_at_1.clone()); + assert_eq!( + state.loop_answer(&slot, seat), + Some(aimed_at_1.clone()), + "paired positive: the FIRST answer is journalled as Uniform before any \ + disagreement, so a writer that stored nothing cannot reach the latch" + ); + + // The disagreement. + state.record_loop_answer(slot.clone(), seat, aimed_at_0); + assert_eq!( + state.loop_answer(&slot, seat), + Some(LoopAnswer::Conflicted), + "CR 732.2a: this engine refuses on a differing answer — an ENGINE-CAPABILITY \ + limit, not a rule the CR states" + ); + assert_eq!( + state.loop_answer(&slot, other_seat), + Some(aimed_at_1.clone()), + "seat-local: the other seat's entry is a DIFFERENT key and is untouched by the \ + latch" + ); + + // Idempotence: the latch fires on DISAGREEMENT, not on repetition, and never + // returns to `Uniform`. + state.record_loop_answer(slot.clone(), seat, aimed_at_1); + assert_eq!( + state.loop_answer(&slot, seat), + Some(LoopAnswer::Conflicted), + "the latch never returns to Uniform, even when a later answer matches the first" + ); + } + + /// **Row T4 — the SEAT half of the key, on the target axis.** A seat can only ever + /// answer for itself: two seats announcing DIFFERENT targets for one slot occupy two + /// entries and neither can be read in place of the other. + /// + /// # Discrimination + /// + /// Drop `player` from the key in `record_loop_answer`/`loop_answer` ⇒ ONE entry, the + /// second write disagrees with the first, and the latch makes both lookups + /// `Conflicted`: the cardinality reads 1 and both value assertions flip. + /// + /// # Reach-guard + /// + /// The two seats are asserted DISTINCT and their two answers asserted DIFFERENT before + /// the lookups — two identical answers would occupy two entries under either key shape + /// and prove nothing. + #[test] + fn c2a_row_t4_two_seats_answering_one_target_slot_occupy_two_independent_entries() { + use crate::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, TargetPin, + }; + + let mut state = journal_state(); + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: a fresh board starts with an EMPTY journal" + ); + let slot = DecisionSlot::target(journal_source(912)); + let (seat_a, seat_b) = (PlayerId(0), PlayerId(1)); + assert_ne!( + seat_a, seat_b, + "reach-guard: the two answering seats must differ, else there is no seat axis" + ); + let answer_a = LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player( + PlayerId(1), + )])); + let answer_b = LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player( + PlayerId(0), + )])); + assert_ne!( + answer_a, answer_b, + "reach-guard: the two answers must DIFFER, else a collapsed key would also hold \ + two consistent entries and this row would not discriminate" + ); + + state.record_loop_answer(slot.clone(), seat_a, answer_a.clone()); + state.record_loop_answer(slot.clone(), seat_b, answer_b.clone()); + + assert_eq!( + state.loop_answers_recorded(), + 2, + "the seat is part of the key: two seats answering one slot hold TWO entries. A \ + seat-less key holds 1" + ); + assert_eq!( + state.loop_answer(&slot, seat_a), + Some(answer_a), + "each seat reads back its OWN announcement, uncorrupted by the other's" + ); + assert_eq!(state.loop_answer(&slot, seat_b), Some(answer_b)); + } + + /// **Row T3-R — the latch, exercised with the MIGRATED spelling.** CR 601.2c: after the + /// provenance split an announced seat is journalled as + /// `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(..))))`, and this row + /// asserts the [`LoopAnswer::Conflicted`] latch behaves identically on that value. + /// + /// # Why it exists — the gap it closes + /// + /// [`c2a_row_t3_a_differing_target_answer_latches_conflicted_idempotently_and_seat_locally`] + /// and its T4 sibling HAND-BUILD their `LoopAnswer`s, so the producer migration cannot + /// reach them and they stay green unmodified. That leaves the latch never exercised with + /// the value production now writes: equality preservation across the re-spelling would + /// rest on derived structural `PartialEq` alone — sound, but unpinned. This pins it. + /// + /// # Discrimination + /// + /// Two independent mutants, each caught by a different arm, and BOTH RUN rather than + /// reasoned about (the mutation must target EQUALITY, not the producer — this row builds + /// its own values, so a producer mutation cannot reach it): + /// + /// * hand-write `impl PartialEq for Ranking` as `self.head() == other.head()` — the + /// head-only shape `evaluate_schedule` uses to RESOLVE, and therefore the plausible + /// wrong reading ⇒ arm (b)'s same-head/different-tail pair compares EQUAL ⇒ no latch ⇒ + /// **arm (b) FAILS while arms (a) and (c) stay green**, because (a)'s heads already + /// differ. The tail is the NEXT episode's pre-declaration, so two proposals agreeing + /// only about this episode are not the same answer; + /// * hand-write `impl PartialEq for AnnouncementSubject` with `(Seat(_), Seat(_)) => true` + /// ⇒ two DIFFERENT seats compare equal ⇒ arm (a)'s own reach-guard fires first and + /// names the vacuity by hand, which is the reach-guard doing its job rather than the + /// latch assertion doing it late. + /// + /// # Paired positive + /// + /// Arm (c) records the SAME ranking twice and requires the entry to stay `Uniform`. Without + /// it, a latch that fired on every write — i.e. an equality that is always false — would + /// satisfy both negative arms. + #[test] + fn c2a_row_t3r_the_conflicted_latch_is_unchanged_by_the_migrated_seat_spelling() { + use crate::analysis::decision_template::{ + AnnouncementSubject, DecisionSlot, LoopAnswer, LoopAnswerValue, Ranking, TargetPin, + TargetSchedule, + }; + + let ranked = |subjects: Vec| { + LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Scheduled( + TargetSchedule::Constant( + Ranking::new(subjects).expect("non-empty, duplicate-free by construction"), + ), + )])) + }; + let seat = |p: u8| AnnouncementSubject::Seat(PlayerId(p)); + + // ── (a) two rankings naming DIFFERENT seats ⇒ Conflicted ── + let mut state = journal_state(); + let slot_a = DecisionSlot::target(journal_source(913)); + let aimed_at_1 = ranked(vec![seat(1)]); + let aimed_at_2 = ranked(vec![seat(2)]); + assert_ne!( + aimed_at_1, aimed_at_2, + "reach-guard: the two announcements must DIFFER as values, else the latch has \ + nothing to fire on and every arm below is vacuous" + ); + state.record_loop_answer(slot_a.clone(), PlayerId(0), aimed_at_1.clone()); + assert_eq!( + state.loop_answer(&slot_a, PlayerId(0)), + Some(aimed_at_1.clone()), + "paired positive: the FIRST ranked answer is journalled as Uniform before any \ + disagreement, so a writer that stored nothing cannot reach the latch" + ); + state.record_loop_answer(slot_a.clone(), PlayerId(0), aimed_at_2); + assert_eq!( + state.loop_answer(&slot_a, PlayerId(0)), + Some(LoopAnswer::Conflicted), + "CR 732.2a: two announcements naming different seats are a disagreement in the \ + ranked spelling exactly as they were in the CHOICE-class one" + ); + + // ── (b) SAME head, DIFFERENT tail ⇒ still Conflicted ── + // + // The tail is a pre-declaration for the NEXT episode (CR 732.2a), not decoration, so + // two proposals that agree only about this episode are not the same answer. This is + // the arm a head-only equality would lose. + let slot_b = DecisionSlot::target(journal_source(914)); + let head1_tail2 = ranked(vec![seat(1), seat(2)]); + let head1_tail3 = ranked(vec![seat(1), seat(3)]); + state.record_loop_answer(slot_b.clone(), PlayerId(0), head1_tail2.clone()); + assert_eq!( + state.loop_answer(&slot_b, PlayerId(0)), + Some(head1_tail2), + "reach-guard: the multi-entry ranking round-trips as Uniform first" + ); + state.record_loop_answer(slot_b.clone(), PlayerId(0), head1_tail3); + assert_eq!( + state.loop_answer(&slot_b, PlayerId(0)), + Some(LoopAnswer::Conflicted), + "equality over a `Ranking` is STRUCTURAL, not head-only: the tail is the next \ + episode's pre-declaration, so differing tails are differing answers" + ); + + // ── (c) the SAME ranking twice ⇒ still Uniform ── + let slot_c = DecisionSlot::target(journal_source(915)); + state.record_loop_answer(slot_c.clone(), PlayerId(0), aimed_at_1.clone()); + state.record_loop_answer(slot_c.clone(), PlayerId(0), aimed_at_1.clone()); + assert_eq!( + state.loop_answer(&slot_c, PlayerId(0)), + Some(aimed_at_1), + "the latch fires on DISAGREEMENT, not on repetition — without this arm an \ + always-false equality would satisfy (a) and (b)" + ); + } } diff --git a/crates/engine/src/types/game_state_size.rs b/crates/engine/src/types/game_state_size.rs index f7ef80dc73..78fa16d469 100644 --- a/crates/engine/src/types/game_state_size.rs +++ b/crates/engine/src/types/game_state_size.rs @@ -34,11 +34,12 @@ //! | grep 'print-type-size type: `types::game_state::GameState`:' //! ``` //! -//! Measured on `nightly-2026-04-19`, aarch64-apple-darwin: +//! Measured on `nightly-2026-04-19` — `GameState` on x86_64-unknown-linux-gnu, +//! the other three rows on aarch64-apple-darwin (not re-measured): //! //! | Type | before boxing | after | ceiling | //! |---|---:|---:|---:| -//! | `GameState` | 30,112 | 12,464 | 12,800 | +//! | `GameState` | 30,112 | 12,816 | 13,312 | //! | `StackEntry` | 5,336 | 344 | 768 | //! | `PendingCast` | 6,632 | 1,376 | 1,792 | //! | `PendingTrigger` | 6,000 | 744 | 1,024 | @@ -52,7 +53,7 @@ const _: () = { use core::mem::size_of; assert!( - size_of::() <= 12_800, + size_of::() <= 13_312, "GameState grew past its stack budget. It is moved by value through the \ phase-server action + AI path, so an overrun is an uncatchable \ guard-page abort, not a panic. Re-run the -Zprint-type-sizes command in \ diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index e7d2e44bd8..e4a18c8652 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -969,10 +969,18 @@ pub enum InteractionResponseSpec { max: u32, confirm: ConfirmSemantics, }, + /// CR 732.2a: the loop-shortcut declaration. `count` is the picker's window and + /// `preview` is what the count it states actually DOES, per axis — see + /// [`InteractionShortcutPreview`] for why the count travels with the magnitudes. + /// + /// The doc lives on the VARIANT rather than on `preview`: ts_rs emits field docs into + /// the generated bindings as JSDoc but drops variant docs, and a comment block in the + /// middle of a union keeps that file from being one declaration per line. Shortcut { count: InteractionShortcutCountSpec, points: Vec, allow_decline: bool, + preview: Option, confirm: ConfirmSemantics, }, ShortcutReply { @@ -999,6 +1007,72 @@ pub enum InteractionShortcutCountSpec { UntilLethal, } +/// The display family one shortcut-preview magnitude belongs to — the projection-layer code +/// for `game::derived_views::UnboundedFamily`, mapped by an exhaustive `match` in +/// `game::interaction`. +/// +/// A code rather than a mirror of `analysis::resource::ResourceAxis`, for this module's own +/// stated reason: `ResourceAxis` carries `PlayerId`, `ManaType`, `CounterClass`, +/// `ObjectClass` and `TriggerKind` payloads, and generating those would be the "second +/// generated copy of the existing engine wire graph" this file exists to avoid. The client +/// already labels these eleven families (glyph + i18n key per family), so a code is +/// everything a renderer needs. +/// +/// No CR governs a display grouping — the grouping authority is `derived_views::family_of`, +/// and this enum tracks it variant-for-variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] +pub enum InteractionShortcutPreviewFamily { + Mana, + Life, + Damage, + Mill, + Counters, + Tokens, + Cards, + Casts, + Combats, + Turns, + Triggers, +} + +/// One axis of what a declared shortcut count finishes with: a signed magnitude, already +/// multiplied out by the engine. +/// +/// `amount` is the FINISHED total, not a per-cycle rate, and it is signed — a drain loop +/// states its victim's life as negative. `player` is the seat the magnitude lands on for the +/// per-seat families (life, damage, mill, and the poison term of counters) and `None` for the +/// whole-game ones (mana, tokens, cards, casts, combats, turns, triggers). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] +pub struct InteractionShortcutPreviewEntry { + pub family: InteractionShortcutPreviewFamily, + pub player: Option, + pub amount: i32, +} + +/// CR 732.2a: the engine-computed consequence of repeating a certified loop a stated number +/// of times — "the predictable results of the sequence of choices", published as numbers. +/// +/// `count` is carried WITH the entries, and that pairing is the point: every magnitude here +/// is stated for exactly this count and for no other, so a renderer can never attach these +/// numbers to a different one. The engine multiplies; the display layer reads. +/// +/// Absent (`None` on the spec) when the offer states no per-period signature to multiply, or +/// states no finite count to multiply it by. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] +pub struct InteractionShortcutPreview { + pub count: u32, + pub entries: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] #[serde( diff --git a/crates/engine/tests/integration/battlefield_entry_authority_census.rs b/crates/engine/tests/integration/battlefield_entry_authority_census.rs index 316d606c14..023a8ba4e1 100644 --- a/crates/engine/tests/integration/battlefield_entry_authority_census.rs +++ b/crates/engine/tests/integration/battlefield_entry_authority_census.rs @@ -245,6 +245,10 @@ use std::path::Path; use super::loop_shortcut_offer_writer_census::{cfg_test_scoped_lines, rs_files}; +// The comment rule is NOT this file's any more: it moved to `src/source_census.rs`, which the +// crate's own unit-test censuses share through a plain `mod` and this binary reaches through a +// `#[path]` declaration in `main.rs`. One implementation, both venues. +use super::source_census::{code, code_span}; /// The bare anchor, ASSEMBLED AT RUNTIME so this file can never count its own text — the doctrine /// `loop_shortcut_offer_writer_census` files against its own superseded round-2 anchor. @@ -580,13 +584,18 @@ struct Hit { /// /// The shared walker for BOTH anchors. Keeping one copy is what guarantees the two pins agree on /// the comment rule, on the brace scan, and on the `cfg_test_scoped_lines` scope resolver. +/// +/// The needle is required in the line's CODE half ([`code_span`]), not merely in the line: a +/// whole-line-only exclusion counts a needle written after a trailing `//` as a construction. +/// Strictly more specific — `code_span` only ever removes comment text, and leaves a `//` that +/// follows a `"` in the code half. fn classify_anchor(src: &str, file: &str, needle: &str, keep: impl Fn(&str) -> bool) -> Vec { let scoped = cfg_test_scoped_lines(src); let lines: Vec<&str> = src.lines().collect(); lines .iter() .enumerate() - .filter(|(_, line)| line.contains(needle) && !line.trim_start().starts_with("//")) + .filter(|(_, line)| code(line).contains(needle)) .filter_map(|(n, _)| { let body = literal_body(&lines, n, needle); keep(&body).then(|| Hit { @@ -606,8 +615,9 @@ fn classify_anchor(src: &str, file: &str, needle: &str, keep: impl Fn(&str) -> b /// branch is what a spelling-based detector misses: `let from = None;` + `from,` constructs exactly /// the same event. /// -/// The comment-line exclusion (`!line.trim_start().starts_with("//")`) is reused verbatim from -/// `loop_shortcut_offer_writer_census::classify`'s measured rule: a comment writes no event, and a +/// The comment exclusion is shared with `loop_shortcut_offer_writer_census::classify` and +/// `loop_shortcut_seat_pin_census::sites_in_source` — all three now route it through +/// [`code_span`], so the rule is whole-line AND trailing: a comment writes no event, and a /// comment-blind anchor makes the tripwire fire on prose. fn classify(src: &str, file: &str) -> Vec { classify_anchor(src, file, &anchor(), |body| { @@ -675,6 +685,13 @@ fn call_tail(lines: &[&str], row: usize, from: usize) -> String { let mut row = row; let mut rest: &str = &lines[row][from..]; loop { + // DELIBERATELY NOT ROUTED THROUGH `source_census::code`, measured rather than overlooked. + // Routing it lets `call_tail` read THROUGH a `/* … */` sitting between the field and its + // call, which turns arm 5(vi)'s UNREADABLE tail into a readable `.extend(` and drops that + // arm from `(1, 1)` to `(0, 0)` — RUN, not reasoned. Arm 5(vi)'s contract is "a tail this + // function cannot read is COUNTED", and it is not this change's to redefine. The shared + // rule governs which text is a NEEDLE SITE; this function reads a TAIL, and the two + // questions come apart exactly here. let trimmed = rest.trim_start(); if !trimmed.is_empty() && !trimmed.starts_with("//") { return trimmed.chars().take(24).collect(); @@ -752,44 +769,6 @@ fn is_ambiguous_mutator(tail: &str) -> bool { .any(|verb| tail.starts_with(verb)) } -/// The byte range of `line` that is CODE: a LEADING `/* … */` comment and a TRAILING `//` comment -/// are excluded from the container search. -/// -/// Both exclusions only ever REMOVE text, and each is guarded so that it cannot remove code: -/// -/// * The leading form fires only when the TRIMMED line starts with `/*`, and drops exactly up to -/// and including the first `*/` — so `/*count=*/ state.last_created_token_ids.push(id)` keeps its -/// call (arm 5(viii)). -/// * The trailing form fires only when no `"` precedes the `//` in the remaining code — so -/// `let u = "http://x"; state.last_created_token_ids.push(id);` keeps its call (arm 5(viii)). -/// -/// The offsets are returned rather than a substring because [`call_tail`] indexes back into the -/// ORIGINAL line, and because the `/* … */` tail form of arm 5(vi) must still reach the classifier. -/// -/// Whatever survives both guards — a `//` after a quote, a `/* … */` opened mid-line, a string -/// literal quoting the defect, or the interior lines of a multi-line block comment — is still -/// scanned, and would ADD a hit. That direction is fail-CLOSED (spurious red, never a missed -/// publish); it is listed in this file's residuals. What is closed here are the two shapes this -/// change's own prose is most likely to take. -fn code_span(line: &str) -> (usize, usize) { - let trimmed = line.trim_start(); - let mut lo = 0usize; - if trimmed.starts_with("/*") { - let start = line.len() - trimmed.len(); - lo = match line[start..].find("*/") { - Some(end) => start + end + 2, - None => line.len(), - }; - } - let mut hi = line.len(); - if let Some(slash) = line[lo..].find("//") { - if !line[lo..lo + slash].contains('"') { - hi = lo + slash; - } - } - (lo, hi) -} - /// Classify every access to either anaphora container in `src` whose TAIL satisfies `keep`. /// /// The third anchor's walker, parameterised by the tail predicate so that @@ -807,14 +786,14 @@ fn classify_container_tails(src: &str, file: &str, keep: fn(&str) -> bool) -> Ve let lines: Vec<&str> = src.lines().collect(); let mut hits = Vec::new(); for (n, line) in lines.iter().enumerate() { - if line.trim_start().starts_with("//") { - continue; - } + // ONE comment rule, the shared one: a whole-line comment has an EMPTY code span, so the + // `starts_with("//")` test this used to carry beside it was a second policy saying the + // same thing — and a second place to get it wrong. let (lo, hi) = code_span(line); - let code = &line[lo..hi]; + let code_part = &line[lo..hi]; for needle in ANAPHORA_CONTAINERS { let mut from = 0usize; - while let Some(at) = code[from..].find(needle) { + while let Some(at) = code_part[from..].find(needle) { from += at + needle.len(); if keep(&call_tail(&lines, n, lo + from)) { hits.push(Hit { @@ -909,13 +888,22 @@ const FN_PREFIX_ALLOW_SET: [&str; 5] = [ /// signature's continuation (`) -> Option<…> {`) carries no bare `fn` token, so it is not collected. /// /// `Err` carries the extend-the-allow-set message for an unrecognised prefix. +/// +/// TOKENS COME FROM THE CODE HALF ([`code_span`]), as everywhere else in this binary: a trailing +/// comment naming `fn` on a column-0 code line would otherwise contribute a bare `fn` token and +/// resolve to an unrecognised prefix — loud rather than silent, but still the wrong answer. The +/// COLUMN-0 test deliberately stays on the ORIGINAL line: `code_span` skips a leading +/// `/* … */`, and testing the trimmed remainder for column 0 would drop a real header that +/// happens to carry one. fn top_level_fn_headers(src: &str) -> Result, String> { let mut out = Vec::new(); for (n, line) in src.lines().enumerate() { - if line.starts_with([' ', '\t']) || line.trim_start().starts_with("//") { + if line.starts_with([' ', '\t']) { continue; } - let tokens: Vec<&str> = line.split_whitespace().collect(); + // A whole-line comment yields an empty code half and therefore no `fn` token, so the + // shared rule subsumes the comment test that used to sit in the condition above. + let tokens: Vec<&str> = code(line).split_whitespace().collect(); let Some(at) = tokens.iter().position(|token| *token == "fn") else { continue; }; diff --git a/crates/engine/tests/integration/dina_noff_turn5_loader.rs b/crates/engine/tests/integration/dina_noff_turn5_loader.rs new file mode 100644 index 0000000000..c492f05058 --- /dev/null +++ b/crates/engine/tests/integration/dina_noff_turn5_loader.rs @@ -0,0 +1,159 @@ +//! The Dina "offers, no fast-forward" turn-5 4p board, loaded through the production restore +//! chokepoint — and the row that makes every later acceptance row on this board possible. +//! +//! # Fixture provenance +//! +//! `../fixtures/dina_noff_turn5_4p.json.gz` is derived, not captured. Root of trust is the +//! read-only pristine dump root, NOT any working-directory copy: +//! +//! | artifact | bytes | sha256 | +//! |---|---|---| +//! | `/home/lgray/vibe-coding/combofb-dumps-pristine/dina-conqueror-offers-no-ff.zip` (canonical; the sole entry, line 1, of that directory's `MANIFEST.sha256`) | 4 334 390 | `4a285dbf5184545507c0d80183c4b831b3d21738f96728bb9e8eaa942a007d43` | +//! | member `game-state-turn-5-2026-08-05T21-53-17-125Z.json` | 21 442 451 | `14e2fe515310ea34f6c1f52087a0ab274842a3bf69f951b3ceacb93c9a0ca660` | +//! | derived `dina_noff_turn5_4p.json.gz` (this fixture) | 844 846 | `9843d5165cbbf7dd7bca4171c7888c190b7eba7e52a2ed095b44ff76fadd7886` | +//! +//! Regeneration re-gzips from the archive and must reproduce those bytes exactly — `-n` is +//! load-bearing, since without it gzip stamps an mtime and the digest never lands: +//! +//! ```text +//! unzip -p game-state-turn-5-2026-08-05T21-53-17-125Z.json \ +//! | jq -c '{gameState}' | gzip -9 -n > crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gz +//! ``` +//! +//! The raw member is 21.4 MB and is deliberately NOT tracked; only the 845 KB `.json.gz` is. + +use engine::types::game_state::{GameState, PersistedGameState}; + +fn gunzip(gz: &[u8]) -> String { + use std::io::Read; + let mut json = String::new(); + flate2::read::GzDecoder::new(gz) + .read_to_string(&mut json) + .expect("fixture .json.gz must inflate to UTF-8 JSON"); + json +} + +/// Load the dump's `["gameState"]` through the REAL production restore chokepoint +/// `PersistedGameState::into_game_state` — never a bare `GameState` decode, which would skip +/// `reject_legacy_raw_prompt_authority` and `decode_persisted_resolution_state`. +fn load_dina_noff() -> GameState { + let json = gunzip(include_bytes!("../fixtures/dina_noff_turn5_4p.json.gz")); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("dump envelope parses as JSON"); + serde_json::from_value::(envelope["gameState"].clone()) + .expect("gameState deserializes through the production decoder") + .into_game_state() +} + +/// The saved ChaCha20 high-water this board carries (`gameState.rng_word_pos`). Also the +/// `current:` in the `HighWaterRegression` this board used to panic with on every load. +const DINA_NOFF_RNG_WORD_POS: u128 = 313; + +/// **Row 9, positive arm.** The chokepoint rehydrates: a load that ENDS at +/// `PersistedGameState::into_game_state`, as `load_dina_noff` does, leaves the LIVE stream at the +/// saved high-water, so a later export-time capture is legal instead of a rewind. Scope: that is +/// the chokepoint's own postcondition, not a claim that every shipped ingress ends here — +/// `server-core`'s `GameSession::from_persisted` re-seeds afterwards and zeroes `rng_word_pos` +/// with it, so the server ends at an agreed live-0 / high-water-0 pair rather than at this +/// resumed position. +/// +/// Non-vacuity: the reach-guard below pins the real board (4 seats, turn 5, the captured life +/// vector, a NON-ZERO saved high-water), so "no panic" cannot be satisfied by a degenerate or +/// empty state. Discrimination: deleting `state.rehydrate_rng()` from +/// `PersistedGameState::into_game_state` leaves the live stream at word 0 while `rng_word_pos` +/// stays 313, and `assert_eq!(live, state.rng_word_pos)` reds with `0 != 313` — measured, not +/// asserted. `capture_rng_word_pos` then `.expect`-panics on the same revert. +#[test] +fn c0_the_native_loader_rehydrates_the_persisted_rng_stream() { + let mut state = load_dina_noff(); + + // Reach-guard: this is the captured 4p board, not a default or empty state. + assert_eq!(state.players.len(), 4, "the real 4p board must have loaded"); + assert_eq!(state.turn_number, 5, "captured on turn 5"); + assert_eq!( + state.players.iter().map(|p| p.life).collect::>(), + vec![51, 29, 34, 34], + "the captured life vector identifies this exact board", + ); + assert_eq!( + state.rng_word_pos, DINA_NOFF_RNG_WORD_POS, + "the board must carry a NON-ZERO saved high-water, or the row measures nothing", + ); + + // The invariant the panic was only a signature of: live stream position == persisted + // high-water. Measuring it directly is deliberate rather than driving the board to a shuffle: + // every shuffle source it holds (Terramorphic Expanse, Evolving Wilds, Fabled Passage) is an + // ACTIVATED ability, so a pass-only driver reaches none of them, and a drive-based row here + // was measured VACUOUS twice before this one replaced it. + assert_eq!( + state.rng.get_word_pos(), + state.rng_word_pos, + "into_game_state must fast-forward the live ChaCha20 stream to the saved high-water", + ); + + // The production consequence, through the `pub` engine seam that used to blow up: the + // export-time capture every subsequent save performs. + state.capture_rng_word_pos(); + assert_eq!( + state.rng_word_pos, DINA_NOFF_RNG_WORD_POS, + "a capture at the restored position must not move the high-water", + ); +} + +/// **Row 9, WASM arm.** The WASM restore keeps its own `state.rehydrate_rng()` after the +/// chokepoint (in `engine-wasm`'s `restore_game_state`), so the load path now runs it twice. Safe +/// only because `rehydrate_rng` is idempotent — both of its statements are absolute assignments +/// from persisted fields (`rng = seed_from_u64(rng_seed)`, then `set_word_pos(rng_word_pos)`), so +/// it neither accumulates nor advances. This row measures that instead of trusting it, which is +/// why no `engine-wasm` edit is owed. +/// +/// Discrimination (RUN, both mutations): drop the reseed and make the fast-forward RELATIVE — +/// `self.rng.set_word_pos(self.rng.get_word_pos() + self.rng_word_pos)` — and the position +/// assertion reds `626 != 313` with the draw comparison behind it, because the second run +/// accumulates. Note that an absolute-but-wrong fast-forward (`set_word_pos(rng_word_pos + 1)`) +/// does NOT red here and is not what this row claims to catch: idempotence is a property of +/// ASSIGNMENT, so only an accumulating form can break it. +#[test] +fn c0_a_second_rehydrate_is_a_no_op_so_the_wasm_restore_may_repeat_it() { + use rand::RngCore; + + let mut once = load_dina_noff(); + let mut twice = load_dina_noff(); + twice.rehydrate_rng(); // the WASM restore's own repeat, on top of the chokepoint's + + assert_eq!( + twice.rng.get_word_pos(), + once.rng.get_word_pos(), + "a repeated rehydrate must not move the stream", + ); + assert_eq!( + twice.rng_word_pos, once.rng_word_pos, + "a repeated rehydrate must not move the persisted high-water", + ); + + // Position equality alone would survive a same-position-different-keystream bug, so compare + // the values the two streams actually produce. + for draw in 0..5 { + assert_eq!( + twice.rng.next_u32(), + once.rng.next_u32(), + "double-rehydrated stream diverged at draw {draw}", + ); + } +} + +/// **Row 9, negative control.** The same board, one axis changed: the live stream rewound to word +/// 0, which is exactly the state a decode produced before the chokepoint rehydrated. It panics. +/// +/// Without this arm the positive arm above is a claim that a call does not panic, with no evidence +/// that it ever could on this board. +#[test] +#[should_panic(expected = "HighWaterRegression")] +fn c0_an_unrehydrated_stream_on_the_same_board_still_panics() { + let mut state = load_dina_noff(); + // Re-create the pre-rehydrate live position. `advance_rng_high_water` rejects on + // `requested < rng_word_pos`, and `requested` is read from the live stream — so the position + // is the whole failing condition. + state.rng.set_word_pos(0); + state.capture_rng_word_pos(); +} diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index 9ee39ca1c9..69ccb7deed 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -87,6 +87,14 @@ fn gunzip(gz: &[u8]) -> String { /// `decode_restored_game_state` funnel through it) — never a bare `GameState` decode, which /// would skip `reject_legacy_raw_prompt_authority` and `decode_persisted_resolution_state`. /// +/// The chokepoint now rehydrates the `#[serde(skip)]` ChaCha20 stream, which it did not always: +/// the repair lived only in `engine-wasm`'s `restore_game_state`, so a load that ENDED at the +/// chokepoint — as `load_f4` does — left the live stream rewound to word 0 under a saved +/// `rng_word_pos` of 379. Every caller now inherits it and WASM's own call became an idempotent +/// repeat. It does NOT make the shipped load paths identical: `server-core`'s +/// `GameSession::from_persisted` re-seeds afterwards and zeroes `rng_word_pos` with it, so the +/// server deliberately DISCARDS the saved position instead of resuming it as `load_f4` does. +/// /// The dump was captured with the detector OFF; every row here is about the CR 732.2a /// interactive offer, so the mode is set to `Interactive` at load — the same thing the user's /// own toggle does. @@ -210,16 +218,25 @@ fn resolve_by_name(state: &GameState, name: &str) -> ObjectId { /// One beat of the F4 drive policy, every beat crossing the public `apply()` boundary. /// /// At `Priority` ALWAYS pass: the mandatory chain resolves and re-triggers, and that IS the -/// loop — casting here wanders off it. At Torch's CR 608.2b target choice aim **P1** (a +/// loop — casting here wanders off it. At Torch's CR 608.2b target choice aim `seat` (a /// CONSTANT seat, so the cycle is board-stable and the detector can certify it); at either /// CR 603.5 "may" prompt TAKE (declining Sue's token breaks the chain to Reed). /// +/// The aimed seat is a PARAMETER, not a constant, so a row can prove the journal FOLLOWS the +/// announcement instead of coinciding with one hard-coded seat. MEASURED: constant P1, +/// constant P2 and constant P3 all certify and reach the offer; it is the VARIATION between +/// iterations, not the seat, that blocks certification. +/// /// ⚠ This is deliberately NOT `loop_shortcut.rs`'s shared `dump_drive_one_beat`: that helper's /// victim preference matches `GameAction::SelectTargets`, and this dump raises /// `GameAction::ChooseTarget`, so its pin is inert here and its "first legal non-terminal /// action" fallback answers Sue's "may" with whichever `DecideOptionalEffect` is enumerated /// first. MEASURED: under that policy this dump reaches no offering beat at all. fn f4_drive_one_beat(state: &mut GameState) -> Result<(), String> { + f4_drive_one_beat_at(state, P1) +} + +fn f4_drive_one_beat_at(state: &mut GameState, seat: PlayerId) -> Result<(), String> { let who = state .waiting_for .acting_player() @@ -236,7 +253,7 @@ fn f4_drive_one_beat(state: &mut GameState) -> Result<(), String> { .find(|a| { matches!( a, - GameAction::ChooseTarget { target: Some(TargetRef::Player(p)) } if *p == P1 + GameAction::ChooseTarget { target: Some(TargetRef::Player(p)) } if *p == seat ) }) .or_else(|| { @@ -261,11 +278,15 @@ fn f4_drive_one_beat(state: &mut GameState) -> Result<(), String> { /// beat index. The beat is SEARCHED, never hardcoded — a hardcoded index is a fixture that /// drifts silently when the drive policy moves. fn drive_f4_to_offer(state: &mut GameState, cap: u32) -> Option { + drive_f4_to_offer_at(state, cap, P1) +} + +fn drive_f4_to_offer_at(state: &mut GameState, cap: u32, seat: PlayerId) -> Option { for beat in 0..cap { if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { return Some(beat); } - f4_drive_one_beat(state).ok()?; + f4_drive_one_beat_at(state, seat).ok()?; } None } @@ -292,7 +313,7 @@ fn offer_parts( /// point, `owner` and `count` supplied by the caller. /// /// This is the shape `handle_declare_shortcut` ACCEPTS (measured in -/// [`u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_shape_is_one_it_never_builds`]), +/// [`u6_the_generators_own_candidate_opens_the_window_and_the_accepted_shape_is_measured`]), /// so every row that needs either an accepted declaration or a one-axis hostile variant of one /// builds it here rather than re-deriving the mapping. Keyed off `schema.points` — never off a /// hard-coded slot — so a re-dump that renumbers objects, or a remedy that widens the announced @@ -307,7 +328,8 @@ fn f4_pin_template( count: u32, ) -> engine::analysis::decision_template::DecisionTemplate { use engine::analysis::decision_template::{ - DecisionGroupKey, DecisionTemplate, MayChoiceOption, PinnedDecision, ReplayMode, TargetPin, + AnnouncementSubject, DecisionGroupKey, DecisionTemplate, MayChoiceOption, PinnedDecision, + Ranking, ReplayMode, TargetPin, TargetSchedule, }; DecisionTemplate { owner, @@ -323,9 +345,17 @@ fn f4_pin_template( // chosen when the trigger goes on the stack and re-checked for legality at // each resolution. P1 is the constant seat `f4_drive_one_beat` aims at and is // living on this board, so the pin stays legal for every driven cycle. + // + // CR 601.2c: "target opponent" makes this an ANNOUNCED target, so the + // reference spells the TARGET class — a one-entry `Ranking` naming the seat — + // and not the CR 115.10a `TargetPin::Player` choice class. This literal is + // the conformance oracle row D1 compares the live publisher against, so it + // has to track the publisher's spelling exactly. DecisionPointKind::Targets { .. } => PinnedDecision::Targets { slot: p.slot.clone(), - targets: vec![TargetPin::Player(P1)], + targets: vec![TargetPin::Scheduled(TargetSchedule::Constant( + Ranking::one(AnnouncementSubject::Seat(P1)), + ))], }, other => panic!("unexpected point kind {other:?}"), }) @@ -353,6 +383,59 @@ fn replay_at_priority(state: &GameState, proposer: PlayerId) -> GameState { replay } +// ───────────────────────────────────────────────────────────────────────────────────────── +// C0 — the tracked F4 dump's RNG stream survives the restore chokepoint +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// **Row 9, tracked-loader arm.** The RNG chokepoint gap was never confined to the untracked Dina +/// board: this TRACKED dump carries `rng_word_pos = 379` and used to restore with the live stream +/// at word 0, so the very next export-time `capture_rng_word_pos` panicked +/// `HighWaterRegression { current: 379, requested: 0 }`. Every row that loads an F4 board loads +/// through `load_f4`, so the gap sat under all of those. NOT literally every row here: `b5f_` and +/// `m1_` load through `load_mode1`, `a1_` through `load_mode2`, and `c1_` loads no board at all +/// (it walks source). Stated as loaders rather than as a count, because a count rots on the next +/// row added and this sentence has already been false once. Scope: this row measures the CHOKEPOINT's +/// postcondition, which is not every shipped ingress's postcondition — `server-core`'s +/// `GameSession::from_persisted` re-seeds after the chokepoint and zeroes `rng_word_pos` with it, +/// ending at an agreed live-0 / high-water-0 pair rather than at this row's resumed position. +/// +/// Two-sided on one axis, like its Dina sibling: the restored stream is AT the high-water and the +/// capture is legal; the same board with the live position rewound to 0 — the exact pre-fix decode +/// state — still panics (`c0_the_unrehydrated_tracked_f4_dump_still_panics`). Revert-probe: +/// deleting `state.rehydrate_rng()` from `PersistedGameState::into_game_state` reds the +/// `get_word_pos() == rng_word_pos` assertion with `0 != 379`. +#[test] +fn c0_the_tracked_f4_dump_restores_a_coherent_rng_stream() { + let mut state = load_f4(); + + // Reach-guard: the real board, carrying a NON-ZERO saved high-water. + assert_eq!(state.players.len(), 4, "the real 4p board must have loaded"); + assert_eq!( + state.rng_word_pos, 379, + "the tracked F4 dump's captured ChaCha20 high-water", + ); + assert_eq!( + state.rng.get_word_pos(), + state.rng_word_pos, + "into_game_state must fast-forward the live stream on the TRACKED dump too", + ); + + state.capture_rng_word_pos(); + assert_eq!( + state.rng_word_pos, 379, + "a capture at the restored position must not move the high-water", + ); +} + +/// The negative control for the row above: without the rehydrate the same board panics. +#[test] +#[should_panic(expected = "HighWaterRegression")] +fn c0_the_unrehydrated_tracked_f4_dump_still_panics() { + let mut state = load_f4(); + state.rng.set_word_pos(0); + state.capture_rng_word_pos(); +} + // ───────────────────────────────────────────────────────────────────────────────────────── // R18 — fail-loud fixture name resolution // ───────────────────────────────────────────────────────────────────────────────────────── @@ -462,8 +545,15 @@ fn panic_message(payload: &Box) -> String { /// fail-closing on. Any surviving cross-reference to `r2` resolves to nothing. /// /// This row keeps the half it always owned — the offer fires, and its bound arithmetic is -/// correct. `r2b`/`r3`/`r4`/`r5` and the interruptibility pair are still unwritten: no `fn r2b_`, -/// `fn r3_`, `fn r4_` or `fn r5_` row exists in this file. +/// correct. `r2b`/`r4`/`r5` and the interruptibility pair are still unwritten: no `fn r2b_`, +/// `fn r4_` or `fn r5_` row exists in this file, and `interruptibility` appears nowhere in it +/// outside this sentence. **`r3` IS written** — +/// `fn r3_placement_a_restored_foreign_owner_declaration_is_refused`, added by the same commit +/// that made a template-free declaration resolve against the offer's published declaration. That +/// commit is why this sentence needed repairing at all: it was measured true when written, and a +/// row added later falsified it silently, because no sweep in this lane reads prose under +/// `crates/engine/`. Locate the row by NAME — this is a measurement of one tree, not a standing +/// property, and the next row added re-opens it. /// /// # What the assertion is bound to, and why it is not `f(x) == f(x)` /// @@ -604,6 +694,182 @@ fn r1_the_bounded_offer_fires_on_the_real_f4_dump() { ); } +/// **Row R2-a — REAL DUMP.** The MAINTAINED-INVARIANT row for the provenance split: after both +/// TARGET-class producers moved to the ranked spelling, this real 4p board still fires its +/// CR 732.2a bounded offer, still publishes a `Some` declaration, still carries the same bound — +/// and Torch's `Targets` pin is now the CR 601.2c TARGET-class spelling. +/// +/// # The two halves, and why one without the other is worthless +/// +/// `declaration.is_some()` alone passes on the OLD spelling, so it cannot see the migration at +/// all. The pin-VALUE assertion alone would pass on a publisher that emitted the right shape +/// while the offer machinery had quietly broken. Both are asserted, on one board, in one run. +/// +/// # Discrimination +/// +/// REVERT-PROBE (the commit itself): restore `record_trigger_target_answer`'s player arm to +/// `Some(TargetPin::Player(*pl))` ⇒ the journal holds the CHOICE-class spelling ⇒ +/// `build_bounded_declaration` copies it through ⇒ the pin-value assertion FAILS while +/// `is_some()` stays green. That asymmetry is the row. +/// +/// # The hostile arm, and the ORDERING that makes it reachable +/// +/// The split's whole content is WHICH AUTHORITY judges a seat, so the hostile fixture makes the +/// seat untargetable and requires the declaration to be REFUSED. The hexproof is applied AFTER +/// the real drive has latched the pin, and the ordering is load-bearing rather than convenient: +/// Torch's "target opponent" has three legal opponents on this board, so a board that was +/// hexproofed BEFORE the drive would let the announcement name a different opponent, and the +/// row would be measuring the announcement's choice instead of the pin's legality. Latch first, +/// then remove the seat from the target set, is also the CR 115.7a shape — "the original target +/// is unchanged, even if the original target is itself illegal by then". +/// +/// PAIRED POSITIVE, same board, same instrument: `validate_pins` on the very same +/// (schema, declaration) pair is `Ok` BEFORE the grantor lands. Without it, `Err` afterwards is +/// equally explained by a seat pin that never validates at all. +/// +/// REVERT-PROBE (hostile arm): the same restore of the producer ⇒ the pin is a +/// `TargetPin::Player`, `resolve_target`'s CHOICE arm asks existence only, the hexproof is not +/// consulted, `validate_pins` returns `Ok` ⇒ the refusal assertion FAILS. This is the real-dump +/// sibling of the resolver-level row in `loop_shortcut_ranking.rs`. +#[test] +fn r2a_split_the_bounded_offer_still_publishes_a_ranked_seat_pin_and_refuses_a_hexproofed_one() { + use engine::analysis::decision_template::{ + validate_pins, AnnouncementSubject, PinnedDecision, Ranking, TargetPin, TargetSchedule, + }; + use engine::types::ability::{ControllerRef, StaticDefinition, TypedFilter}; + use engine::types::game_state::LayersDirty; + use engine::types::identifiers::CardId; + use engine::types::statics::StaticMode; + use engine::types::zones::Zone; + + let mut state = load_f4(); + let beat = drive_f4_to_offer(&mut state, 400) + .expect("REACH-GUARD: the bounded offer must still FIRE after the provenance split"); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + + assert_eq!( + schema.max_iterations, 18, + "MAINTAINED INVARIANT: the CR 704.5a-derived bound at beat {beat} is unchanged by a \ + change of pin SPELLING — the split moves which authority judges a seat, not how much \ + the loop consumes" + ); + + let declaration = offer_declaration(&state) + .expect("MAINTAINED INVARIANT: the offer still publishes a declaration"); + assert_eq!( + declaration.owner, proposer, + "reach-guard: the published declaration is the proposer's own" + ); + + let target_slot = schema + .points + .iter() + .find(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) + .map(|p| p.slot.clone()) + .expect("reach-guard: the offer publishes Torch's CR 601.2c Targets point"); + let pinned = declaration + .decisions + .iter() + .find_map(|pin| match pin { + PinnedDecision::Targets { slot, targets } if *slot == target_slot => Some(targets), + _ => None, + }) + .expect("reach-guard: the declaration pins the published Targets slot"); + assert_eq!( + *pinned, + vec![TargetPin::Scheduled(TargetSchedule::Constant( + Ranking::one(AnnouncementSubject::Seat(P1)) + ))], + "CR 601.2c: Torch's announced opponent is a TARGET, so the published pin carries the \ + TARGET-class spelling. Without this half the row passes unchanged on the pre-split \ + `TargetPin::Player(P1)`" + ); + + // ── PAIRED POSITIVE: the pin is LEGAL against the offer's own schema, before the hostile + // change lands ── + assert!( + validate_pins(&schema, &declaration, schema.max_iterations, &state).is_ok(), + "paired positive: the ranked pin validates at the FULL declared range on the \ + un-hexproofed board — otherwise the refusal below is explained by a seat pin that \ + never validates at all" + ); + + // ── HOSTILE: P1 gains hexproof from a permanent P1 controls, AFTER the pin is latched ── + let mut hostile = state.clone(); + // Built with production `zones::create_object` rather than a raw `objects.insert`: a raw + // insert never joins `state.battlefield`, so the grantor would be invisible to + // `game_functioning_statics` and the hexproof would silently never apply. + let grantor = engine::game::zones::create_object( + &mut hostile, + CardId(9401), + P1, + "You Have Hexproof Source".to_string(), + Zone::Battlefield, + ); + hostile + .objects + .get_mut(&grantor) + .expect("the grantor was just created") + .static_definitions = vec![StaticDefinition::new(StaticMode::Hexproof).affected( + engine::types::ability::TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::You), + ), + )] + .into(); + // MEASURED, and the reach-guard below is what caught it: after a completed drive this + // board's `layers_dirty` is `Clean`, and `create_object` does not re-dirty it — so a bare + // `flush_layers` returns immediately, `refresh_static_mode_presence` never runs, and the + // O(1) `static_mode_presence` gate answers `false` for `Hexproof` no matter what the + // grantor carries. Marking the pass dirty is fixture bookkeeping, not a rule: it requests + // exactly the re-evaluation an ETB would have requested. + hostile.layers_dirty = LayersDirty::Full; + engine::game::layers::flush_layers(&mut hostile); + + // The grant must actually bite at the TARGET seam, or the refusal below proves nothing. + // CR 702.11c is opponent-scoped, so it is asked with Torch's own controller as the source + // controller — the same question `evaluate_schedule`'s `Seat` arm asks. + let torch = resolve_by_name(&hostile, TORCH); + let torch_controller = hostile.objects[&torch].controller; + assert!( + engine::game::players::is_opponent(&hostile, P1, torch_controller), + "reach-guard: CR 702.11c only excludes OPPONENTS' spells and abilities, so Torch's \ + controller {torch_controller:?} must be P1's opponent" + ); + assert!( + engine::game::static_abilities::player_cannot_be_targeted_by( + &hostile, + P1, + torch, + torch_controller + ), + "reach-guard: the hexproof grant must bite at the TARGET seam for Torch's ability. \ + grantor_on_battlefield={} player_has_hexproof={} — if the second is false while the \ + first is true, the layers pass did not re-run and the O(1) `static_mode_presence` \ + gate is stale", + hostile.battlefield.contains(&grantor), + engine::game::static_abilities::player_has_hexproof(&hostile, P1), + ); + assert!( + !engine::game::static_abilities::player_cannot_be_targeted_by( + &hostile, + PlayerId(2), + torch, + torch_controller + ), + "reach-guard: a DIFFERENT seat on the same board is still targetable, so the exclusion \ + above is the hexproof and not a blanket refusal" + ); + + assert!( + validate_pins(&schema, &declaration, schema.max_iterations, &hostile).is_err(), + "CR 601.2c + CR 702.11c: a TARGET-class seat that has become untargetable is an \ + ILLEGAL pin value, so the declaration is REFUSED rather than driven at a wrong seat. \ + Under the pre-split `TargetPin::Player` this returns Ok — existence alone — which is \ + exactly the over-veto-free CHOICE authority the split moved this pin off" + ); +} + /// §6 R1, SECOND HALF — the published point set, pinned so it cannot drift silently. /// /// R1 as written expects `points ≡ {Targets(403 Torch), MayChoice(401 Reed), @@ -835,6 +1101,253 @@ fn r2a_an_accepted_declaration_commits_exactly_n_cycles_because_reeds_may_is_ann ); } +/// **R3-a** — the CR 732.2a EPISODE BOUNDARY, driven on the real dump: a completed drive +/// hands back at the priority point with the detection window CLEARED (`loop_detect_ring` +/// empty, `loop_answer_journal == None`), and this same `apply()` does NOT re-offer. +/// +/// The seam is the drive-end block in `game::engine` — `*state = committed;`, then the ring +/// clear and journal clear, then the priority handback. That is **site 2** of the eight +/// ring-clear sites [`c1_every_ring_clear_site_also_clears_the_loop_answer_journal`] +/// enumerates, and that census covers site 2 STRUCTURALLY only (its own doc says so). This +/// row drives it. +/// +/// # Why the f4 board, and why it is not substitutable +/// +/// Four shipped fixtures reach this seam. MEASURED, this dump is the only one whose journal +/// is non-empty there (`answers=3`; the three `loop_shortcut.rs` fixtures arrive at +/// `answers=0`). The `loop_answer_journal` half of the claim is therefore unpinnable +/// anywhere else — which is what makes this row REAL-DUMP rather than convenient. The ABORT +/// entry to the same seam is covered where its fixtures already live, on +/// `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle` in `loop_shortcut.rs`. +/// +/// # Discrimination — REVERT-PROBE, RUN, not adopted from a code read +/// +/// Delete the seam's `state.loop_detect_ring.clear();` + `state.loop_answer_journal = None;` +/// ⇒ MEASURED `ring=12, answers=3, wf=LoopShortcut` against this drive's `0, 0, Priority`: +/// all three assertions below flip together and the engine re-offers within the same +/// `apply()`. +/// +/// The ANTI-PROBE, also run: deleting `apply_action`'s PRE-ACTION clear instead leaves the +/// final state MEASURED-unchanged at `0, 0, Priority`. This row keys on the drive-end seam +/// and not on the upstream clear, and must not be attributed to it. +/// +/// ⚠ **Do NOT assert that the ring/journal are non-empty immediately before the seam.** +/// MEASURED: they read `0/0` at the post-declare beat, because `apply_action`'s pre-action +/// clear fires on `DeclareShortcut`. The `12/3` the seam itself receives is internal and +/// unobservable from a test. The paired positive below is taken at the OFFER beat, which is +/// observable. +/// +/// ⚠ **Do NOT add a revert-probe on the `WaitingFor::Priority` re-seat** that follows the +/// clear: MEASURED VACUOUS on all four fixtures reaching this seam — they are already at +/// `Priority` on entry. The seam's own comment block carries that as labelled +/// interpretation, deliberately not as a pinned claim. +#[test] +fn r3a_the_accepted_drive_ends_at_the_priority_point_with_the_window_cleared() { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + + // ── PAIRED POSITIVE (i): the window is LIVE at the offer beat, same board, same run. + // MEASURED at `2160f6e2c`: ring=9, answers=3. Without it every zero below is + // satisfiable by a board that never sampled or never answered a `may`. ── + let ring_at_offer = state.loop_detect_ring.len(); + let answers_at_offer = state.loop_answers_recorded(); + assert!( + ring_at_offer > 0 && answers_at_offer > 0, + "paired positive: at the CR 732.2a offer beat this board must carry BOTH a populated \ + detection ring and a populated CR 603.5 answer journal, else the cleared-window \ + assertions after the drive are vacuous. ring={ring_at_offer} answers={answers_at_offer}" + ); + + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + let template = f4_pin_template(&schema, proposer, 3); + apply( + &mut state, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(3), + template: Some(template), + }, + ) + .expect("the declaration is dispatched"); + // Reach-guard, not the claim: a REFUSED declaration hands priority straight back, and + // then the cleared window below would be the pre-action clear's work rather than the + // drive-end seam's. + assert!( + matches!(state.waiting_for, WaitingFor::RespondToShortcut { .. }), + "reach-guard: the declaration carrying the full published pin set must be accepted \ + and open the CR 732.2b window, got {:?}", + state.waiting_for + ); + let responders = accept_all_opponents(&mut state); + assert!( + responders > 0, + "reach-guard: the CR 732.2b response window must actually have opened and been \ + answered — the shortcut is taken only once the last opponent has accepted \ + (CR 732.2c) — else the drive never ran and no seam was reached" + ); + + // ── THE CLAIM: the drive ended at the CR 732.2a ending point with the window discarded ── + assert!( + state.loop_detect_ring.is_empty(), + "CR 732.2a: the accepted drive ends at the ending point with the detection window \ + DISCARDED, so the next episode re-detects from scratch. ring still carries {} \ + sample(s) (it carried {ring_at_offer} at the offer beat)", + state.loop_detect_ring.len() + ); + assert_eq!( + state.loop_answers_recorded(), + 0, + "CR 603.5: the recorded `may` answers describe the window that just ended, and the \ + drive-end seam drops them with the ring (it carried {answers_at_offer} at the offer \ + beat)" + ); + assert!( + matches!(state.waiting_for, WaitingFor::Priority { .. }), + "CR 732.2a: the ending point of the taken sequence is a place where a player has \ + priority — and a `LoopShortcut` here would be the re-offer the seam's ring clear \ + exists to prevent. got {:?}", + state.waiting_for + ); + + // ── PAIRED POSITIVE (ii): the sampler is still ON at handback, so an empty ring is a + // CLEARED ring and not a disabled detector. ── + assert!( + state.loop_detection.samples(), + "paired positive: the detector must still be sampling after the handback ({:?}), \ + else `ring.is_empty()` above says nothing about the seam", + state.loop_detection + ); +} + +/// **R3-b, DRIVEN ARM** — the CROSS-EPISODE CARRIER claim, taken on the real 4-player board +/// across a whole accepted drive rather than at helper level. +/// +/// `analysis::decision_template::DecisionKind`'s doc states that a `LoopChoice` template +/// SURVIVES the CR 603.3b batch boundary and is therefore the vehicle a later episode's +/// declaration rides. Its sibling `loop_shortcut_ranking::r3b_*` states the same property at +/// the seam — it calls `clear_ephemeral_trigger_order_templates()` directly — which pins WHICH +/// CELL the predicate removes but says nothing about whether an accepted production drive ever +/// reaches that predicate, or reaches it only once, or leaves the survivor intact afterwards. +/// This row is that missing half: `DeclareShortcut` → the full CR 732.2b APNAP window → +/// `apply_confirmed_shortcut` → `materialize_fixed_shortcut`, every beat through `apply()`. +/// +/// # Non-vacuity +/// +/// The `TriggerOrdering` + ephemeral cell is the paired positive: it is REMOVED by the same +/// drive that keeps the `LoopChoice` one, so "the drive never reached the boundary" and "the +/// drive dropped everything" both fail here. MEASURED `3 → 2`. +/// +/// # Discrimination +/// +/// The asserted vector is two-sided, and each side names the mutant that flips it: +/// +/// * drop the seam's `kind ==` conjunct ⇒ the `(LoopChoice, ephemeral)` element disappears; +/// * never reach the seam at all ⇒ the `(TriggerOrdering, ephemeral)` element is still there. +/// +/// The second is MEASURED by this row passing (`3 → 2`, with that cell and only that cell +/// gone). The first is attributed rather than mutated HERE, and the attribution is licensed by +/// a census rather than by a code read: over `crates/engine/src` the only `retain` on a LIVE +/// `decision_templates` is `GameState::clear_ephemeral_trigger_order_templates` — `visibility`'s +/// retain runs on the per-viewer CLONE (`filtered.decision_templates`), and no other site +/// clears, drains, removes or reassigns the Vec. So a drive that demonstrably removed one cell +/// ran that predicate, and the survivor beside it is that predicate's `kind ==` conjunct doing +/// work. The predicate-level mutant itself is RUN on the seam-level sibling +/// `loop_shortcut_ranking::r3b_*`, which is where a production-source mutation belongs. +/// +/// The planted cells are inert as far as the drive is concerned — they key on a source no F4 +/// trigger raises — so they observe the boundary without steering it. +#[test] +fn r3b_driven_a_loop_choice_carrier_survives_a_whole_accepted_f4_drive() { + use super::loop_shortcut_ranking::grid_template; + + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + let template = f4_pin_template(&schema, proposer, 3); + + // Planted at the OFFER beat, keyed to a real battlefield object resolved BY NAME so a + // re-dump that renumbers `ObjectId`s flows through (see `resolve_by_name`). + // + // The plant is purely ADDITIVE, and that is ASSERTED rather than assumed: had the drive + // left real templates here, overwriting them could steer the very drive this row observes, + // and the survivor set below would be reporting the fixture's own damage. + let anchor = resolve_by_name(&state, THING); + assert!( + state.decision_templates.is_empty(), + "reach-guard: the F4 drive reaches its offer beat carrying NO templates, so the grid \ + below is planted onto an empty vector and displaces nothing; got {:?}", + state + .decision_templates + .iter() + .map(|t| (t.key.kind, t.key.is_ephemeral())) + .collect::>() + ); + state.decision_templates = vec![ + grid_template(P0, DecisionKind::LoopChoice, true, anchor), + grid_template(P0, DecisionKind::TriggerOrdering, true, anchor), + grid_template(P0, DecisionKind::TriggerOrdering, false, anchor), + ]; + let cells = |state: &GameState| -> Vec<(DecisionKind, bool)> { + state + .decision_templates + .iter() + .map(|t| (t.key.kind, t.key.is_ephemeral())) + .collect() + }; + assert_eq!( + cells(&state), + vec![ + (DecisionKind::LoopChoice, true), + (DecisionKind::TriggerOrdering, true), + (DecisionKind::TriggerOrdering, false), + ], + "reach-guard on the INSTRUMENT: both axes must be genuinely distinguishable on the real + board too, else 'exactly one cell removed' could be an artefact of three identical keys" + ); + + apply( + &mut state, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(3), + template: Some(template), + }, + ) + .expect("the declaration is dispatched"); + assert!( + matches!(state.waiting_for, WaitingFor::RespondToShortcut { .. }), + "reach-guard: the declaration carrying the full published pin set must be accepted and \ + open the CR 732.2b window, got {:?}", + state.waiting_for + ); + let responders = accept_all_opponents(&mut state); + assert!( + responders > 0, + "reach-guard: the CR 732.2b window must actually have opened and been answered \ + (CR 732.2c), else no drive ran and no batch boundary was crossed" + ); + assert!( + matches!(state.waiting_for, WaitingFor::Priority { .. }), + "reach-guard: the accepted drive ran to its CR 732.2a ending point, got {:?}", + state.waiting_for + ); + + assert_eq!( + cells(&state), + vec![ + (DecisionKind::LoopChoice, true), + (DecisionKind::TriggerOrdering, false), + ], + "CR 732.2a + CR 603.3b: across a whole ACCEPTED drive the ephemeral `LoopChoice` \ + carrier SURVIVES — it is the cross-episode vehicle P4 rides — while the ephemeral \ + `TriggerOrdering` cell beside it is dropped at the batch boundary the drive crosses. \ + A missing `LoopChoice` means the retain predicate lost its KIND conjunct; a surviving \ + ephemeral `TriggerOrdering` means the drive never reached the boundary at all" + ); +} + // ───────────────────────────────────────────────────────────────────────────────────────── // R23 conjunct (5-reach) — the beat guard's reachability on the real dump // ───────────────────────────────────────────────────────────────────────────────────────── @@ -1312,119 +1825,978 @@ fn r27_a1_the_f4_dumps_recorded_sample_keeps_a_live_half_normalization_would_hav } // ───────────────────────────────────────────────────────────────────────────────────────── -// helpers used by more than one row +// C1 — the CR 603.5 "may"-answer journal +// +// TIER, stated so no row here is read as covering more than it does: C1 ships the journal +// (record + read) and nothing that CONSUMES it. `build_bounded_declaration` and the offer's +// published `declaration` arrive with C2, so every row below asserts at the JOURNAL, never +// at a minted-or-refused declaration. // ───────────────────────────────────────────────────────────────────────────────────────── -/// Count the stack entries whose triggered ability is CR 603.5 `optional`. Used as a reach -/// guard where a row's claim is about the optional gate. -fn optional_entries(state: &GameState) -> usize { - state - .stack - .iter() - .filter(|e| match &e.kind { - StackEntryKind::TriggeredAbility { ability, .. } => ability.optional, - _ => false, - }) - .count() +/// The CR 603.5 "may" SLOT the journal keys on. The source half is built the way +/// `game::engine::object_decision_source` builds it (CR 400.7: `ThisObject` bound to the +/// object's CURRENT incarnation, `trigger_description` held `None`) and is reconstructed +/// here rather than called because the engine's helper is `pub(crate)`; every row that uses +/// it asserts the reconstruction is faithful by requiring the production write site to have +/// stored something under it. The SUB-INDEX half is not reconstructed at all — it comes from +/// the engine's own `DecisionSlot::may`, the same constructor the publisher and the +/// `DecideOptionalEffect` writer use, so this key cannot drift from theirs. +fn may_source_key( + state: &GameState, + source_id: ObjectId, +) -> engine::analysis::decision_template::DecisionSlot { + engine::analysis::decision_template::DecisionSlot::may( + engine::types::game_state::YieldTarget::ThisObject { + source_id, + incarnation: Some(state.objects[&source_id].incarnation), + trigger_description: None, + }, + ) } -// ───────────────────────────────────────────────────────────────────────────────────────── -// U6 — the AI's candidate set at the real F4 offer, and what the engine does with it -// -// Reachability of the seam under test: `phase_ai::search::choose_action` dispatches -// `WaitingFor::LoopShortcut { .. } => engine::ai_support::legal_actions(state)` -// (`crates/phase-ai/src/search.rs`), and `legal_actions` funnels into the -// `WaitingFor::LoopShortcut` arm of `engine::ai_support::candidates`. These rows drive the -// REAL dump to the REAL offer and measure that arm's output there, plus what -// `handle_declare_shortcut` does with each member of it. -// -// ⚠ MEASURED SCOPE. §5 U6 as planned expects a declare candidate "whose template pins all -// three F4 slots (or declines)". F4 does publish all THREE slots — `r1b` pins -// `[Sue MayChoice, Reed MayChoice, Torch Targets]` — and the measured answer is still the -// SECOND branch: the AI DECLINES, because the only declaration it can emit is one the engine -// refuses outright. The generator builds no pinning template at ALL (its only `Fixed` candidate -// carries `template: None`), so a published set of three is exactly as unreachable for it as a -// set of one would have been — the count is not what excludes it, its emptiness gate is. These -// rows pin that, name the two independent reasons, and pin the accepted shape the generator -// never emits — they do not assert the planned prediction. -// ───────────────────────────────────────────────────────────────────────────────────────── +/// How the drive answers CR 603.5 "may" prompts. Typed rather than a pair of `bool`s: the +/// three rows below need three genuinely different drive shapes, and each is named. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MayPolicy { + /// Take every prompt and drive on to the bounded offer — the shipped F4 policy. + TakeAll, + /// Take every prompt, and STOP at the first prompt that repeats a (source, seat) pair. + TakeUntilRepeat, + /// Take every prompt, then DECLINE the first prompt that repeats a (source, seat) pair, + /// and stop there. + DeclineOnRepeat, +} + +/// One answered "may" prompt, as the drive saw it. +struct MayBeat { + key: engine::analysis::decision_template::DecisionSlot, + seat: PlayerId, + take: bool, + /// The journal entry for this (source, seat) pair BEFORE this beat was answered — the + /// evidence that a "repeat" beat really is a repeat. + before: Option, +} + +/// Drive the F4 dump under `policy`, answering "may" prompts directly (so the row controls +/// the answer) and delegating every other beat to [`f4_drive_one_beat`]. +/// +/// The repeat-stopping policies stop AT the beat that lands, deliberately: a later +/// deliberate action or non-forced window would clear the ring, and the journal follows it. +fn drive_f4_may_beats(state: &mut GameState, cap: u32, policy: MayPolicy) -> Vec { + let mut beats: Vec = Vec::new(); + for _ in 0..cap { + if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { + return beats; + } + let prompt = match &state.waiting_for { + WaitingFor::OptionalEffectChoice { + player, source_id, .. + } => Some((*player, *source_id)), + _ => None, + }; + let Some((seat, source_id)) = prompt else { + if f4_drive_one_beat(state).is_err() { + return beats; + } + continue; + }; + let key = may_source_key(state, source_id); + let repeat = beats.iter().any(|b| b.key == key && b.seat == seat); + let take = !(repeat && policy == MayPolicy::DeclineOnRepeat); + let before = state.loop_answer(&key, seat); + if apply( + state, + seat, + GameAction::DecideOptionalEffect { accept: take }, + ) + .is_err() + { + return beats; + } + beats.push(MayBeat { + key, + seat, + take, + before, + }); + if repeat && policy != MayPolicy::TakeAll { + return beats; + } + } + beats +} -/// §5 U6 (i) — MEASURED: at the real F4 bounded offer the engine's AI candidate generator -/// emits exactly ONE action, `DeclineShortcut`. It offers no declaration at all. +/// **Row 1′.** CR 603.5 + CR 732.2a: at the real F4 bounded offer, every published +/// `MayChoice` point's source has a journal entry UNDER THE PROPOSER'S OWN KEY. /// -/// Both declare candidates are excluded, each by a different conjunct, and this board trips -/// both at once: +/// `proposer` is bound from the minted `WaitingFor::LoopShortcut`, never hard-coded: the +/// publisher filters the published may slot on `gate.prompt_player == proposer`, so +/// `(source, proposer)` is precisely the key that is supposed to exist, and a hard-coded +/// seat would read `None` and red this row for the wrong reason. /// -/// * `UntilLethal` is gated on `!schema.is_bounded()`. CR 732.2a: a count-free declaration -/// names no legal repetition number against an offer that narrowed the bound, and -/// `handle_declare_shortcut` refuses it — measured in -/// [`u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_shape_is_one_it_never_builds`]. -/// * `Fixed(max_iterations)` is gated on `schema.points.is_empty()` — it carries -/// `template: None`, and a published pin set fail-closes on that. F4 publishes THREE points -/// (`r1b`), so the gate is closed with room to spare; ONE would already have closed it. +/// # Discrimination /// -/// So the AI declines because it has nothing else it can legally say, not because it emitted a -/// declaration the engine then accepted-and-discarded. +/// Delete the `record_loop_answer` call from the `DecideOptionalEffect` reducer arm ⇒ the +/// journal stays empty ⇒ `loop_answers_recorded() > 0` fails and every lookup returns +/// `None`. Weaken the gate the other way (record under a fixed seat) ⇒ the per-point +/// lookups fail for any board whose prompt seat is not the proposer. /// -/// # Reach-guards (each excludes a way this could pass degenerately) +/// # Reach-guards /// -/// * the offer is BOUNDED (`is_bounded()`, bound narrowed below the ceiling) — that is the -/// `UntilLethal` gate's conjunct; on an unbounded offer that candidate would be PRESENT, so -/// without this guard the row could pass on a board where it was never at issue; -/// * `schema.points` is NON-empty — that is the `Fixed` gate's conjunct, and symmetrically the -/// row would otherwise pass on a board where `Fixed` was never at issue; -/// * `predicted_winner` is `None`. Recorded as a measured property of this board, NOT as -/// reachability for `phase_ai::policies::loop_shortcut::LoopShortcutPolicy`'s -/// `(None, UntilLethal) => reject` arm: since the bounded gate landed, this generator can no -/// longer put that pair in front of the policy from a bounded offer, and -/// `declare_until_lethal_with_no_predicted_winner_is_rejected` covers the arm directly. -/// -/// REVERT-PROBE — one per excluded candidate, because a single probe would leave the OTHER -/// exclusion holding the assertion up and report a false pass: -/// -/// * drop `!schema.is_bounded()` from the `UntilLethal` push in `ai_support/candidates.rs` -/// ⇒ `DeclareShortcut { UntilLethal, None }` reappears ⇒ this row FLIPS on the equality; -/// * drop `schema.points.is_empty() &&` from the `Fixed` push ⇒ `Fixed(max_iterations)` -/// appears ⇒ this row FLIPS on the equality. +/// * the restored dump starts with an EMPTY journal, so every entry is one this drive wrote; +/// * the drive really answered at least one "may" prompt; +/// * the offer really published at least one `MayChoice` point — without this the `for` loop +/// below is empty and the row would pass on a board it never tested. #[test] -fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { +fn c1_row1_the_may_journal_is_populated_at_the_f4_offer_under_the_proposers_own_key() { + use engine::analysis::decision_template::{LoopAnswer, LoopAnswerValue, MayChoiceOption}; + let mut state = load_f4(); - drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); - let (proposer, _certificate, schema) = offer_parts(&state); - let WaitingFor::LoopShortcut { - predicted_winner, .. - } = &state.waiting_for - else { - unreachable!("offer_parts would have panicked") - }; + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: the restored dump starts with an EMPTY journal" + ); + let beats = drive_f4_may_beats(&mut state, 400, MayPolicy::TakeAll); assert!( - schema.is_bounded() && schema.max_iterations < MAX_SHORTCUT_CYCLES_MIRROR, - "reach-guard: the generator's `Fixed` candidate is gated on `is_bounded()` too, so an \ - unbounded offer would exclude it for the wrong reason. bounded={} max_it={}", - schema.is_bounded(), - schema.max_iterations + !beats.is_empty(), + "reach-guard: the drive must have answered at least one CR 603.5 `may` prompt, else \ + there is no write for this row to observe" ); + + let (proposer, _certificate, schema) = offer_parts(&state); + // The WHOLE published slot, sub-index included — the journal is keyed on it, so + // projecting it down to `slot.source` here would test a coarser identity than the one + // production writes and reads. + let may_slots: Vec<_> = schema + .points + .iter() + .filter(|p| matches!(p.kind, DecisionPointKind::MayChoice)) + .map(|p| p.slot.clone()) + .collect(); assert!( - !schema.points.is_empty(), - "reach-guard: a NON-empty published pin set is the conjunct this row is about" + !may_slots.is_empty(), + "reach-guard: the offer must publish at least one MayChoice point (r1b measures \ + three points on this board), else the per-point assertions below are vacuous" ); - assert_eq!( - *predicted_winner, None, - "reach-guard + REACHABILITY for `phase_ai::policies::loop_shortcut`: the F4 offer \ - latches NO predicted winner, which is what routes its `(None, UntilLethal)` reject arm" + assert!( + state.loop_answers_recorded() > 0, + "CR 603.5: the offer beat must carry the answers the drive gave" ); + for slot in &may_slots { + assert_eq!( + state.loop_answer(slot, proposer), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "every published may point's slot must be journalled under the PROPOSER's own \ + key; slot {slot:?}, proposer {proposer:?}, journal holds {} entries", + state.loop_answers_recorded() + ); + } +} - // ── the seam: `phase-ai/src/search.rs` `WaitingFor::LoopShortcut { .. } =>` calls this ── +/// **Row T1 — WIRE / JOURNAL TIER.** CR 608.2b + CR 601.2c (reached via CR 603.3d) + +/// CR 732.2a: at the real F4 bounded offer, the published `Targets` point's SLOT carries the +/// announcement the proposer actually made, under the proposer's own key. +/// +/// Every beat crosses the public `apply()` boundary; the slot is bound from `schema.points` +/// and the pinned seat from the drive policy's own aim, so a re-dump that renumbers objects +/// flows through without edit. +/// +/// # Discrimination +/// +/// Delete the `record_trigger_target_answer(..)` call from `apply_action`'s +/// `(TriggerTargetSelection, ChooseTarget)` arm ⇒ the `Targets` slot is never journalled and +/// the value assertion reads `None`. The helper and its `SelectTargets` caller survive, so +/// the mutation COMPILES and reds on the assert. The `SelectTargets` arm is covered at a +/// DIFFERENT TIER by `loop_shortcut.rs`'s +/// `c2a_row_t1b_both_trigger_target_selection_arms_route_through_the_single_writer`, which is +/// a SOURCE CENSUS: it asserts that both reducer arms are WIRED to the single writer, and +/// structurally cannot observe an announced seat (no fixture in this repo reaches the +/// `SelectTargets` arm — that row's own doc records the per-dump measurement and the backlog +/// item). The two deletions are ASYMMETRIC, and the asymmetry is the usable part: deleting the +/// `SelectTargets` call reds ONLY the census, while deleting the `ChooseTarget` call reds BOTH — +/// so a red census names the arm, and this row disambiguates which one moved. The census cannot +/// be blind to either arm: it asserts `unwired.is_empty()` across both. +/// +/// # Sibling (T1-sib), asserted in this same body +/// +/// After that mutation the two `MayChoice` points still read `Uniform(May(Take))`, so the +/// deletion is TARGET-SPECIFIC and cannot be confused with a journal that stopped working. +/// +/// # Reach-guards, all asserted BEFORE the claim +/// +/// * the restored dump starts with an EMPTY journal, so every entry is one this drive wrote; +/// * the drive really reaches the CR 732.2a offer beat (searched, never hardcoded); +/// * the offer really publishes a `Targets` point — without this the loop below is empty; +/// * the drive's aimed seat is NOT the proposer's own seat, so a writer that journalled the +/// proposer instead of the announcement could not pass. +/// +/// # What this row does NOT claim +/// +/// It is a WRITER row. C2a ships no declaration consumer, so nothing here asserts that a +/// declaration is built from these entries. +#[test] +fn c2a_row_t1_the_announced_target_is_journalled_at_the_f4_offers_published_slot() { + use engine::analysis::decision_template::{ + AnnouncementSubject, LoopAnswer, LoopAnswerValue, MayChoiceOption, Ranking, TargetPin, + TargetSchedule, + }; + + let mut state = load_f4(); + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: the restored dump starts with an EMPTY journal" + ); + + let beat = drive_f4_to_offer(&mut state, 400) + .expect("reach-guard: the F4 drive must reach the CR 732.2a bounded offer"); + let (proposer, _certificate, schema) = offer_parts(&state); + + let target_slots: Vec<_> = schema + .points + .iter() + .filter(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) + .map(|p| p.slot.clone()) + .collect(); + assert!( + !target_slots.is_empty(), + "reach-guard: the offer must publish at least one CR 601.2c Targets point at beat \ + {beat}, else the per-point assertion below is vacuous" + ); + // `P1` is the seat `f4_drive_one_beat` aims Torch's "target opponent" at. It must not be + // the proposer, or a writer that journalled the PROMPT'S OWN SEAT rather than the + // ANNOUNCED target would satisfy this row. + assert_ne!( + P1, proposer, + "reach-guard: the drive's aimed seat must differ from the proposer's own seat" + ); + + for slot in &target_slots { + assert_eq!( + state.loop_answer(slot, proposer), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(P1) + ))) + ]))), + "CR 608.2b: the published Targets slot must hold the announcement the drive made \ + (a constant CR 115.2 player target, in the CR 601.2c TARGET-class spelling), \ + under the PROPOSER's own key; slot \ + {slot:?}, proposer {proposer:?}, journal holds {} entries", + state.loop_answers_recorded() + ); + } + + // ── T1-sib: the CR 603.5 axis is untouched by the target axis's write ── + let may_slots: Vec<_> = schema + .points + .iter() + .filter(|p| matches!(p.kind, DecisionPointKind::MayChoice)) + .map(|p| p.slot.clone()) + .collect(); + assert!( + !may_slots.is_empty(), + "reach-guard: this board publishes MayChoice points too, else the sibling assertion \ + below is vacuous" + ); + for slot in &may_slots { + assert_eq!( + state.loop_answer(slot, proposer), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "T1-sib: deleting the target write must leave C1's CR 603.5 axis green — the two \ + axes share one journal but not one entry" + ); + } +} + +/// **Row T1-P — WIRE / PROVENANCE.** The journalled pin FOLLOWS THE ANNOUNCEMENT, not a +/// constant: driving the SAME dump with the SAME policy but a different aimed seat produces a +/// different journal value at the same published slot. +/// +/// # Why this row exists at all — the vacuity it closes +/// +/// [`c2a_row_t1_the_announced_target_is_journalled_at_the_f4_offers_published_slot`] drives +/// the shipped policy, which aims at P1. A writer that IGNORED the announcement and stored +/// the constant seat P1 would satisfy it exactly. Only a second seat discriminates that, and it +/// must be a REAL drive: the seat is announced through production `apply()` at Torch's +/// CR 601.2c choice, never injected. +/// +/// # Discrimination +/// +/// In `record_trigger_target_answer`, replace the mapped `targets` with +/// `vec![TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one(AnnouncementSubject::Seat(PlayerId(1)))))]` +/// ⇒ this row reds on the value while T1 stays GREEN. That asymmetry is the point: T1 alone +/// cannot see this mutation. The mutant is spelled in the CURRENT producer spelling on purpose: +/// the discrimination is seat-vs-seat and survives any re-spelling, but a recipe naming a +/// spelling the producer no longer emits is a recipe that no longer compiles. +/// +/// # Reach-guards +/// +/// The P2 drive must reach the offer (MEASURED: constant P1, P2 and P3 all certify — it is +/// the variation between iterations, not the seat, that blocks certification), the offer must +/// publish a `Targets` point, and the aimed seat must differ from T1's. +#[test] +fn c2a_row_t1p_the_journalled_pin_follows_the_announced_seat_not_a_constant() { + use engine::analysis::decision_template::{ + AnnouncementSubject, LoopAnswer, LoopAnswerValue, Ranking, TargetPin, TargetSchedule, + }; + + const AIMED: PlayerId = PlayerId(2); + assert_ne!( + AIMED, P1, + "reach-guard: this row's aimed seat must differ from the shipped policy's, else it \ + re-runs T1 and discriminates nothing" + ); + + let mut state = load_f4(); + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: the restored dump starts with an EMPTY journal" + ); + let beat = drive_f4_to_offer_at(&mut state, 400, AIMED).expect( + "reach-guard: a CONSTANT non-P1 target still certifies — it is the VARIATION between \ + iterations, not the seat, that blocks the CR 732.2a offer", + ); + let (proposer, _certificate, schema) = offer_parts(&state); + assert_ne!( + AIMED, proposer, + "reach-guard: the aimed seat must not be the proposer's own" + ); + + let target_slots: Vec<_> = schema + .points + .iter() + .filter(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) + .map(|p| p.slot.clone()) + .collect(); + assert!( + !target_slots.is_empty(), + "reach-guard: the offer at beat {beat} must publish a CR 601.2c Targets point" + ); + for slot in &target_slots { + assert_eq!( + state.loop_answer(slot, proposer), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(AIMED) + ))) + ]))), + "PROVENANCE: the journal must hold the seat this drive ANNOUNCED ({AIMED:?}), not \ + the seat the shipped policy happens to aim at; slot {slot:?}" + ); + } +} + +/// The declaration the live offer PUBLISHES. A separate accessor rather than a fourth element +/// on [`offer_parts`], so the ~20 existing callers of that helper are untouched. +fn offer_declaration( + state: &GameState, +) -> Option { + match &state.waiting_for { + WaitingFor::LoopShortcut { declaration, .. } => declaration.clone(), + other => panic!("expected the CR 732.2a bounded offer, got {other:?}"), + } +} + +/// **Row D1 — WIRE / CONFORMANCE.** The bounded offer publishes a `Some` declaration that +/// CONFORMS to the reference shape this suite already accepts, on all three tracked dumps. +/// +/// ⚠ **THIS IS A CONFORMANCE ORACLE, NEVER A PROVENANCE ONE.** [`f4_pin_template`] is a pure +/// function of `(schema, owner, count)` — it hard-codes `MayChoiceOption::Take` and the seat P1 +/// (as `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(P1))))`, the CR 601.2c +/// TARGET-class spelling the publisher emits) and never reads the journal — so a consumer that +/// ignored the journal entirely and emitted those same constants passes this row. That is exactly what +/// [`d1p_the_published_pin_follows_the_journal_not_a_constant`] and its P3 sibling are for. +/// +/// # The count trap, measured +/// +/// The reference must be built with `count = schema.max_iterations`, NOT the `1` every other +/// declare row in this file passes: `build_bounded_declaration` sets +/// `replay: Scheduled { count: schema.iteration_count }`, and `certified_bounded_cycle_offer` +/// builds the schema with `IterationCount::Fixed(max_iterations)`. Measured on all three boards: +/// `REAL == f4_pin_template(count = 1)` is FALSE and `REAL == f4_pin_template(count = max)` is +/// TRUE. +/// +/// # Reach-guards, asserted BEFORE the claim +/// +/// The journal holds at least one answer per published point (else `declaration.is_some()` +/// could only ever be the empty-schema path), the point count is the board's known one, and the +/// bound is the board's measured `max_iterations` — which is also the reference's count. +/// +/// REVERT-PROBE: make `build_bounded_declaration`'s `(Targets, Targets)` arm `return None` ⇒ +/// `is_some()` flips on all three boards. +#[test] +fn d1_the_bounded_offer_publishes_a_conformant_declaration_on_every_tracked_dump() { + use engine::analysis::decision_template::{predictability_gate, validate_pins}; + + for (label, mut state, expected_points, expected_max) in [ + ("F4", load_f4(), 3usize, 18u32), + ("MODE1", load_mode1(), 2, 17), + ("MODE2", load_mode2(), 3, 16), + ] { + let beat = drive_f4_to_offer(&mut state, 400) + .unwrap_or_else(|| panic!("[{label}] REACH-GUARD: the bounded offer must FIRE")); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + + assert!( + state.loop_answers_recorded() >= schema.points.len(), + "[{label}] REACH-GUARD: every published point must have an answer in the journal, \ + else a `Some` declaration below could not be about this schema at all. recorded={} \ + points={}", + state.loop_answers_recorded(), + schema.points.len() + ); + assert_eq!( + schema.points.len(), + expected_points, + "[{label}] REACH-GUARD: the published point count at beat {beat}" + ); + assert_eq!( + schema.max_iterations, expected_max, + "[{label}] REACH-GUARD: the CR 704.5a-derived bound — and the count the reference \ + below must be built with" + ); + + let declaration = offer_declaration(&state) + .unwrap_or_else(|| panic!("[{label}] the offer publishes a declaration")); + assert_eq!( + declaration, + f4_pin_template(&schema, proposer, schema.max_iterations), + "[{label}] CR 732.2a: the published declaration must CONFORM to the shape this \ + suite's accepted declarations take — one pin per published point, owner == \ + proposer, `replay.count` == the offer's own suggestion" + ); + + let required: Vec<_> = schema.points.iter().map(|p| p.slot.clone()).collect(); + assert!( + predictability_gate(&declaration, &required).is_ok(), + "[{label}] the published declaration covers every published slot — the coverage half \ + of the declare-time firewall" + ); + assert!( + validate_pins(&schema, &declaration, 1, &state).is_ok(), + "[{label}] and its pin VALUES are legal at iteration 1" + ); + assert!( + validate_pins(&schema, &declaration, schema.max_iterations, &state).is_ok(), + "[{label}] and at the full declared range — the count the AI's candidate carries" + ); + } +} + +/// **Row D1-P — WIRE / PROVENANCE.** The declaration's pinned target FOLLOWS THE JOURNAL, not a +/// constant: driving the SAME dump with the SAME policy but a different aimed seat publishes a +/// different pin at the same published slot. +/// +/// This is the CONSUMER-tier sibling of +/// [`c2a_row_t1p_the_journalled_pin_follows_the_announced_seat_not_a_constant`] (the WRITER-tier +/// row) and reuses its two helpers, so the drive is production `apply()` and the seat is +/// ANNOUNCED at Torch's CR 601.2c choice, never injected. +/// +/// # The asymmetry IS the row +/// +/// On the shipped P1 board, replacing the journalled targets with the constant seat P1 +/// (`vec![TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one(AnnouncementSubject::Seat(PlayerId(1)))))]`) +/// is GREEN — that mutant is indistinguishable there. At a second seat it is RED. Only a second +/// seat discriminates a journal-blind consumer. +/// +/// # Reach-guards, asserted BEFORE the claim +/// +/// The offer fires at the aimed seat, the point set is the known one, the aimed seat is not the +/// proposer's own (or a writer that stored the PROMPT's seat would satisfy the row), and the +/// `Targets` point's journal entry already reads the aimed seat before the consumer is called. +/// +/// REVERT-PROBE: in `build_bounded_declaration`'s `(Targets, Targets)` arm, replace the +/// journalled `targets` with the same constant-P1 vector named above ⇒ this row flips on the pin +/// VALUE while D1 stays green. +/// +/// *What wrong implementation would still pass this row?* One that reads the journal but ignores +/// `point.slot` — there is one `Targets` point here, so the slot axis is D1-P-may's and D3's. +#[test] +fn d1p_the_published_pin_follows_the_journal_not_a_constant() { + d1p_provenance_at_seat(PlayerId(2)); +} + +/// **Row D1-P-sib** — the same claim at a THIRD seat, so the provenance cannot be a coincidence +/// of one seat's numbering. +#[test] +fn d1p_sib_the_published_pin_provenance_is_not_specific_to_one_second_seat() { + d1p_provenance_at_seat(PlayerId(3)); +} + +fn d1p_provenance_at_seat(aimed: PlayerId) { + use engine::analysis::decision_template::{ + validate_pins, AnnouncementSubject, LoopAnswer, LoopAnswerValue, PinnedDecision, Ranking, + TargetPin, TargetSchedule, + }; + // CR 601.2c: the one spelling this row expects at BOTH tiers — the journal's own write and + // the declaration the publisher derives from it. Built once so the two `assert_eq!`s below + // cannot drift apart; it is still a fully-determined VALUE, not a pattern. + let announced_seat = TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(aimed), + ))); + + assert_ne!( + aimed, P1, + "reach-guard: the aimed seat must differ from the shipped policy's, else this re-runs D1 \ + and discriminates nothing" + ); + let mut state = load_f4(); + let beat = drive_f4_to_offer_at(&mut state, 400, aimed).expect( + "reach-guard: a CONSTANT non-P1 target still certifies — it is the VARIATION between \ + iterations, not the seat, that blocks the CR 732.2a offer", + ); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + assert_ne!( + aimed, proposer, + "reach-guard: the aimed seat must not be the proposer's own" + ); + assert_eq!( + schema.points.len(), + 3, + "reach-guard: the published point set at beat {beat}" + ); + + let target_slot = schema + .points + .iter() + .find(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) + .map(|p| p.slot.clone()) + .expect("reach-guard: the offer publishes a CR 601.2c Targets point"); + // The WRITER's own output, asserted BEFORE the consumer runs: without this the row could not + // tell "the consumer ignored the journal" from "the journal never held the aimed seat". + assert_eq!( + state.loop_answer(&target_slot, proposer), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + announced_seat.clone() + ]))), + "reach-guard: the journal holds the ANNOUNCED seat {aimed:?} at the published slot" + ); + + let declaration = offer_declaration(&state).expect("the offer publishes a declaration"); + let pinned = declaration + .decisions + .iter() + .find_map(|pin| match pin { + PinnedDecision::Targets { slot, targets } if *slot == target_slot => Some(targets), + _ => None, + }) + .expect("the declaration pins the published Targets slot"); + assert_eq!( + *pinned, + vec![announced_seat], + "PROVENANCE: the declaration must pin the seat this drive ANNOUNCED ({aimed:?}), not the \ + seat the shipped policy happens to aim at" + ); + assert!( + validate_pins(&schema, &declaration, 1, &state).is_ok(), + "and the journal-derived pin is LEGAL against the offer's own schema — otherwise a \ + provenance-correct consumer could still be publishing an unusable declaration" + ); +} + +/// **Row 2b — JOURNAL TIER.** CR 603.5: ONE seat answering ONE source two different ways +/// inside one detection window latches [`LoopAnswer::Conflicted`]. +/// +/// ⚠ TIER LIMIT, stated rather than implied: C1 ships no declaration consumer, so this row +/// asserts the LATCH, not a refused declaration. The declaration-tier half — that a +/// `Conflicted` entry makes `build_bounded_declaration` return `None` on this same board — +/// belongs to C2 and is NOT covered here. +/// +/// The same-seat constraint is asserted in the body, not assumed: under the pair key two +/// DIFFERENT seats answering one source land in two entries and the `Entry::Occupied` arm is +/// never entered at all, which would make this row vacuous. +/// +/// # Discrimination +/// +/// Delete `record_loop_answer`'s `Entry::Occupied` conflict arm (let a second write be +/// ignored, or overwrite) ⇒ the entry stays `Uniform { take: Take }` ⇒ the final assertion +/// flips. MEASURED, not predicted — see this row's companion probe in the implementation +/// report. +/// +/// # Paired positive / reach-guard +/// +/// `before` on the conflicting beat must already be `Uniform { Take }`: that proves the beat +/// really was a REPEAT of an already-journalled pair, so a drive that never repeated cannot +/// satisfy this row. +#[test] +fn c1_row2b_one_seat_answering_one_source_two_ways_latches_conflicted() { + use engine::analysis::decision_template::{LoopAnswer, LoopAnswerValue, MayChoiceOption}; + + let mut state = load_f4(); + let beats = drive_f4_may_beats(&mut state, 400, MayPolicy::DeclineOnRepeat); + let last = beats + .last() + .expect("the drive must have answered at least one `may` prompt"); + assert!( + !last.take, + "reach-guard: the drive must have REACHED a repeated (source, seat) prompt and \ + declined it; it answered {} prompts and the last was a Take", + beats.len() + ); + + let first = beats + .iter() + .find(|b| b.key == last.key && b.seat == last.seat && b.take) + .expect("the repeat's own first answer must be in the drive's record"); + assert_eq!( + first.seat, last.seat, + "SAME-SEAT CONSTRAINT: both answers must come from one seat. Two seats occupy two \ + keys, never enter the conflict arm, and would make this row vacuous" + ); + assert_eq!( + last.before, + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "paired positive: the FIRST answer was journalled as Uniform(May(Take)) before the \ + differing one landed" + ); + assert_eq!( + state.loop_answer(&last.key, last.seat), + Some(LoopAnswer::Conflicted), + "CR 603.5: a second, DIFFERENT answer from the same seat for the same source latches \ + Conflicted (an engine-capability refusal, not a CR 732.2a mandate)" + ); +} + +/// **Row 2b sibling — idempotence.** The latch fires on DISAGREEMENT, not on repetition: the +/// same seat answering the same source the same way twice stays `Uniform`. +/// +/// Without this sibling, a `record_loop_answer` that latched `Conflicted` on EVERY repeat +/// would pass row 2b and destroy every real board — the F4 drive answers each may source +/// once per iteration. +/// +/// Discrimination: replace the conflict arm's `if *o.get() != answer` with an unconditional +/// `o.insert(LoopAnswer::Conflicted)` ⇒ this row reds while row 2b stays green. +#[test] +fn c1_row2b_sibling_an_identical_second_answer_stays_uniform() { + use engine::analysis::decision_template::{LoopAnswer, LoopAnswerValue, MayChoiceOption}; + + let mut state = load_f4(); + let beats = drive_f4_may_beats(&mut state, 400, MayPolicy::TakeUntilRepeat); + let last = beats + .last() + .expect("the drive must have answered at least one `may` prompt"); + assert_eq!( + last.before, + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "reach-guard: the last beat must be a REPEAT of an already-journalled pair, else this \ + row asserts idempotence over a single write" + ); + assert_eq!( + state.loop_answer(&last.key, last.seat), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "an identical second answer must not latch Conflicted" + ); +} + +/// **Row 7b″.** The journal is invalidated with `loop_detect_ring`, ON THE SAME RECEIVER. +/// +/// Three of the eight ring-clear sites act on a `clone`/`self` rather than on `state`, so a +/// journal clear applied to the wrong receiver would leave a stored sample carrying the live +/// window's answers. Sites 6 and 7 are only observable downstream, through +/// `LoopDetectSample`'s `pub normalized` / `pub live` halves on the ring — this row asserts +/// there, simultaneously with the LIVE state being non-empty, so no single-receiver bug +/// satisfies both halves. +/// +/// Site 5 (`apply_action`'s pre-action clear, a `state` receiver) is driven directly. +/// Sites 1–4 and 8 are covered structurally instead, by +/// [`c1_every_ring_clear_site_also_clears_the_loop_answer_journal`] — stated here so the coverage of +/// this row is not read as more than it is. +/// +/// # Discrimination +/// +/// Delete `clone.loop_answer_journal = None;` from `normalize_for_loop` or from +/// `loop_detect_live_sample` ⇒ the corresponding per-sample assertion flips. Delete it from +/// `apply_action`'s clear block ⇒ the final assertion flips. +#[test] +fn c1_row7b_the_may_journal_follows_the_ring_on_the_same_receiver() { + let mut state = load_f4(); + drive_f4_may_beats(&mut state, 400, MayPolicy::TakeAll); + let (proposer, _certificate, _schema) = offer_parts(&state); + + assert!( + state.loop_answers_recorded() > 0, + "paired positive: the LIVE state must carry answers at the offer beat, else every \ + zero below is satisfied by a journal that was never written" + ); + assert!( + !state.loop_detect_ring.is_empty(), + "reach-guard: there must be stored samples to inspect" + ); + for (i, sample) in state.loop_detect_ring.iter().enumerate() { + assert_eq!( + sample.normalized.loop_answers_recorded(), + 0, + "site 6 (`normalize_for_loop`, CLONE receiver): stored sample {i}'s normalized \ + half must not carry the live window's answers" + ); + assert_eq!( + sample.live.loop_answers_recorded(), + 0, + "site 7 (`loop_detect_live_sample`, CLONE receiver): stored sample {i}'s live \ + half must not carry the live window's answers" + ); + } + + apply(&mut state, proposer, GameAction::DeclineShortcut) + .expect("declining the offer is always legal for the proposer"); + assert!( + state.loop_detect_ring.is_empty(), + "reach-guard: site 5's ring clear must actually have fired on this action, else the \ + journal zero below is not evidence about that clear" + ); + assert_eq!( + state.loop_answers_recorded(), + 0, + "site 5 (`apply_action`, STATE receiver): the journal follows the ring" + ); +} + +/// **Row 7c.** The journal never crosses save/load as stale data. +/// +/// `last_loop_action_sequence` fell into exactly this trap once; `#[serde(skip, default)]` +/// is the bar, and this row asserts BOTH halves of it — the field is absent from the encoded +/// payload, and a decode of a populated board restores an empty journal. +/// +/// Discrimination: drop `skip` from the field's serde attribute ⇒ the key appears in the +/// encoded value ⇒ the first assertion flips (and NEITHER `LoopAnswer` NOR `LoopAnswerValue` +/// derives `Serialize`, so that edit does not even compile — which is the point of the note +/// on the field; the compile-time bar had to be re-checked when the value type grew a second +/// axis, and this row is the runtime half of it). +#[test] +fn c1_row7c_the_may_journal_does_not_cross_save_load() { + let mut state = load_f4(); + drive_f4_may_beats(&mut state, 400, MayPolicy::TakeAll); + assert!( + state.loop_answers_recorded() > 0, + "reach-guard: the board being serialized must have a POPULATED journal, else the \ + empty restore below proves nothing" + ); + + let encoded = serde_json::to_value(&state).expect("a live GameState serializes"); + assert!( + encoded.get("loop_answer_journal").is_none(), + "`#[serde(skip)]`: the journal must be absent from the encoded payload entirely" + ); + let restored = serde_json::from_value::(encoded) + .expect("the encoded board decodes through the production decoder") + .into_game_state(); + assert_eq!( + restored.loop_answers_recorded(), + 0, + "a restored board must start its own window with no inherited answers" + ); +} + +/// **Row 7b″, structural half.** EVERY production `loop_detect_ring.clear()` is paired with +/// a `loop_answer_journal = None` on the same receiver, at all eight sites. +/// +/// The driven row above reaches sites 5, 6 and 7 on the F4 board; sites 1–4 and 8 need +/// materialize / until-lethal / pipeline / unobserved-life-move boards that this fixture does +/// not produce. A source-level census covers the whole set at the only tier that can, and +/// fails loudly if a NINTH clear site is added without the journal, which is the actual +/// regression this guards. +/// +/// THE WALK IS THE WHOLE CRATE, not a named pair of files. A hard-coded +/// `["game/engine.rs", "types/game_state.rs"]` cannot see a ninth site in any THIRD file: such +/// a site is neither paired nor reported, so `paired == 8` still passes while the regression is +/// live. MEASURED on this tree: the recursive walk finds exactly the 8 sites the named pair did +/// (5 in `game/engine.rs`, 3 in `types/game_state.rs`), so THE COUNT ASSERTION IS BLIND TO THE +/// WIDENING — the planted-third-file probe below is the only thing that measures it. +/// +/// Discrimination, BOTH DIRECTIONS, RUN: +/// * delete any one `loop_answer_journal = None;` that follows a ring clear ⇒ the pairing count +/// drops and this row reds naming the file and line; +/// * add an unpaired `state.loop_detect_ring.clear();` to a THIRD file under +/// `crates/engine/src` ⇒ `unpaired` names that file and this row reds. Under the named-pair +/// walk the identical plant left the row GREEN. +#[test] +fn c1_every_ring_clear_site_also_clears_the_loop_answer_journal() { + use std::path::Path; + + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut unpaired: Vec = Vec::new(); + let mut paired = 0usize; + // The walker is the sibling census's, not a second copy: one home for "every `.rs` file + // under a root", already shared by the census rows in this binary. + for path in super::loop_shortcut_offer_writer_census::rs_files(&src) { + let rel = path + .strip_prefix(&src) + .expect("walked path is under src") + .to_string_lossy() + .replace('\\', "/"); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + let lines: Vec<&str> = text.lines().collect(); + // Both halves read the CODE of a line, never its comment: prose neither clears the ring + // nor clears the journal. Whole-line-only exclusion is not enough here and the failure is + // two-sided — a comment naming the clear would be counted as a site, and a comment naming + // `loop_answer_journal = None` inside a window would mark a genuinely UNPAIRED site as + // paired, which is the direction that hides the regression. Shared rule, one home: + // `src/source_census.rs`, the same file the crate's own unit-test censuses use. + use super::source_census::code; + for (i, line) in lines.iter().enumerate() { + if !code(line).contains("loop_detect_ring.clear()") { + continue; + } + // The journal assignment sits within the same block, immediately after the ring + // clear (a comment line may separate them). + let window = lines[i + 1..(i + 5).min(lines.len())] + .iter() + .map(|l| code(l)) + .collect::>() + .join("\n"); + if window.contains("loop_answer_journal = None") { + paired += 1; + } else { + unpaired.push(format!("{rel}:{}", i + 1)); + } + } + } + assert!( + unpaired.is_empty(), + "every ring-clear site must also clear the CR 603.5 + CR 608.2b loop-answer journal; \ + unpaired: \ + {unpaired:?}" + ); + assert_eq!( + paired, 8, + "the ring has EIGHT production clear sites across the whole of `crates/engine/src` \ + (5 in game/engine.rs, 3 in types/game_state.rs; MEASURED by this recursive walk). A \ + different count means a site was added or removed and this census must be re-derived, \ + not re-numbered" + ); +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// helpers used by more than one row +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// Count the stack entries whose triggered ability is CR 603.5 `optional`. Used as a reach +/// guard where a row's claim is about the optional gate. +fn optional_entries(state: &GameState) -> usize { + state + .stack + .iter() + .filter(|e| match &e.kind { + StackEntryKind::TriggeredAbility { ability, .. } => ability.optional, + _ => false, + }) + .count() +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// U6 — the AI's candidate set at the real F4 offer, and what the engine does with it +// +// Reachability of the seam under test: `phase_ai::search::choose_action` dispatches +// `WaitingFor::LoopShortcut { .. } => engine::ai_support::legal_actions(state)` +// (`crates/phase-ai/src/search.rs`), and `legal_actions` funnels into the +// `WaitingFor::LoopShortcut` arm of `engine::ai_support::candidates`. These rows drive the +// REAL dump to the REAL offer and measure that arm's output there, plus what +// `handle_declare_shortcut` does with each member of it. +// +// ⚠ MEASURED SCOPE. §5 U6 as planned expects a declare candidate "whose template pins all +// three F4 slots (or declines)". F4 does publish all THREE slots — `r1b` pins +// `[Sue MayChoice, Reed MayChoice, Torch Targets]` — and the measured answer is still the +// SECOND branch: the AI DECLINES, because the only declaration it can emit is one the engine +// refuses outright. The generator builds no pinning template at ALL (its only `Fixed` candidate +// carries `template: None`), so a published set of three is exactly as unreachable for it as a +// set of one would have been — the count is not what excludes it, its emptiness gate is. These +// rows pin that, name the two independent reasons, and pin the accepted shape the generator +// never emits — they do not assert the planned prediction. +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// **Row D6 — WIRE / POSITIVE.** At the real F4 bounded offer the AI candidate generator now +/// emits `DeclareShortcut { Fixed(max_iterations), Some(declaration) }` beside the decline, and +/// the `template` it carries IS THE OFFER'S OWN published declaration — not one the AI built. +/// +/// ⚠ **THIS ROW'S PREVIOUS CLAIM WAS THE OPPOSITE, AND IT IS SUPERSEDED, NOT BROKEN.** As +/// `u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only` it asserted +/// `assert_eq!(actions, vec![GameAction::DeclineShortcut])` — that the generator could offer no +/// declaration at all, because its only `Fixed` candidate carried `template: None` and a +/// published pin set fail-closes on that. Publishing the offer's own declaration is exactly the +/// capability item-4 C2b adds, so the old assertion asserted the ABSENCE of this commit's +/// subject. The name had to change with it: "decline only" is now false on this board. +/// +/// # What is kept, and why +/// +/// Both reach-guards survive verbatim and have flipped from exclusion conjuncts to POSITIVE +/// ones: `is_bounded()` is the count gate, and a NON-empty `points` set is what makes the +/// declaration (rather than the empty-schema `None`) the reason the candidate appears. The +/// `predicted_winner == None` guard stays as a measured property of this board. +/// +/// # Non-vacuity +/// +/// The template is asserted EQUAL to `offer_declaration(&state)`, never merely `Some(_)`: a +/// generator that fabricated its own conformant-looking template would satisfy `is_some()` and +/// fail this. `d6n_a_points_carrying_offer_without_a_declaration_enumerates_only_decline` +/// (in-crate, `ai_support/candidates.rs`) is the paired negative — with `declaration: None` the +/// candidate must NOT appear. +/// +/// REVERT-PROBE: drop the `|| declaration.is_some()` disjunct from the generator's gate ⇒ the +/// candidate disappears against this points-carrying offer ⇒ the equality flips. +#[test] +fn d6_the_ai_declare_candidate_carries_the_offers_own_published_declaration() { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + let WaitingFor::LoopShortcut { + predicted_winner, .. + } = &state.waiting_for + else { + unreachable!("offer_parts would have panicked") + }; + + assert!( + schema.is_bounded() && schema.max_iterations < MAX_SHORTCUT_CYCLES_MIRROR, + "reach-guard: the generator's `Fixed` candidate is gated on `is_bounded()`, so an \ + unbounded offer would decide this row for the wrong reason. bounded={} max_it={}", + schema.is_bounded(), + schema.max_iterations + ); + assert!( + !schema.points.is_empty(), + "reach-guard: a NON-empty published pin set is the conjunct this row is about — with \ + `points` empty the candidate appears regardless of the declaration" + ); + assert_eq!( + *predicted_winner, None, + "reach-guard: the F4 offer latches NO predicted winner (a measured property of this \ + board, recorded so a future board swap is visible)" + ); + let declaration = offer_declaration(&state).expect( + "reach-guard: the offer PUBLISHES a declaration — that is the generator's new input", + ); + + // ── the seam: `phase-ai/src/search.rs` `WaitingFor::LoopShortcut { .. } =>` calls this ── let actions = engine::ai_support::legal_actions(&state); assert_eq!( actions, - vec![GameAction::DeclineShortcut], - "MEASURED: exactly one candidate. No `UntilLethal` declaration (gated on \ - `!schema.is_bounded()`, and this offer narrowed its bound to {}), no `Fixed` \ - declaration (gated on `schema.points.is_empty()`, and this schema publishes {} \ - point(s)), and no declaration carrying a template at all — so the AI cannot pin the \ - point the offer DID publish", + vec![ + GameAction::DeclareShortcut { + count: IterationCount::Fixed(schema.max_iterations), + template: Some(declaration.clone()), + }, + GameAction::DeclineShortcut, + ], + "CR 732.2a: exactly two candidates. No `UntilLethal` declaration (gated on \ + `!schema.is_bounded()`, and this offer narrowed its bound to {}), and the `Fixed` \ + declaration carries the ENGINE'S OWN pin set for the {} published point(s)", schema.max_iterations, schema.points.len() ); @@ -1432,24 +2804,15 @@ fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { // Stated separately from the equality above so a future generator change that adds an // unrelated candidate reports the interesting fact rather than a diff of two long vectors. assert!( - !actions.iter().any(|a| matches!( - a, - GameAction::DeclareShortcut { - count: IterationCount::Fixed(_), - .. - } - )), - "no `Fixed` candidate is generated against a points-carrying offer" - ); - assert!( - !actions.iter().any(|a| matches!( + actions.iter().any(|a| matches!( a, GameAction::DeclareShortcut { - template: Some(_), - .. - } + count: IterationCount::Fixed(n), + template: Some(t), + } if *n == schema.max_iterations && *t == declaration )), - "the generator never builds a pin template — that is the capability §5 U6 asks about" + "the candidate's template is the offer's own declaration, VALUE-EQUAL — a fabricated \ + template of the same shape would fail here and pass an `is_some()` check" ); assert_eq!( proposer, P0, @@ -1457,22 +2820,45 @@ fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { ); } -/// §5 U6 (ii) — the branch that fires, and the MEASURED reason it fires. +/// §5 U6 (ii) — the generator's OWN candidate now opens the CR 732.2b window, and the four +/// one-axis declare drives that say why. /// -/// Every action the AI can take at this offer hands priority straight back; the accepted shape -/// is one the generator never emits. Four declarations are driven through `apply()` on the SAME -/// real offer board, differing one axis at a time: +/// ⚠ **THIS ROW'S PREVIOUS CLAIM WAS THAT THE CAPABILITY WAS ABSENT, AND IT IS SUPERSEDED, NOT +/// BROKEN.** As `u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_ +/// shape_is_one_it_never_builds` its candidate loop asserted that EVERY AI candidate lands on +/// `WaitingFor::Priority` — *"a `RespondToShortcut` here would mean the AI CAN open the +/// CR 732.2b window, which is the capability this row measures absent"*. That capability is +/// exactly what item-4 C2b adds, so the loop now asserts the complementary fact, still +/// RE-DERIVED from the generator rather than hand-named: the declare candidate opens the window +/// and the decline hands priority back. +/// +/// **The four one-axis drives below still measure the engine-side guards the generator's gate +/// depends on, but one of them has FLIPPED, deliberately.** `Fixed(max) + None` used to be a +/// live fail-closed guard, on the stated grounds that resolving a `template: None` declaration +/// against the offer's own published declaration was a declare-handler change deferred out of +/// that commit's partition. Item-4 C2 IS that change: `handle_declare_shortcut` now resolves a +/// `None` template against `offer.declaration` before the `template.owner` firewall, so on this +/// board — which publishes a declaration — that arm is ACCEPTED and the `None if +/// …loop_period_controller() != Some(proposer)` arm is bypassed rather than reached. The arm is +/// kept, flipped, because it is the one row here that measures the manual ingress agreeing with +/// the AI ingress on one and the same offer. Its fail-closed sibling did not disappear — it +/// moved to the offer shape that still reaches it, which is +/// [`a_template_free_declaration_is_admitted_only_by_the_proposers_own_period`] (offer with +/// `declaration: None`). +/// +/// Four declarations are driven through `apply()` on the SAME real offer board, differing one +/// axis at a time: /// /// | declaration | measured | /// |---|---| /// | `UntilLethal` + `None` — **the shape the generator emitted before the bounded gate** | REFUSED ⇒ `Priority` | /// | `UntilLethal` + a conformant template | REFUSED ⇒ `Priority` (so the refusal is keyed on the COUNT, not on the pins) | -/// | `Fixed(max)` + `None` | REFUSED ⇒ `Priority` (`template: None` against a non-empty schema fail-closes when `last_loop_action_sequence` is empty — measured empty here) | +/// | `Fixed(max)` + `None` | **ACCEPTED** ⇒ item-4 C2 resolves the `None` against the declaration this offer published, so the browser payload reaches the same window the AI's does | /// | `Fixed(max)` + a conformant template | **ACCEPTED** ⇒ the CR 732.2b APNAP window opens | /// /// The last row is the ANTI-VACUITY control: without it, "everything reaches `Priority`" would /// be satisfied by a board that refuses every declaration for some unrelated reason. With it, -/// the three refusals are proved to be refusals of *those* declarations. +/// the two `UntilLethal` refusals are proved to be refusals of *those* declarations. /// /// ⚠ This row deliberately does NOT assert what the accepted declaration then accomplishes — /// that is [`r2a_an_accepted_declaration_commits_exactly_n_cycles_because_reeds_may_is_announced`]'s @@ -1480,7 +2866,7 @@ fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { /// Reed's `may` was unpublished). Splitting the two keeps this row a DECLARE-time matrix. /// /// The `UntilLethal` rows are what justifies the generator's `!schema.is_bounded()` gate -/// ([`u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only`]): the engine refuses that count +/// ([`d6_the_ai_declare_candidate_carries_the_offers_own_published_declaration`]): the engine refuses that count /// against a narrowed bound on a real board, so emitting it was offering the search layer an /// action that is accepted-then-discarded. These rows keep measuring the ENGINE guard directly, /// which is the fact the generator gate depends on and must not be allowed to rot. @@ -1499,8 +2885,7 @@ fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { /// The row asserts both arms for exactly that reason: a single-guard probe would report the /// AI's candidate as still-refused and hide the change. #[test] -fn u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_shape_is_one_it_never_builds( -) { +fn u6_the_generators_own_candidate_opens_the_window_and_the_accepted_shape_is_measured() { let mut state = load_f4(); drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); let (proposer, _certificate, schema) = offer_parts(&state); @@ -1509,33 +2894,60 @@ fn u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_ assert!( state.last_loop_action_sequence.is_empty(), - "the measured precondition for the `Fixed` + `None` arm below: with a NON-empty \ - sequence a template-free declaration is legitimately re-derivable and that arm would \ - be measuring something else. len={}", + "the measured precondition that makes the `Fixed` + `None` arm below ATTRIBUTABLE: with \ + no recorded period at all, the `None if …loop_period_controller() != Some(proposer)` \ + arm would refuse this declaration on the pre-C2 engine, so that arm's acceptance is \ + attributable to item-4 C2's `or_else` and to nothing else on this board. len={}", state.last_loop_action_sequence.len() ); + assert!( + offer_declaration(&state).is_some(), + "and the other half of that attribution: the `or_else` can only accept because THIS \ + offer published a declaration to fall back to. An offer publishing `None` still \ + fail-closes — `a_template_free_declaration_is_admitted_only_by_the_proposers_own_period`" + ); - // Every AI candidate, driven through the public boundary. Since the bounded gate landed this - // set is `[DeclineShortcut]` alone, so on its own the loop is a WEAK statement — it is the - // four one-axis drives below that carry this row. Kept because it is the only assertion here - // that re-derives the candidate set from the generator rather than naming shapes by hand: a - // future generator change that reintroduces a declaration at this node has to survive it. + // Every AI candidate, driven through the public boundary and dispatched on its own SHAPE, + // so the expectation is re-derived from the generator rather than named by hand: a future + // generator change at this node has to survive it. let candidates = engine::ai_support::legal_actions(&state); assert!( !candidates.is_empty(), "positive control: an EMPTY candidate set would satisfy the loop below vacuously" ); + let mut opened_the_window = 0usize; for action in candidates { let mut probe = state.clone(); apply(&mut probe, proposer, action.clone()).expect("dispatched — refusal is a HANDBACK"); - assert!( - matches!(probe.waiting_for, WaitingFor::Priority { .. }), - "CR 800.4a: the AI candidate {action:?} hands priority back. A \ - `RespondToShortcut` here would mean the AI CAN open the CR 732.2b window, which \ - is the capability this row measures absent. got {:?}", - probe.waiting_for - ); + match &action { + GameAction::DeclareShortcut { .. } => { + opened_the_window += 1; + assert!( + matches!(probe.waiting_for, WaitingFor::RespondToShortcut { .. }), + "CR 732.2b: the generator's own declare candidate {action:?} must OPEN the \ + accept-or-shorten window — it carries the engine's published declaration, \ + which is the shape the accepted-control arm below proves the engine takes. \ + A `Priority` here means the AI is enumerating an action the engine refuses. \ + got {:?}", + probe.waiting_for + ); + } + _ => assert!( + matches!(probe.waiting_for, WaitingFor::Priority { .. }), + // CR 732.2a: a shortcut is a SUGGESTION made by the player who already has + // priority, so refusing it takes no game action and that player still has + // priority — `handle_decline_shortcut` re-seats `WaitingFor::Priority` and + // cites the same rule. (Not CR 800.4a, which is player-elimination.) + "CR 732.2a: the decline candidate {action:?} hands priority back, got {:?}", + probe.waiting_for + ), + } } + assert_eq!( + opened_the_window, 1, + "reach-guard for the loop above: EXACTLY ONE candidate is a declaration, so neither arm \ + of the match is vacuous" + ); let outcome = |count: IterationCount, template: Option<_>| { let mut probe = state.clone(); @@ -1560,10 +2972,13 @@ fn u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_ ); assert_eq!( outcome(IterationCount::Fixed(max), None), - "Priority", - "and 'just emit `Fixed`' is not a template-free remedy: a `template: None` declaration \ - against a non-empty schema fail-closes unless the recorded driving period belongs to \ - the offer's proposer, and here there is no period at all" + "RespondToShortcut", + "item-4 C2, and this arm FLIPPED with it: `Fixed` + `template: None` is the browser's \ + own payload, and `handle_declare_shortcut` now resolves that `None` against the \ + declaration THIS offer published rather than discarding it. Both reach-guards above \ + are what make the flip attributable — no recorded period (so the pre-C2 engine refused \ + here) and a published declaration (so there is something to resolve against). Revert \ + the `or_else` ⇒ `Priority`" ); // ── ANTI-VACUITY CONTROL: this board DOES accept a declaration ── assert_eq!( @@ -1636,7 +3051,7 @@ fn u6_the_declare_owner_firewall_holds_on_the_real_f4_offer() { vec![("RespondToShortcut", 0), ("Priority", 0)], "CR 732.2a + CR 603.5: the declaration owned by the engine-issued proposer opens the \ APNAP window; the byte-identical declaration owned by {hostile:?} is refused into the \ - CR 800.4a manual handback. `handle_declare_shortcut` pushes no events on either path, \ + manual handback. `handle_declare_shortcut` pushes no events on either path, \ so the event counts are exact rather than wildcards" ); } @@ -2173,8 +3588,10 @@ fn a1_the_users_accept_committed_nothing_board_now_commits_on_every_axis() { assert_axis_scales("MODE2", "token", tokens_1, tokens_3); } -/// ITEM 2 (CR 732.2a) — the DECLARE seam: a `template: None` declaration is admitted only when -/// the recorded period belongs to the offer's own proposer. +/// ITEM 2 (CR 732.2a) — the DECLARE seam: **on an offer that published no declaration of its +/// own**, a `template: None` declaration is admitted only when the recorded period belongs to +/// the offer's own proposer. The qualifier is item-4 C2's and is load-bearing — see the arm +/// table below. /// /// **WHY THIS FIXTURE AND NOT `loop_shortcut.rs`.** Site F sits under /// `if !offer.schema.points.is_empty()`. The dina bounded offer publishes an EMPTY point set @@ -2195,11 +3612,20 @@ fn a1_the_users_accept_committed_nothing_board_now_commits_on_every_axis() { /// foreign period would take the unvalidated sibling arm and open the CR 732.2b APNAP window on a /// client-supplied declaration. The arm therefore asks whose period it is. /// -/// | arm | sequence | expected `waiting_for` | -/// |---|---|---| -/// | EMPTY-seq | empty | `Priority` (fail-closed) — must-not-flip | -/// | OWN-seq | proposer's | `RespondToShortcut` (the legitimate object-growth route) — must-not-flip | -/// | FOREIGN-seq | an opponent's | `Priority` — **the remedy** | +/// **ALL THREE ARMS RUN ON AN OFFER WHOSE OWN `declaration` IS CLEARED (item-4 C2).** That is +/// the offer shape site F still decides — `handle_declare_shortcut` resolves a `template: None` +/// declaration against `offer.declaration` above the pin block, so an offer that published one +/// bypasses site F entirely. The clearing keeps this row on its own subject instead of silently +/// converting it into a `declaration_conforms` row; the fourth arm below is the paired positive +/// that proves the clearing is the operative axis. See the closure's own comment for why a +/// declaration-free offer is a reachable production shape rather than a contrivance. +/// +/// | arm | offer `declaration` | sequence | expected `waiting_for` | +/// |---|---|---|---| +/// | EMPTY-seq | cleared | empty | `Priority` (fail-closed) — must-not-flip | +/// | OWN-seq | cleared | proposer's | `RespondToShortcut` (the legitimate object-growth route) — must-not-flip | +/// | FOREIGN-seq | cleared | an opponent's | `Priority` — **the remedy** | +/// | RETAINED | **retained** | empty | `RespondToShortcut` — **the C2 paired positive**: one field apart from EMPTY-seq, and it flips | /// /// **TWO-SIDED CONTROL, PER ASSERTION** — no constant implementation passes: /// * **DROP** the proposer test (restore `state.last_loop_action_sequence.is_empty()`) ⇒ @@ -2208,6 +3634,10 @@ fn a1_the_users_accept_committed_nothing_board_now_commits_on_every_axis() { /// instead (the shipped object-growth declarations break — the tree's own doc above this arm /// says keying on `template.is_none()` alone does exactly this). TRIVIALIZE to never-reject ⇒ /// EMPTY-seq returns `RespondToShortcut` ⇒ that assertion fails. +/// * **REVERT item-4 C2** (drop `let template = template.or_else(|| offer.declaration.cloned())` +/// from `handle_declare_shortcut`) ⇒ the RETAINED arm returns `Priority` ⇒ **that** assertion +/// fails, while the three cleared-offer arms are untouched (they have no declaration to +/// resolve against, so the `or_else` was already a no-op for them). /// /// ⚠ **WHAT THIS ROW DELIBERATELY DOES NOT ASSERT — a realized negative, recorded rather than /// re-keyed.** Continuing each ACCEPTED arm through `accept_all_opponents` was measured, and both @@ -2238,6 +3668,13 @@ fn a_template_free_declaration_is_admitted_only_by_the_proposers_own_period() { "REACH-GUARD: the published bound must admit `Fixed(1)`, else the arms are refused for \ a reason that has nothing to do with the period" ); + assert!( + offer_declaration(&state).is_some(), + "REACH-GUARD for the `declaration = None` mutation the closure below applies: the \ + UNTOUCHED offer really does publish a declaration, so that clearing is a genuine \ + one-field mutation rather than a no-op restating the fixture. Paired with the \ + `declaration retained` positive at the end of this row" + ); let opp = state .players @@ -2253,9 +3690,42 @@ fn a_template_free_declaration_is_admitted_only_by_the_proposers_own_period() { .expect("the dump has objects"); // One offer state, one field reassigned per arm, one action applied — nothing else differs. + // + // ⚠ THE OFFER'S OWN `declaration` IS CLEARED, and that is what keeps this row LIVE rather + // than what weakens it (item-4 C2). `handle_declare_shortcut` now resolves a `template: + // None` declaration against `offer.declaration` ABOVE the pin block, so on an offer that + // published one, `&template` takes the `Some(t)` arm and site F is never reached — all + // three arms below would read `RespondToShortcut` and the row would be measuring + // `declaration_conforms` instead of the period test it is named for. Clearing the + // declaration puts the row back on the offer shape site F still decides, which is a + // REACHABLE production shape and not a contrivance: `build_bounded_declaration` returns + // `None` on a journal miss or a kind/value mismatch even with a non-empty schema, both + // non-bounded mints hard-code `declaration: None`, and a restored save may carry `None`. + // Measured across the tracked suite at this tip: 34 distinct tests still reach site F on a + // point-carrying offer that published no declaration. let declare_with = |seq: Vec| { let mut probe = state.clone(); probe.last_loop_action_sequence = seq; + match &mut probe.waiting_for { + WaitingFor::LoopShortcut { declaration, .. } => *declaration = None, + other => panic!("expected the CR 732.2a bounded offer, got {other:?}"), + } + apply( + &mut probe, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: None, + }, + ) + .expect("dispatched — a refusal is a HANDBACK, not an error"); + probe.waiting_for.variant_name() + }; + // The SAME EMPTY-seq call with the declaration RETAINED — one field apart from the first + // assertion below, and the axis is the offer's own `declaration`. + let declare_empty_seq_with_declaration_retained = || { + let mut probe = state.clone(); + probe.last_loop_action_sequence = Vec::new(); apply( &mut probe, proposer, @@ -2297,7 +3767,377 @@ fn a_template_free_declaration_is_admitted_only_by_the_proposers_own_period() { "Priority", "FOREIGN-seq — THE REMEDY. CR 732.2a: an opponent's independent activation is not a \ template this proposer's drive can re-derive from, so admitting it would open the \ - CR 732.2b window on a client-supplied declaration that received ZERO pin validation \ + CR 732.2b window on a client-supplied declaration that received ZERO pin validation. \ + NOTE the paired assertion below: this seat-relative refusal is what site F decides on a \ + declaration-free offer, NOT a blanket refusal of `template: None` \ against a schema with published points" ); + // ── PAIRED POSITIVE, and it is what makes the two refusals above ATTRIBUTABLE ── + assert_eq!( + declare_empty_seq_with_declaration_retained(), + "RespondToShortcut", + "item-4 C2: byte-identical to the EMPTY-seq arm above except that the offer's own \ + `declaration` is RETAINED, and it flips. Two things follow, and neither is provable \ + from the refusals alone. (1) Those refusals are site F's seat-relative period verdict, \ + not this fixture refusing every `template: None` declaration for some unrelated reason \ + — an always-reject engine fails HERE. (2) Site F is REACHED at all on the cleared \ + offer, because the only difference between reaching it and bypassing it is the field \ + this assertion restores. Revert C2's `or_else` ⇒ this arm reads `Priority` and the \ + whole row degenerates into three copies of one verdict" + ); +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// item-4 C2 — the engine-issued declaration is HONOURED on the manual declare path +// +// The defect these rows close is an ACTOR DIVERGENCE on one and the same offer: the engine +// mints a bounded offer carrying its own `declaration` (the proposer's journalled answers), +// `ai_support::candidates` reads that field and declares with `template: Some(declaration)` and +// is accepted, while a browser — which structurally sends `template: null`, because the client +// never constructs a template — was refused. The repair is one `Option::or_else` in +// `handle_declare_shortcut`, placed ABOVE the `template.owner` firewall. +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// The accepted proposal behind a `RespondToShortcut` window. Panics loudly on any other state +/// so a row that meant to assert on a proposal can never silently assert on its absence. +fn accepted_proposal(state: &GameState) -> &engine::analysis::loop_check::ShortcutProposal { + match &state.waiting_for { + WaitingFor::RespondToShortcut { proposal, .. } => proposal, + other => panic!("expected the `RespondToShortcut` accept-or-shorten window, got {other:?}"), + } +} + +/// Declare `Fixed(k)` with the browser's own payload (`template: None`) against the live F4 +/// offer, returning the post-state. +fn declare_template_free(state: &GameState, proposer: PlayerId, k: u32) -> GameState { + let mut probe = state.clone(); + apply( + &mut probe, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(k), + template: None, + }, + ) + .expect("dispatched — a refusal is a HANDBACK, not an error"); + probe +} + +/// **Rows R1 + R1b — THE REPAIR.** A browser `template: null` declaration against the real +/// point-carrying bounded offer reaches the ACCEPTED declaration, at every count the picker +/// makes selectable rather than only at the suggested one. +/// +/// # Why this row exists at all +/// +/// `ai_support::candidates` gates its declare candidate on `declaration.is_some()` and sends +/// that very template, so the AI path was already green +/// ([`d6_the_ai_declare_candidate_carries_the_offers_own_published_declaration`]). The manual +/// arm bound `declaration: _` and threw the field away, so the identical offer answered the two +/// ingresses differently. `template: null` is not "no pins" — it is "no OVERRIDE of the pins you +/// already published", and this row is the measurement of that reading. +/// +/// # The revert-failing assertion, named +/// +/// `proposal.template == Some(offer_declaration(&state))` — VALUE-equal against the field the +/// offer published, never `is_some()`. Delete `let template = template.or_else(|| ...)` from +/// `handle_declare_shortcut` and every arm here lands `Priority`, so `accepted_proposal` panics +/// before any assertion is reached. +/// +/// # R1b: the counts are not the suggested one +/// +/// The picker's whole point is that any count in `[min, max]` may be declared, so a repair that +/// only worked at `suggested` would be no repair. `k = 1` is the window's lower edge and +/// `k = 5` is neither edge nor the suggestion — no implementation that special-cases +/// `max_iterations` (which this board publishes as `suggested`) satisfies the `k = 5` arm. +/// `proposal.count` is asserted per arm, so an engine that accepted the declaration but drove +/// the suggested count anyway fails here rather than silently overriding the player. +/// +/// # Reach-guards, asserted BEFORE the claim +/// +/// The schema publishes points (else the pin block is skipped and the row measures the empty +/// path — that is [`c2_r4b_a_points_empty_offer_is_gated_by_the_owner_firewall_alone`]'s +/// subject), the schema is bounded, the offer really published a declaration (else the +/// `or_else` has nothing to resolve against and every arm would be measuring site F), and the +/// window is wide enough that `k = 5` is genuinely interior. The bound is read from the schema +/// rather than pinned to a literal. +#[test] +fn c2_r1_the_browsers_template_free_declaration_reaches_the_accepted_declaration() { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, schema) = offer_parts(&state); + let (points, bounded, max) = ( + schema.points.len(), + schema.is_bounded(), + schema.max_iterations, + ); + + assert!( + points > 0, + "REACH-GUARD: with an empty point set `handle_declare_shortcut` skips the pin block \ + entirely and this row would measure the owner firewall instead of the repair" + ); + assert!( + bounded, + "REACH-GUARD: an unbounded schema takes the `UntilLethal` arms, not this one" + ); + let published = offer_declaration(&state).expect( + "REACH-GUARD: the `or_else` resolves against THIS field — without it every arm \ + below would be measuring site F's period test, not the repair", + ); + assert!( + max >= 5, + "REACH-GUARD: `k = 5` must be INTERIOR to the declarable window, else R1b's \ + non-suggested arm is refused by the `Fixed(n) > max_iterations` cap for a reason that \ + has nothing to do with the repair. max_iterations={max}" + ); + + // R1 — the suggested count, which is `max` on this board. + let at_max = declare_template_free(&state, proposer, max); + assert_eq!( + accepted_proposal(&at_max).template.as_ref(), + Some(&published), + "item-4 C2: the accepted proposal carries the offer's OWN published declaration, \ + value-equal. `is_some()` would also pass on an engine that fabricated an empty \ + template, which is precisely the wrong implementation \ + `a_template_free_declaration_is_admitted_only_by_the_proposers_own_period` kills" + ); + assert_eq!( + accepted_proposal(&at_max).count, + IterationCount::Fixed(max), + "and the count the player named is the count the proposal carries" + ); + + // R1b — a lower-edge count and a strictly interior one. Neither is `suggested`. + for k in [1u32, 5] { + let post = declare_template_free(&state, proposer, k); + assert_eq!( + accepted_proposal(&post).template.as_ref(), + Some(&published), + "R1b at k={k}: the picker may name ANY count in the window, and the resolved \ + declaration is the same published one at every count — the offer publishes one \ + declaration, not one per count" + ); + assert_eq!( + accepted_proposal(&post).count, + IterationCount::Fixed(k), + "R1b at k={k}: the proposal drives the count the player NAMED. An engine that \ + accepted the declaration and then substituted `suggested` fails here. k=5 is \ + neither window edge (1/{max}) nor the suggestion, so no hard-coded value \ + satisfies this arm" + ); + } +} + +/// **Row R3 — PLACEMENT.** A restored offer whose published declaration carries a FOREIGN owner +/// is refused, because the `or_else` resolves the `None` template ABOVE the `template.owner` +/// firewall rather than below it. +/// +/// # ⚠ What this row does and does not discriminate — read before trusting it +/// +/// **It does NOT discriminate the C2 repair itself: it passes both ways.** Pre-repair the +/// `template: None` never resolves, reaches site F and lands `Priority`; post-repair the +/// resolved `Some(hostile)` reaches the owner firewall and lands `Priority`. Same verdict by two +/// different paths, and the paths are indistinguishable from outside — all six refusal arms call +/// the same `reject_shortcut_declaration`, which writes a byte-identical `WaitingFor::Priority` +/// and pushes zero events (`game/engine.rs`, on the count `match`: *"no row can observe which +/// block refused first"*). No assertion can recover which arm fired, so none is attempted here; +/// an arm-exclusion assert would read as verification while proving nothing. +/// [`c2_r1_the_browsers_template_free_declaration_reaches_the_accepted_declaration`] is what +/// covers the repair. +/// +/// **What it DOES discriminate is the `or_else`'s PLACEMENT**, which is the one thing about C2 +/// that is not self-evident from the diff. Move that statement one line down, below the +/// firewall, and this row flips to `RespondToShortcut`: the firewall would see the unresolved +/// `None` and pass it, then the `Some(t)` arm would judge the hostile template by +/// `declaration_conforms` alone — and `declaration_conforms` is `predictability_gate && +/// validate_pins`, neither of which reads `owner`. The firewall is therefore the SOLE refuser of +/// a foreign-owner declaration, and below it there is nothing left to refuse one. +/// MEASURED, by physically relocating the statement: refused above, ACCEPTED below. +/// +/// # Fixture guard, labelled honestly +/// +/// `offer_declaration(..).is_some()` after the mutation is a FIXTURE guard — it proves the owner +/// rewrite did not erase the declaration — and not a path discriminator. It is true pre-repair +/// as well. +/// +/// # The matched positive is what makes "refused" mean anything +/// +/// The untampered offer, same call, same count, must open APNAP. Without it, `Priority` here is +/// indistinguishable from a fixture that refuses everything. The two differ in exactly one +/// field: `declaration.owner`. +#[test] +fn r3_placement_a_restored_foreign_owner_declaration_is_refused() { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, schema) = offer_parts(&state); + assert!( + !schema.points.is_empty(), + "REACH-GUARD: an empty point set would make the two arms differ for a different reason" + ); + let hostile = state + .players + .iter() + .find(|p| p.id != proposer && !p.is_eliminated) + .map(|p| p.id) + .expect("REACH-GUARD: a living seat other than the proposer must exist on a 4p board"); + + // The RESTORE ingress image: a persisted offer whose published declaration names another + // seat. One field differs from the untampered board. + let mut tampered = state.clone(); + match &mut tampered.waiting_for { + WaitingFor::LoopShortcut { declaration, .. } => { + declaration + .as_mut() + .expect("the untampered offer publishes a declaration") + .owner = hostile; + } + other => panic!("expected the CR 732.2a bounded offer, got {other:?}"), + } + assert_eq!( + offer_declaration(&tampered).map(|d| d.owner), + Some(hostile), + "FIXTURE GUARD (not a path discriminator): the owner rewrite landed and did not erase \ + the declaration. This is equally true before the repair" + ); + + assert_eq!( + declare_template_free(&tampered, proposer, 1) + .waiting_for + .variant_name(), + "Priority", + "PLACEMENT: the resolved declaration meets the `template.owner` firewall BEFORE anything \ + else looks at it. Relocate the `or_else` below that firewall and this reads \ + `RespondToShortcut`, because `declaration_conforms` accepts a template that differs \ + only in `owner`" + ); + // ── MATCHED POSITIVE, one field apart ── + assert_eq!( + declare_template_free(&state, proposer, 1) + .waiting_for + .variant_name(), + "RespondToShortcut", + "the byte-identical offer whose declaration is owned by the PROPOSER opens the APNAP \ + window. Without this arm the refusal above would be indistinguishable from a fixture \ + that refuses every declaration" + ); +} + +/// **Rows R4b + R5 — the points-EMPTY offer, where the owner firewall is the only gate.** +/// +/// `handle_declare_shortcut` runs the pin block only under `!offer.schema.points.is_empty()`, so +/// on a point-free offer neither `declaration_conforms` nor site F ever runs and the resolved +/// template meets the firewall alone. Three arms on one F4-derived fixture, `schema.points` +/// emptied: +/// +/// | arm | offer `declaration` | expected | +/// |---|---|---| +/// | **R5** point-free control | cleared | `RespondToShortcut` — accepts pre- AND post-repair | +/// | **R4b/A** | retained, `owner == proposer` | `RespondToShortcut`, and `proposal.template` carries it | +/// | **R4b/B** | retained, `owner == hostile` | `Priority` — the firewall, alone | +/// +/// # Per-arm discrimination, stated rather than assumed +/// +/// **R5 passes both ways and is labelled a CONTROL.** Its job is to prove this fixture accepts +/// declarations at all once the point set is gone, so R4b/B's refusal is attributable to the +/// owner rather than to the emptied schema. It also pins that the `or_else` is a genuine no-op +/// on the shape §4.3 calls row 4: every production mint publishes `declaration: None` for an +/// empty schema, because `build_bounded_declaration` returns `None` on +/// `schema.points.is_empty()` before doing anything else. +/// +/// **R4b/A discriminates the repair** — pre-repair `proposal.template` is `None` here, so the +/// `Some(..)` assertion fails. **R4b/B discriminates in the OPPOSITE direction** — pre-repair +/// the firewall sees an unresolved `None` and ACCEPTS, so `Priority` is the post-repair verdict +/// only. The pair is the row; neither half alone shows both directions. +/// +/// # The capability R4b/B does not create, recorded because it looks like one +/// +/// A points-empty offer carrying a restored declaration is reachable only through the restore +/// ingress — no production mint emits that pair. The `or_else` is deliberately NOT guarded with +/// `!points.is_empty()`: a live client can already send `template: Some(anything owned by the +/// proposer)` against a points-empty offer today and reach `proposal.template` with the pin +/// block skipped, so the firewall is the only gate on this shape both before and after. A guard +/// for an unreachable case is a special case; the behaviour is pinned here instead. +#[test] +fn c2_r4b_a_points_empty_offer_is_gated_by_the_owner_firewall_alone() { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, _schema) = offer_parts(&state); + let hostile = state + .players + .iter() + .find(|p| p.id != proposer && !p.is_eliminated) + .map(|p| p.id) + .expect("REACH-GUARD: a living seat other than the proposer must exist on a 4p board"); + let published = + offer_declaration(&state).expect("the untampered offer publishes a declaration"); + + // One F4 offer, `schema.points` emptied, `declaration` set per arm. Nothing else differs. + let point_free_offer = |declaration: Option| { + let mut probe = state.clone(); + match &mut probe.waiting_for { + WaitingFor::LoopShortcut { + schema, + declaration: decl, + .. + } => { + schema.points.clear(); + *decl = declaration.map(|owner| { + let mut d = published.clone(); + d.owner = owner; + d + }); + } + other => panic!("expected the CR 732.2a bounded offer, got {other:?}"), + } + assert!( + match &probe.waiting_for { + WaitingFor::LoopShortcut { schema, .. } => schema.points.is_empty(), + _ => false, + }, + "REACH-GUARD: the row is about the SKIPPED pin block, so the point set must really \ + be empty — otherwise `declaration_conforms` runs and the firewall is not alone" + ); + probe + }; + + // ── R5, the point-free CONTROL: passes pre- and post-repair ── + assert_eq!( + declare_template_free(&point_free_offer(None), proposer, 1) + .waiting_for + .variant_name(), + "RespondToShortcut", + "R5 CONTROL: a point-free offer publishing no declaration drains exactly as before — \ + the `or_else` resolves `None` to `None` and is a no-op. This arm is what makes R4b/B's \ + refusal below attributable to the OWNER rather than to the emptied schema" + ); + + // ── R4b/A: retained declaration, owner == proposer ── + let honest = declare_template_free(&point_free_offer(Some(proposer)), proposer, 1); + assert_eq!( + honest.waiting_for.variant_name(), + "RespondToShortcut", + "R4b/A: the firewall passes a declaration owned by the proposer" + ); + assert_eq!( + accepted_proposal(&honest) + .template + .as_ref() + .map(|t| t.owner), + Some(proposer), + "R4b/A discriminates the repair: PRE-repair `proposal.template` is `None` here, because \ + the offer's declaration was discarded and the pin block never ran. The resolved \ + template reaching the proposal is the change" + ); + + // ── R4b/B: retained declaration, foreign owner — the OPPOSITE direction ── + assert_eq!( + declare_template_free(&point_free_offer(Some(hostile)), proposer, 1) + .waiting_for + .variant_name(), + "Priority", + "R4b/B discriminates in the opposite direction from R4b/A: PRE-repair this ACCEPTS, \ + because the firewall inspects an unresolved `None` and passes it. Post-repair the \ + resolved foreign-owner declaration meets the firewall and is refused. A row asserting \ + only R4b/A would miss that the repair WIDENS what the firewall inspects" + ); } diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 199e773e0c..7c9f9f9e3f 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -34,7 +34,8 @@ use engine::types::interaction::{ InteractionPreviewRequest, InteractionPreviewStatus, InteractionReasonCode, InteractionResponse, InteractionResponseSpec, InteractionRoleCode, InteractionSessionId, InteractionShortcutCountSpec, InteractionShortcutDecision, InteractionShortcutPin, - InteractionShortcutPointKind, InteractionShortcutResponseCode, InteractionSubmission, + InteractionShortcutPointKind, InteractionShortcutPreview, InteractionShortcutPreviewEntry, + InteractionShortcutPreviewFamily, InteractionShortcutResponseCode, InteractionSubmission, PreviewRequestId, MAX_INTERACTION_LIST_LEN, }; use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; @@ -2365,6 +2366,7 @@ fn loop_shortcut_zero_max_iterations_is_rejected_not_clamped() { max_iterations, ..Default::default() }, + declaration: None, }; bind(&mut state, "loop-zero-bound"); state @@ -2426,6 +2428,7 @@ fn loop_shortcut_narrowed_max_iterations_bounds_the_picker() { max_iterations: 3, ..Default::default() }, + declaration: None, }; bind(&mut state, "loop-narrowed-bound"); @@ -2474,6 +2477,7 @@ fn loop_shortcut_number_schema_accepts_a_fixed_count_above_one() { // No narrowed CR 732.2a bound — `Default` carries the global cap. ..Default::default() }, + declaration: None, }; bind(&mut state, "loop-count"); let view = priority_view(&state); @@ -2499,6 +2503,386 @@ fn loop_shortcut_number_schema_accepts_a_fixed_count_above_one() { assert_eq!(preview.status, InteractionPreviewStatus::Confirmable); } +/// The per-period signature the C4 preview rows multiply out. Chosen so that three separate +/// ways of getting the preview wrong all show up as a value mismatch: +/// +/// * **two mana colors**, so a preview that published raw axes instead of folding them into +/// one engine-side family total would emit two `Mana` rows; +/// * **a life LOSS on a seat that is not the proposer**, which `unbounded_components` drops +/// entirely (it reports only what a cycle accrues) and which a proposer-keyed subject +/// mapping would attribute to the wrong player; +/// * **a whole-game axis** (`tokens_created`) with no seat, so the `Option` subject is +/// exercised on both sides. +fn preview_period_delta() -> engine::analysis::resource::ResourceVector { + let mut delta = engine::analysis::resource::ResourceVector::default(); + // `MANA_INDEX` is `[W, U, B, R, G, C]`. + delta.mana[0] = 1; + delta.mana[1] = 2; + delta.life.insert(P1, -2); + delta.tokens_created = 4; + delta +} + +/// A `LoopShortcut` offer stated exactly the way `certified_bounded_cycle_offer` states one: +/// `Fixed(max_iterations)` as the suggestion and the same number as the ceiling, with the +/// measured period on the certificate. +fn preview_offer( + iteration_count: IterationCount, + max_iterations: u32, + per_cycle: Option, +) -> GameState { + let mut state = GameState::new_two_player(42); + state.waiting_for = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: Some(P0), + certificate: engine::analysis::loop_check::LoopCertificate { + unbounded: Vec::new(), + win_kind: engine::analysis::loop_check::WinKind::Advantage, + mandatory: false, + residual_board_delta: engine::analysis::resource::BoardDelta::default(), + per_cycle: per_cycle.map(|delta| engine::analysis::resource::PeriodicDelta { + frames_per_period: 1, + delta, + victim_slot: Vec::new(), + }), + }, + schema: ShortcutDecisionSchema { + iteration_count, + max_iterations, + ..Default::default() + }, + // `points` is empty here (`..Default::default()`), and an empty schema never publishes a + // declaration — the same invariant row D4 asserts against `build_bounded_declaration`. So + // `None` is what the engine itself would stage, not merely what makes the literal compile. + // These rows exercise the PREVIEW projection, which reads the certificate and schema; a + // declaration here would stage a state the producer cannot emit. + declaration: None, + }; + bind(&mut state, "loop-preview"); + state +} + +fn shortcut_preview_of(state: &GameState) -> Option { + let view = priority_view(state); + let InteractionOpportunityResponse::Schema { + spec: InteractionResponseSpec::Shortcut { preview, .. }, + .. + } = &view.opportunities[0].response + else { + panic!("loop shortcut uses a shortcut schema"); + }; + preview.clone() +} + +fn preview_entry( + family: InteractionShortcutPreviewFamily, + player: Option, + amount: i32, +) -> InteractionShortcutPreviewEntry { + InteractionShortcutPreviewEntry { + family, + player, + amount, + } +} + +/// C4a — CR 732.2a: the offer publishes what its stated count actually DOES, computed by the +/// engine as `n × δ` over the certificate's measured per-period delta. Without this the count +/// picker C5 wires up is a number with no displayed consequence, and the only other way to +/// show one is `× count` arithmetic in the display layer, which the layer rule forbids. +/// +/// **Asserted at TWO distinct counts, and that is the point of the row.** A single count is +/// satisfiable by an implementation that ignores `count` entirely and publishes the raw +/// per-cycle delta, or by one that hardcodes a constant. Only the pair pins the +/// multiplication. +/// +/// REVERT-PROBES, both RUN: +/// * drop the `count` factor (`per_cycle` instead of `per_cycle.saturating_mul(count)`) ⇒ +/// both arms fail on values; +/// * hardcode the factor to `3` ⇒ the `n = 3` arm still PASSES and the `n = 5` arm fails, +/// which is exactly the "one value is satisfiable by a constant" hole the second count +/// closes. +#[test] +fn loop_shortcut_preview_states_the_finished_magnitude_for_the_declared_count() { + use engine::analysis::resource::ResourceAxis; + + // ── REACH-GUARDS on the fixture, before any preview is read. Each one names the wrong + // implementation it makes observable; without them this row could pass while the + // preview was built on the wrong fold or aggregated in the wrong layer. + let delta = preview_period_delta(); + assert!( + !delta + .unbounded_components() + .iter() + .any(|(axis, _)| matches!(axis, ResourceAxis::Life(_))), + "reach-guard: the victim's life LOSS is INVISIBLE to `unbounded_components`, so a \ + preview rebuilt on that fold would silently publish a lethal drain as producing \ + nothing. The `Life` expectations below are what detect it" + ); + assert_eq!( + delta + .axis_components() + .iter() + .filter(|(axis, _)| matches!(axis, ResourceAxis::Mana(_))) + .count(), + 2, + "reach-guard: the period moves TWO mana axes, so the single `Mana` entry expected \ + below is proof the engine folded them — not proof that only one existed" + ); + assert_ne!( + P1.0, P0.0, + "reach-guard: the victim is not the proposer, so a subject mapping keyed off the \ + proposer resolves to the wrong seat" + ); + + let at = |n: u32| { + shortcut_preview_of(&preview_offer( + IterationCount::Fixed(n), + n, + Some(preview_period_delta()), + )) + .expect("a bounded offer with a measured period states a preview") + }; + + let three = at(3); + assert_eq!( + three.count, 3, + "the count travels WITH the magnitudes, so a renderer cannot attach them to another" + ); + assert_eq!( + three.entries, + vec![ + preview_entry(InteractionShortcutPreviewFamily::Mana, None, 9), + preview_entry(InteractionShortcutPreviewFamily::Life, Some(P1.0), -6), + preview_entry(InteractionShortcutPreviewFamily::Tokens, None, 12), + ], + "CR 732.2a: three repetitions of (+1W +2U, P1 -2 life, +4 tokens) finish at +9 mana, \ + P1 at -6 life, +12 tokens" + ); + + let five = at(5); + assert_eq!(five.count, 5); + assert_eq!( + five.entries, + vec![ + preview_entry(InteractionShortcutPreviewFamily::Mana, None, 15), + preview_entry(InteractionShortcutPreviewFamily::Life, Some(P1.0), -10), + preview_entry(InteractionShortcutPreviewFamily::Tokens, None, 20), + ], + "the SECOND count is what makes this row unsatisfiable by a constant: an \ + implementation pinned to 3 passes the arm above and fails here" + ); +} + +/// C4a, negative half — a preview is published only when the offer supplies BOTH authorities +/// it multiplies: a measured per-period signature and a finite count. Every arm is paired +/// with the positive control on the same builder, so none of them can pass because the whole +/// window failed to project. +#[test] +fn loop_shortcut_preview_is_absent_without_both_a_period_and_a_finite_count() { + // ── PAIRED POSITIVE, first. + assert!( + shortcut_preview_of(&preview_offer( + IterationCount::Fixed(4), + 4, + Some(preview_period_delta()), + )) + .is_some(), + "control: both authorities present must publish a preview, else every arm below \ + passes for an unrelated reason" + ); + + // ── No measured period: every mint except the bounded one carries `per_cycle: None`, + // as does every save written before that field existed. + assert_eq!( + shortcut_preview_of(&preview_offer(IterationCount::Fixed(4), 4, None)), + None, + "an offer that states no per-period signature has nothing to multiply" + ); + + // ── CR 704.5a: `UntilLethal` is the determinate-drain mode. It names no number, so + // there is no declared count to state a finished magnitude for — even though the + // period here IS measured, which is what keeps this arm distinct from the one above. + assert_eq!( + shortcut_preview_of(&preview_offer( + IterationCount::UntilLethal, + 4, + Some(preview_period_delta()), + )), + None, + "`UntilLethal` states no finite count to multiply the period by" + ); + + // ── A period whose every family nets to zero (one W gained and one W spent) states + // nothing, and is dropped rather than published as a row of zeroes. + let mut inert = engine::analysis::resource::ResourceVector::default(); + inert.mana[0] = 1; + inert.mana[5] = -1; + assert_eq!( + inert.axis_components().len(), + 2, + "reach-guard: the inert period really does move two axes, so the `None` below is the \ + family fold cancelling them — not an empty vector arriving empty" + ); + assert_eq!( + shortcut_preview_of(&preview_offer(IterationCount::Fixed(4), 4, Some(inert))), + None, + "a period that nets to nothing on every family publishes no preview at all" + ); +} + +/// C4a's hostile guard — the preview is ARITHMETIC, and must never become a clone-apply. +/// +/// `game::interaction::preview_interaction` answers a different question (is this response +/// submittable) by cloning the whole `GameState` and applying to the clone. It cannot answer +/// this one: a CR 732.2a shortcut's declared count may reach `MAX_SHORTCUT_CYCLES`, and the +/// entire point of the rule is that the sequence is NOT played out to find out what it does. +/// A future rewrite that reached for the previewer would be quietly quadratic and quietly +/// wrong, and no value assertion would catch it — so this row reads the source. +/// +/// ⚠ TWO SPANS, AND THE SECOND ONE IS WHY THIS ROW CAN FAIL AT ALL (fix round 2, F1). +/// +/// The first revision read only `shortcut_preview_entries`, whose signature is +/// `(&ResourceVector, u32)` — no `GameState` is in scope anywhere in it, and neither is one in +/// its only caller `loop_shortcut_projection(&WaitingFor)`. The banned construct was therefore +/// not CONSTRUCTIBLE in the span, so the row could not fail no matter what regressed. MEASURED +/// by the reviewer: inserting `let mut probe_clone = authoritative_state.clone();` immediately +/// above the `loop_shortcut_projection` call left all three C4 rows green. +/// +/// The clone-apply can only originate where the spec is BUILT: `opportunity_for_slot`'s +/// `LoopShortcut` arm, which holds `authoritative_state` and `filtered_state`, both +/// `&GameState`. Both spans are read now, and the arm span proves its OWN constructibility — +/// the enclosing signature binds two `&GameState` parameters and the span uses one — so it +/// cannot silently degrade into another span where the ban is unwritable. A positive control +/// proves the SEARCH is real; only the constructibility guard proves the SPAN is right. +/// +/// WHAT WRONG IMPLEMENTATION WOULD STILL PASS THIS ROW? One that clones the state inside a +/// THIRD function called from the arm — the ban is textual, not a call-graph closure — and one +/// that computes the right numbers by some other expensive means. This is a routing guard; the +/// value rows above pin the arithmetic. +/// +/// The likeliest instance of that first gap is closed by TYPE rather than by text (fix round 3, +/// G4): the cheapest way to reach a `GameState` from the preview is to widen +/// `loop_shortcut_projection` to accept one, which contains none of the banned strings and +/// lives in a span this row does not read. Its parameter list is pinned below, so the +/// projection can see the waiting-for state and nothing else — and neither can anything it +/// calls. What remains uncovered is a clone reached through some OTHER existing binding, which +/// no signature can rule out. +/// +/// REVERT-PROBES, ALL THREE RUN: +/// * add the line `// preview_interaction` inside `shortcut_preview_entries` ⇒ FAILS on the +/// assert (it still compiles, so the probe discriminates on the assertion, not the build); +/// * insert `let mut probe_clone = authoritative_state.clone();` immediately above the +/// `loop_shortcut_projection` call in the arm — the reviewer's exact probe ⇒ FAILS; +/// * widen `loop_shortcut_projection` to `(waiting_for: &WaitingFor, _state: &GameState)` — +/// the exact evasion the textual ban misses ⇒ FAILS on the signature pin (and on nothing +/// else, which is the point). +#[test] +fn loop_shortcut_preview_never_routes_through_the_clone_apply_previewer() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/game/interaction.rs"); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + + // ── POSITIVE CONTROL: the banned symbol IS in this file. Without this the "does not + // contain" assertions below would pass just as happily against an empty read, a + // renamed file, or a search that never matched anything. + assert!( + text.contains("pub fn preview_interaction("), + "positive control: `preview_interaction` must exist in this file, else the absence \ + asserted below is the absence of the whole search" + ); + + // The span from `marker` up to the next `terminator`, both anchored at a line start. + let extract = |scope: &str, marker: &str, terminator: &str| -> String { + let start = scope.find(marker).unwrap_or_else(|| { + panic!("reach-guard: `{marker}` must be found by name, or this row is vacuous") + }); + let rest = &scope[start + marker.len()..]; + let end = rest.find(terminator).unwrap_or(rest.len()); + format!("{marker}{}", &rest[..end]) + }; + + // ── SPAN 1: the arithmetic itself. + let arithmetic = extract(&text, "\nfn shortcut_preview_entries(", "\nfn "); + assert!( + arithmetic.contains("saturating_mul"), + "reach-guard: the extracted span must be the real body — the multiplication is the \ + function's entire job, so its absence means the span is wrong" + ); + + // ── SPAN 2: the attach site, where the spec carrying the preview is built. + let builder = "\nfn opportunity_for_slot("; + let builder_start = text.find(builder).expect( + "reach-guard: the spec builder must be found by name — it is the only scope holding a \ + `GameState` on the preview's path", + ); + let builder_scope = &text[builder_start..]; + let signature_end = builder_scope + .find(") -> ") + .expect("reach-guard: the builder's signature must be delimited"); + let signature = &builder_scope[..signature_end]; + // ── CONSTRUCTIBILITY: the ban below is only a guard where the banned thing can be + // WRITTEN. This span sits inside a function that binds two `&GameState` parameters, + // so `authoritative_state.clone()` — the reviewer's exact probe — compiles here. + assert!( + signature.contains("authoritative_state: &GameState") + && signature.contains("filtered_state: &GameState"), + "constructibility: the arm span guards nothing unless a `GameState` is IN SCOPE to be \ + cloned. `shortcut_preview_entries` takes `(&ResourceVector, u32)`, which is exactly \ + why reading only that function produced a row that could not fail" + ); + let attach = extract( + builder_scope, + "\n HumanResponseModel::LoopShortcut => {", + "\n HumanResponseModel::", + ); + assert!( + attach.contains("loop_shortcut_projection(") && attach.contains("projection.preview"), + "reach-guard: the extracted arm must be the one that projects the offer AND publishes \ + the preview onto the spec, else the ban is being applied to the wrong arm" + ); + assert!( + attach.contains("filtered_state"), + "constructibility, second half: the arm must actually USE one of those `&GameState` \ + bindings, so a clone is writable at the exact point the reviewer's probe inserted one" + ); + + // ── TYPE-LEVEL PIN: the ban below is TEXTUAL, so its cheapest evasion is to widen + // `loop_shortcut_projection` to take a `&GameState` and clone it THERE — a third span + // this row does not read, and one that would contain none of the three banned strings. + // The projection's parameter list closes that route by TYPE rather than by text: with + // only a `&WaitingFor` in scope, no callee it reaches can be handed a `GameState` + // either, so "the preview computation cannot see game state" stops being a search + // result and becomes a fact about the signature. + let projection_signature = extract(&text, "\nfn loop_shortcut_projection(", ") -> "); + let projection_params = projection_signature + .strip_prefix("\nfn loop_shortcut_projection(") + .expect("`extract` re-emits its own marker, so the prefix is always present") + .split_whitespace() + .collect::>() + .join(" "); + assert_eq!( + projection_params.trim_end_matches(','), + "waiting_for: &WaitingFor", + "type-level pin: the shortcut preview is computed from the WAITING-FOR state alone. \ + Adding a parameter here — a `&GameState`, or anything reaching one — reopens the \ + clone-apply route through a span the textual ban below never reads" + ); + + for (span_name, body) in [ + ("shortcut_preview_entries", &arithmetic), + ("opportunity_for_slot's LoopShortcut arm", &attach), + ] { + for banned in ["preview_interaction", "state.clone()", "GameState"] { + assert!( + !body.contains(banned), + "CR 732.2a: the shortcut preview is `n × δ` over the certificate's measured \ + period. {span_name} must not reach `{banned}` — a clone-apply cannot state \ + the result of a sequence that is deliberately never played out" + ); + } + } +} + #[test] fn loop_shortcut_schema_and_materializer_cover_every_decision_point_kind() { let mut scenario = GameScenario::new(); @@ -2570,6 +2954,7 @@ fn loop_shortcut_schema_and_materializer_cover_every_decision_point_kind() { ], convoke_tappable_count: 1, }, + declaration: None, }; bind(runner.state_mut(), "loop-point-kinds"); @@ -2643,6 +3028,187 @@ fn loop_shortcut_schema_and_materializer_cover_every_decision_point_kind() { ); } +/// **Row R2-f — the HUMAN ingress emits the same spelling as the engine's own producer.** +/// +/// CR 601.2c: one shape per point kind, whoever submitted it. `materialize_loop_shortcut_response` +/// decodes a submitted player candidate on a `Targets` point into +/// `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(..))))` — the same value +/// `game::engine::record_trigger_target_answer` journals for an announced seat — and an OBJECT +/// candidate on the SAME point into `TargetPin::ByIdentity`, unchanged. +/// +/// # Discrimination +/// +/// Migrate only the engine's producer and leave this decoder emitting `TargetPin::Player(*player)` +/// ⇒ one `Targets` point yields two different pin spellings depending on WHO submitted the +/// answer, and the seat assertion below FAILS while the object assertion stays green. That +/// asymmetry — one arm moving, one not — is what makes this a spelling row rather than a +/// smoke test. +/// +/// # Paired positive reach-guard +/// +/// The decoder must still ACCEPT end to end: `resolve_interaction_response` returns +/// `Ok(GameAction::DeclareShortcut { .. })`, which means `declaration_conforms` ran +/// `predictability_gate` and `validate_pins` at range 1 and passed. Without it the row would be +/// satisfied by a decoder that had simply started refusing everything. +/// +/// # ⚠ WHY THIS ROW BUILDS ITS OWN BOARD (measured, not preference) +/// +/// The file's other shortcut rows share a schema whose only slot source is +/// `AllCopies { CardId(9001) }`, which no battlefield object carries. After the split a `Seat` +/// pin on such a slot resolves through `resolve_ability_instance` ⇒ `resolve_source`'s +/// `AllCopies` arm (`.filter(|o| o.zone == Zone::Battlefield && o.card_id == *card_id)`) ⇒ +/// `None` ⇒ `IllegalTarget` ⇒ `validate_pins` ⇒ `declaration_conforms == false` ⇒ +/// `ConstraintUnsatisfied`. The positive reach-guard above would be UNSATISFIABLE there, and the +/// cheapest-looking repair would be to loosen a fail-closed predicate. So the slot source here is +/// a `ThisObject` naming a live battlefield creature, at that object's LIVE incarnation read from +/// state (CR 400.7) — never a hard-coded one. `AllCopies` cannot take the CR 114.4 / CR 113.6p +/// command-zone disjunct either: that disjunct is `ThisObject`-only, so a command-zone source +/// named by CARD identity (a conspiracy, an Eminence commander — both of which DO have cards) +/// still resolves `None` and fails closed. Measured residual, disclosed rather than closed. +/// +/// The three shipped `Shortcut` rows in this file are untouched by the split, but by INDEX +/// ORDERING rather than by design: the file has exactly one candidate-selection site and it takes +/// `candidate_ids[0]`, which on the one board offering both is the OBJECT. That vector must not +/// be reordered. +/// +/// This row's own board deliberately exercises BOTH indices, and the two arms key each other: if +/// the projection's candidate order did not follow `legal_targets`, both assertions would fail +/// rather than one silently passing on the wrong candidate. +#[test] +fn loop_shortcut_human_ingress_emits_the_target_class_spelling_for_a_submitted_seat() { + use engine::analysis::decision_template::{ + AnnouncementSubject, PinnedDecision, Ranking, TargetPin, TargetSchedule, + }; + + let mut scenario = GameScenario::new(); + let target = scenario.add_creature(P0, "R2f Ability Source", 1, 1).id(); + let mut runner = scenario.build(); + let incarnation = runner.state().objects[&target].incarnation; + let slot = DecisionSlot { + source: engine::types::game_state::YieldTarget::ThisObject { + source_id: target, + incarnation: Some(incarnation), + trigger_description: None, + }, + index: 0, + }; + runner.state_mut().waiting_for = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: Some(P0), + certificate: engine::analysis::loop_check::LoopCertificate { + unbounded: Vec::new(), + win_kind: engine::analysis::loop_check::WinKind::Advantage, + mandatory: false, + residual_board_delta: engine::analysis::resource::BoardDelta::default(), + per_cycle: None, + }, + schema: ShortcutDecisionSchema { + iteration_count: IterationCount::Fixed(2), + max_iterations: ShortcutDecisionSchema::default().max_iterations, + points: vec![DecisionPoint { + slot: slot.clone(), + kind: DecisionPointKind::Targets { + // Index 0 is the OBJECT, index 1 is the SEAT. Both are exercised below. + legal_targets: vec![TargetRef::Object(target), TargetRef::Player(P1)], + min_targets: 1, + max_targets: 1, + ordered: true, + }, + }], + convoke_tappable_count: 0, + }, + declaration: None, + }; + bind(runner.state_mut(), "r2f-human-seat-pin"); + + let view = priority_view(runner.state()); + let InteractionOpportunityResponse::Schema { + spec: InteractionResponseSpec::Shortcut { points, .. }, + .. + } = &view.opportunities[0].response + else { + panic!("the loop shortcut offer uses a shortcut schema"); + }; + assert_eq!( + points.len(), + 1, + "reach-guard: exactly one published point, so the pin below addresses the point this \ + row is about" + ); + assert_eq!( + points[0].candidate_ids.len(), + 2, + "reach-guard: BOTH legal targets must be offered as candidates, else one of the two \ + arms below is unreachable" + ); + + let decode = |candidate: usize| { + resolve_interaction_response( + runner.state(), + P0, + &InteractionSubmission { + interaction_id: view.opportunities[0].interaction_id.clone(), + response: InteractionResponse::Shortcut { + decision: InteractionShortcutDecision::AcceptSuggested, + pins: vec![InteractionShortcutPin { + group: 0, + choice_ids: vec![points[0].candidate_ids[candidate].clone()], + }], + }, + }, + ) + }; + + // ── THE CLAIM: a submitted SEAT decodes to the CR 601.2c TARGET-class spelling ── + let GameAction::DeclareShortcut { + template: Some(seat_template), + .. + } = decode(1).expect( + "paired positive: the human ingress still ACCEPTS end to end — `declaration_conforms` \ + ran `predictability_gate` and `validate_pins` at range 1 and passed", + ) + else { + panic!("a shortcut acceptance carrying pins materializes a template"); + }; + assert_eq!( + seat_template.decisions, + vec![PinnedDecision::Targets { + slot: slot.clone(), + targets: vec![TargetPin::Scheduled(TargetSchedule::Constant( + Ranking::one(AnnouncementSubject::Seat(P1)) + ))], + }], + "CR 601.2c: a candidate on a `Targets` point is an ANNOUNCED target, so a submitted \ + seat takes the TARGET-class spelling — the same value the engine's own producer \ + journals. `TargetPin::Player(P1)` here would select the authority by WHO SUBMITTED \ + the answer rather than by WHAT IT IS" + ); + + // ── THE SIBLING: an OBJECT candidate on the SAME point is unchanged ── + let GameAction::DeclareShortcut { + template: Some(object_template), + .. + } = decode(0).expect("the object candidate is accepted on the same point") + else { + panic!("a shortcut acceptance carrying pins materializes a template"); + }; + assert_eq!( + object_template.decisions, + vec![PinnedDecision::Targets { + slot, + targets: vec![TargetPin::ByIdentity( + engine::types::game_state::YieldTarget::ThisObject { + source_id: target, + incarnation: Some(incarnation), + trigger_description: None, + } + )], + }], + "the migration re-spelled the SEAT branch only: an object candidate still binds by \ + CR 400.7 identity" + ); +} + #[test] fn coin_flip_sequence_supports_multi_keep_and_rejects_duplicates() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs index e3f526191d..8eeb224413 100644 --- a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs +++ b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs @@ -88,7 +88,13 @@ fn gunzip(gz: &[u8]) -> String { /// Load the real 4p dump's `["gameState"]` and route it through the REAL production restore /// chokepoint `PersistedGameState::into_game_state` (both server `from_persisted` and WASM -/// `decode_restored_game_state` funnel through it). The sequence deserializes NORMALLY (len 6), +/// `decode_restored_game_state` funnel through it). The chokepoint now rehydrates the ChaCha20 +/// stream, which only `engine-wasm`'s `restore_game_state` used to do on its own — a load that +/// ENDED at the chokepoint, as this one does, was left with a word-0 stream under this dump's +/// saved `rng_word_pos` of 293. WASM's own call is now an idempotent repeat. Callers may still +/// diverge afterwards: `GameSession::from_persisted` re-seeds and zeroes `rng_word_pos` with it, +/// discarding the saved position rather than resuming it as this load does. +/// The sequence deserializes NORMALLY (len 6), /// then `GameState::migrate_transient_loop_sequence` DROPS it because the dump was captured at /// empty-stack `Priority` (NOT a shortcut window) — exactly the production load behavior. Reverting /// the migration (or its `Priority`-drops-it branch) leaves the 6 stale pinless steps intact ⇒ the diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 5ce7c08f5b..fdbfbb4c5c 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -15,9 +15,9 @@ //! `match` perturbed even one event. Because the golden is pre-edit, this is not circular. use engine::analysis::decision_template::{ - DecisionGroupKey, DecisionKind, DecisionPoint, DecisionPointKind, DecisionSlot, - DecisionTemplate, IterationCount, PinnedDecision, ReplayMode, ShortcutDecisionSchema, - TargetPin, TargetSchedule, + AnnouncementSubject, DecisionGroupKey, DecisionKind, DecisionPoint, DecisionPointKind, + DecisionSlot, DecisionTemplate, IterationCount, PinnedDecision, Ranking, ReplayMode, + ShortcutDecisionSchema, TargetPin, TargetSchedule, }; use engine::analysis::loop_check::{LoopCertificate, ShortcutProposal, ShortcutResponse, WinKind}; use engine::analysis::resource::{loop_states_equal_modulo_resources, BoardDelta, ResourceAxis}; @@ -40,6 +40,14 @@ const P0: PlayerId = PlayerId(0); const P1: PlayerId = PlayerId(1); const P2: PlayerId = PlayerId(2); +/// The one-element ranking a schedule step carries when it names a single object: a +/// `Ranking::one(Object(src))` IS the pre-parameterization constant subject, so every site +/// below is a re-spelling with no behaviour delta (CR 732.2a — only the head is ever +/// resolved within one drive). +fn obj_rank(src: YieldTarget) -> Ranking { + Ranking::one(AnnouncementSubject::Object(src)) +} + const DRAIN_CLERIC: &str = "Whenever you gain life, each opponent loses 1 life."; const BLOOD_SIPPER: &str = "Whenever an opponent loses life, you gain 1 life."; const KICKOFF: &str = "You gain 1 life."; @@ -1079,12 +1087,14 @@ fn loop_shortcut_acting_player_reads_proposer() { predicted_winner: Some(P0), certificate: cert.clone(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; let wf_b = WaitingFor::LoopShortcut { proposer: P2, predicted_winner: None, certificate: cert.clone(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; assert_eq!(wf_a.acting_player(), Some(P1)); assert_eq!(wf_b.acting_player(), Some(P2)); @@ -1118,6 +1128,7 @@ fn loop_shortcut_acting_player_reads_proposer() { predicted_winner: Some(P0), certificate: cert.clone(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; apply(&mut delegated, P0, GameAction::DeclineShortcut) .expect("the turn controller may submit the priority holder's decline"); @@ -1691,6 +1702,7 @@ fn injected_3p_one_faller_no_crown() { predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; runner .act(GameAction::DeclareShortcut { @@ -1832,6 +1844,7 @@ fn declare_illegal_pin_falls_back_legal_ingests() { predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema: schema.clone(), + declaration: None, }; runner .act(GameAction::DeclareShortcut { @@ -1852,6 +1865,7 @@ fn declare_illegal_pin_falls_back_legal_ingests() { predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema, + declaration: None, }; runner2 .act(GameAction::DeclareShortcut { @@ -1908,6 +1922,7 @@ fn injected_3p_unequal_life_pin_all_no_crown() { predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; runner .act(GameAction::DeclareShortcut { @@ -2051,9 +2066,9 @@ fn piecewise_cleric_template( source: valid.clone(), index: 0, }; - let mut schedule = vec![(0u32, valid.clone())]; + let mut schedule = vec![(0u32, obj_rank(valid.clone()))]; if let Some(at) = switch_to_bogus_at { - schedule.push((at, bogus)); + schedule.push((at, obj_rank(bogus))); } DecisionTemplate { owner, @@ -2124,7 +2139,9 @@ fn b3_materialize_stop_short() { /// PR-7 DoS cap (CR 732.2a SAFETY LIMIT): a `Fixed` count over `MAX_SHORTCUT_CYCLES` is /// handed back to manual play with NO drive. This is the engine-side count cap that stops the /// catastrophic 4-byte remote vector — `Fixed(u32)` scalar-encodes ~4.3e9 cycles in ~10 bytes, -/// sailing through the 8 KB WS frame cap. The count is HARDCODED as `Fixed(u32::MAX)`; the cap +/// sailing through the WS frame cap (`phase-server`'s `MAX_WS_MESSAGE_BYTES`, 64 KB). The count +/// is HARDCODED as +/// `Fixed(u32::MAX)`; the cap /// const is private to the engine crate and invisible across this integration-test boundary. /// /// VACUITY TRAP (PR-7): a handback lands on `WaitingFor::Priority`, and so does the cap-ABSENT @@ -4297,6 +4314,7 @@ fn loop_shortcut_schema_redacts_hidden_targets_for_non_controller() { predicted_winner: Some(P0), certificate: cert, schema, + declaration: None, }; let targets_of = |wf: &WaitingFor| -> Vec { @@ -4341,6 +4359,714 @@ fn loop_shortcut_schema_redacts_hidden_targets_for_non_controller() { ); } +/// R0-a ⭐ (SECURITY): the RESPONDER-facing copy of a declared template is redacted too. +/// +/// `handle_declare_shortcut` moves the proposer's `DecisionTemplate` VERBATIM onto +/// `ShortcutProposal.template` one state transition after the `LoopShortcut` offer whose +/// `declaration` is redacted by `d5h_a_hidden_object_pin_drops_the_whole_declaration_for_a_non_proposer` +/// (`engine/src/game/visibility.rs`). Before this row, `grep -c RespondToShortcut +/// crates/engine/src/game/visibility.rs` was 0: the identical pin vector was public to every +/// responder and spectator. +/// +/// CR 732.2b, ALL-OR-NOTHING: the responder's right is to shorten "by naming a place where they +/// will make a game choice that's different than what's been proposed", so a half-shown pin set +/// would state a proposal that was never made. The hostile fixture pins ONE hidden hand card and +/// ONE public seat, and the assertion is `template.is_none()` — an implementation that merely +/// trimmed the hidden pin would hand back `Some` with a one-pin vector and fail here. +/// +/// # Non-vacuity +/// +/// The board is FOUR seats so that the pinned card's owner is neither the proposer nor a +/// responder, which separates three properties a 2-seat fixture conflates. Paired positives, +/// because a redactor that simply dropped every template would satisfy the negative for free: +/// (1) the PROPOSER (P0) keeps it — the guard is keyed to the viewer boundary; (2) the OWNER of +/// the pinned hand card (P3) keeps it even though they are not the proposer — so the drop is +/// keyed to what the viewer may actually see, not to "everyone but the proposer"; (3) an all-seat +/// template reaches the responder unchanged and byte-equal to the proposer's. The negative is +/// asserted for the QUEUED responder P2 as well as the current one P1, which is what makes the +/// guard's keying on `proposal.proposer` (not on the prompted `player`) observable. +/// +/// REVERT-PROBE: delete the `WaitingFor::RespondToShortcut` arm in `filter_state_for_viewer` ⇒ +/// P1/P2 see the hidden-hand pin ⇒ the two `is_none()` assertions FAIL while all positives stay +/// green. Flipping the shared `pins_name_hidden_source`'s inner `any` to `all` fails this row AND +/// the pre-existing `LoopShortcut` row D5-h — which is the proof that the extraction left ONE +/// authority behind rather than two copies. +/// +/// *What wrong implementation would still pass this row?* One that redacts `proposal.template` +/// but leaks the same identity through the `LoopShortcut` offer it came from — that surface is +/// covered by D5-h and by `loop_shortcut_schema_redacts_hidden_targets_for_non_controller` above. +#[test] +fn respond_to_shortcut_template_redacts_a_hidden_pin_for_non_proposers() { + const P3: PlayerId = PlayerId(3); + + let mut scenario = GameScenario::new_n_player(4, 7); + scenario.at_phase(Phase::PreCombatMain); + // The pinned card sits in a hand belonging to NEITHER the proposer (P0) nor either + // responder (P1 current, P2 queued), so "cannot see it" and "is not the proposer" are + // distinguishable properties of a viewer on this one board. + let hidden_hand = scenario.add_bolt_to_hand(P3); + let runner = scenario.build(); + + let source = YieldTarget::ThisObject { + source_id: ObjectId(999), + incarnation: None, + trigger_description: None, + }; + let window = |pins: Vec| -> GameState { + let mut state = runner.state().clone(); + state.waiting_for = WaitingFor::RespondToShortcut { + player: P1, + remaining_players: vec![P2], + proposal: ShortcutProposal { + proposer: P0, + predicted_winner: Some(P0), + count: IterationCount::Fixed(3), + unbounded: vec![], + win_kind: WinKind::LethalDamage, + template: Some(DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot: DecisionSlot { + source: source.clone(), + index: 0, + }, + targets: pins, + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(3), + }, + key: DecisionGroupKey::from_sources( + std::slice::from_ref(&source), + DecisionKind::LoopChoice, + ), + }), + per_cycle: None, + }, + }; + state + }; + let projected = |state: &GameState, viewer: PlayerId| -> Option { + match engine::game::visibility::filter_state_for_viewer(state, viewer).waiting_for { + WaitingFor::RespondToShortcut { proposal, .. } => proposal.template, + other => panic!("the fixture parks on the CR 732.2b response window, got {other:?}"), + } + }; + + // ── the hostile arm: one hidden hand card + one public seat in the SAME pin vector ── + let hidden_state = window(vec![ + TargetPin::ByIdentity(YieldTarget::ThisObject { + source_id: hidden_hand, + incarnation: None, + trigger_description: None, + }), + TargetPin::Player(P1), + ]); + assert!( + projected(&hidden_state, P0).is_some(), + "reach-guard + positive: the PROPOSER's own projection keeps the template, so the drops \ + below are keyed to the viewer boundary rather than to the fixture" + ); + assert!( + projected(&hidden_state, P1).is_none(), + "CR 732.2b: the CURRENT responder must not receive a proposal whose pin names a card in \ + a hand they cannot see — and all-or-nothing means the public seat pin goes with it" + ); + assert!( + projected(&hidden_state, P2).is_none(), + "the QUEUED responder is projected the same way: the guard keys on `proposal.proposer`, \ + not on the prompted `player`" + ); + assert!( + projected(&hidden_state, P3).is_some(), + "positive: the OWNER of the pinned hand card keeps the template even though they are not \ + the proposer — the drop is keyed to what this viewer may actually see, so a redactor \ + that dropped the template for every non-proposer fails here" + ); + + // ── the paired positive: an all-seat template carries no hidden identity ── + let public_state = window(vec![TargetPin::Player(P1)]); + assert_eq!( + projected(&public_state, P1), + projected(&public_state, P0), + "an all-seat template reaches the responder UNCHANGED — without this arm a redactor that \ + dropped every template would pass the negatives above" + ); + assert!( + projected(&public_state, P1).is_some(), + "and it is genuinely present, not two matching `None`s" + ); +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// R3-c — the `DecisionTemplate` carrier census over `WaitingFor` +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// HOW a `WaitingFor` variant reaches a `DecisionTemplate`: in its own body, or through a +/// field type that carries one. The intermediate type is NAMED rather than flattened to a +/// bool — "via which type" is a real axis (`RespondToShortcut` reaches the template through +/// `ShortcutProposal`) and a bool would erase it. +#[derive(Debug, Clone, PartialEq, Eq)] +enum CarrierKind { + Direct, + Via(String), +} + +use syn::{GenericArgument, Item, PathArguments, Type}; + +/// Every type identifier `ty` names, outermost first: `Option>` ⇒ +/// `["Option", "Vec", "Foo"]`. +/// +/// A NAME list rather than a `contains(marker)` over rendered text: the latter answers yes for +/// `DecisionTemplateAudit` and for the word inside a doc comment, and both would be carriers +/// this census invented. +fn type_names(ty: &Type) -> Vec { + fn walk(ty: &Type, out: &mut Vec) { + match ty { + Type::Path(p) => { + for seg in &p.path.segments { + out.push(seg.ident.to_string()); + if let PathArguments::AngleBracketed(args) = &seg.arguments { + for arg in &args.args { + if let GenericArgument::Type(inner) = arg { + walk(inner, out); + } + } + } + } + } + Type::Reference(r) => walk(&r.elem, out), + Type::Slice(s) => walk(&s.elem, out), + Type::Array(a) => walk(&a.elem, out), + Type::Group(g) => walk(&g.elem, out), + Type::Paren(p) => walk(&p.elem, out), + Type::Tuple(t) => t.elems.iter().for_each(|e| walk(e, out)), + _ => {} + } + } + let mut out = Vec::new(); + walk(ty, &mut out); + out +} + +/// Every item in `items`, INCLUDING the ones nested in inline `mod` blocks — a holder does not +/// stop being a holder for living inside a module. +fn flatten<'a>(items: &'a [Item], out: &mut Vec<&'a Item>) { + for item in items { + out.push(item); + if let Item::Mod(m) = item { + if let Some((_, inner)) = &m.content { + flatten(inner, out); + } + } + } +} + +/// `(variant name, its field types)` for `enum_name`, in declaration order. +fn enum_variants(src: &str, enum_name: &str) -> Vec<(String, Vec)> { + let parsed = + syn::parse_file(src).unwrap_or_else(|e| panic!("parse the `{enum_name}` source: {e}")); + let mut items = Vec::new(); + flatten(&parsed.items, &mut items); + items + .iter() + .find_map(|item| match item { + Item::Enum(e) if e.ident == enum_name => Some( + e.variants + .iter() + .map(|v| { + ( + v.ident.to_string(), + v.fields.iter().map(|f| f.ty.clone()).collect(), + ) + }) + .collect(), + ), + _ => None, + }) + .unwrap_or_else(|| panic!("`{enum_name}` must be declared in the parsed source")) +} + +/// Every type declaration in the walked corpus whose own body names `marker` — struct, enum or +/// alias, at any visibility, generic or not, nested in a `mod` or not. THE DEPTH-1 STEP, and +/// the whole of it — see the row's disclosed limitation. +/// +/// # Why `syn` and not string ops +/// +/// This step used to scan for `pub struct {` at column 0, which is a small fraction of +/// the declarations that can hold a `marker`. MEASURED over this walk root: that shape matches +/// **491** declarations, while **654** `pub enum`, **108** `pub(crate) struct` and **12** +/// generic `pub struct` heads are invisible to it — and a carrier the depth-1 step cannot see +/// is scored as a clean GREEN, which is the one failure mode a census must not have. `syn` is +/// already a `[dev-dependencies]` entry of this crate and already the instrument +/// `deterministic_game_state_serde` parses production sources with, so this is reuse and not a +/// new dependency. `PLANT 5` below is the arm that holds the four recovered forms. +/// +/// Computed ONCE per corpus rather than re-scanned per candidate identifier: the walk is 500+ +/// files and the enum is 128 variants, so the per-identifier form is quadratic in the corpus +/// for no extra signal. +fn types_carrying(corpus: &[(String, String)], marker: &str) -> std::collections::BTreeSet { + let mut out = std::collections::BTreeSet::new(); + for (file, src) in corpus { + // A SOUND prefilter, not a shortcut: a declaration can only NAME `marker` if its file + // text contains `marker`, so this skips ~500 parses without narrowing the answer. + if !src.contains(marker) { + continue; + } + let parsed = syn::parse_file(src).unwrap_or_else(|e| panic!("parse {file}: {e}")); + let mut items = Vec::new(); + flatten(&parsed.items, &mut items); + for item in items { + let (name, field_types): (String, Vec<&Type>) = match item { + Item::Struct(s) => ( + s.ident.to_string(), + s.fields.iter().map(|f| &f.ty).collect(), + ), + Item::Enum(e) => ( + e.ident.to_string(), + e.variants + .iter() + .flat_map(|v| v.fields.iter().map(|f| &f.ty)) + .collect(), + ), + Item::Type(t) => (t.ident.to_string(), vec![t.ty.as_ref()]), + _ => continue, + }; + if field_types + .iter() + .flat_map(|t| type_names(t)) + .any(|n| n == marker) + { + out.insert(name); + } + } + } + out +} + +/// Every variant of `enum_name` in `enum_src` that reaches `marker`, DIRECTLY or through one +/// field type found in `corpus`. +/// +/// SOURCE-PARAMETERIZED on purpose: every probe below plants into an in-memory `String`, so the +/// census's own discrimination costs no compile and mutates no worktree file. Mirrors +/// `super::loop_shortcut_offer_writer_census::classify`'s `(src, needle, file)` shape. +fn carriers_in_source( + enum_src: &str, + enum_name: &str, + corpus: &[(String, String)], + marker: &str, + walk_via: bool, +) -> Vec<(String, CarrierKind)> { + let via_types = types_carrying(corpus, marker); + let mut out = Vec::new(); + for (name, field_types) in enum_variants(enum_src, enum_name) { + // Outermost-first across the variant's fields in declaration order, so `Via` names the + // holder a reader would name. + let named: Vec = field_types.iter().flat_map(type_names).collect(); + if named.iter().any(|n| n == marker) { + out.push((name, CarrierKind::Direct)); + continue; + } + if !walk_via { + continue; + } + if let Some(via) = named.into_iter().find(|n| via_types.contains(n)) { + out.push((name, CarrierKind::Via(via))); + } + } + out +} + +/// Does `filter_state_for_viewer`'s body carry an `if let WaitingFor::` dispatch arm? +fn redaction_arm_present(visibility_src: &str, name: &str) -> bool { + let needle = format!("if let {}::{name}", "WaitingFor"); + let mut inside = false; + for line in visibility_src.lines() { + if !inside { + inside = line.starts_with("pub fn filter_state_for_viewer("); + continue; + } + if line == "}" { + break; + } + if line.contains(&needle) { + return true; + } + } + false +} + +/// Splice `injected` in immediately above `enum_name`'s closing brace, on a COPY of `src`. +fn plant_into_enum(src: &str, enum_name: &str, injected: &str) -> String { + let open = format!("pub enum {enum_name} {{"); + let mut out = String::new(); + let mut inside = false; + let mut planted = false; + for line in src.lines() { + if !planted { + if inside && line == "}" { + out.push_str(injected); + planted = true; + } else if !inside && line.trim_start().starts_with(&open) { + inside = true; + } + } + out.push_str(line); + out.push('\n'); + } + assert!( + planted, + "the plant anchor `{open}` … `}}` must exist in the copy" + ); + out +} + +/// **R3-c** — exactly TWO `WaitingFor` variants carry a `DecisionTemplate`, and both have a +/// redaction arm inside `filter_state_for_viewer`. +/// +/// MEASURED at this tip: `{(LoopShortcut, Direct), (RespondToShortcut, Via(ShortcutProposal))}` +/// out of 128 variants; the VIA target is `analysis::loop_check::ShortcutProposal`'s +/// `pub template: Option`. +/// +/// # Why a census exists here at all +/// +/// The REDACTION DISPATCH in `filter_state_for_viewer` is two `if let`s, not a `match`, so a +/// THIRD carrier gets no compile error THERE — that is the gap this row closes. It is not true +/// elsewhere: **at least 9** exhaustive `match`es on `WaitingFor` would fail E0004, spread over +/// two crates (`engine`, `phase-ai`) — measured by whole-workspace AST enumeration over +/// `crates/`, which is a **lower bound**, not a total. Those matches make a new variant hard to +/// ADD; not one of them makes it hard to add UNREDACTED. **The site list is deliberately not +/// enumerated here** — a frozen list in a doc comment is a claim no test defends, and it rots +/// the moment a crate is added. +/// +/// # Why the probes plant into a `String` instead of adding a variant +/// +/// MEASURED: a real third variant yields 6 E0004 under `cargo check -p phase-engine --lib` and +/// 7 with `--features test-support`, and BOTH runs abort at the lib — so the dependent crates +/// and this ~4 800-row integration target are never type-checked, and the repair list is +/// neither stable nor bounded. Source injection has neither problem and needs no build. +/// +/// # Discrimination — five plant arms, all RUN, all over in-memory copies +/// +/// * a 3rd DIRECT and a 4th VIA carrier planted into a copy of the enum source ⇒ the set +/// assertion fails NAMING both new variants (`n = 4`); +/// * the depth-1 VIA step disabled ⇒ the set shrinks to `{LoopShortcut}` ⇒ fails, so the +/// transitive step is load-bearing rather than decoration; +/// * a synthetic enum with no carrier at all ⇒ `n = 0`, so the classifier cannot only ever +/// return the answer this row wants; +/// * the `RespondToShortcut` arm deleted from a copy of `visibility.rs` ⇒ the redaction half +/// flips to `false` for that carrier while `LoopShortcut` stays `true`. The mutation is on a +/// COPY, so the shipped row `respond_to_shortcut_template_redacts_a_hidden_pin_for_non_proposers` +/// above is not perturbed; +/// * a `pub(crate)`, a GENERIC, an ENUM, an ALIAS and a `mod`-NESTED holder planted at once ⇒ +/// all five are NAMED as `Via` carriers, and a non-carrier planted beside them is not. These +/// are the forms the retired `pub struct {{` string scan could not see, and each was +/// a false GREEN rather than a false alarm. +/// +/// # DISCLOSED LIMITATIONS +/// +/// 1. **The walk is DEPTH-1.** A `WaitingFor` field whose type reaches a `DecisionTemplate` two +/// levels down is invisible to it. Measured today: zero such types exist. That is a latent +/// gap, not a covered case. +/// 2. **The E0004 figure above is a LOWER BOUND from a named instrument**, not a total, and this +/// row must never be "helpfully" upgraded into a site list. +#[test] +fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redacted() { + use super::loop_shortcut_offer_writer_census::rs_files; + + // Assembled at runtime for the same reason both sibling censuses assemble their anchors: + // an instrument that can count its own needle after a future move is one that lies about + // the surface it measures. + let marker = format!("{}{}", "Decision", "Template"); + let engine_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let corpus: Vec<(String, String)> = rs_files(&engine_src) + .into_iter() + .map(|path| { + let src = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + let rel = path + .strip_prefix(&engine_src) + .expect("walked path is under its root") + .to_string_lossy() + .replace('\\', "/"); + (rel, src) + }) + .collect(); + let file_of = |suffix: &str| -> String { + corpus + .iter() + .find(|(f, _)| f.ends_with(suffix)) + .unwrap_or_else(|| panic!("the walk must reach {suffix}")) + .1 + .clone() + }; + let enum_src = file_of("types/game_state.rs"); + let visibility_src = file_of("game/visibility.rs"); + + // ── the classifier's own reach-guard: the enum was actually found ── + let total = enum_variants(&enum_src, "WaitingFor").len(); + assert_eq!( + total, 130, + "`WaitingFor` has 130 variants at this tip, read off the `syn` parse. This number is \ + pinned so a variant REMOVED is as visible as one added; if you added a variant and it \ + carries no `DecisionTemplate`, update this number. A wildly different count means the \ + reader lost its anchor, and every assertion below would then be measuring an empty enum" + ); + // 128 ⇒ 129 is ADJUDICATED, not bumped: upstream #7336 ("make dig entries attack") added + // `EntryAttackTargetChoice { player, object_id, valid_targets }`. Measured, because the + // number alone cannot say it: that variant carries NO `DecisionTemplate` (zero matches in + // its body), so it is not a third carrier and the assertion below is unchanged by it. The + // count moved for a reason that does not touch this row's subject — which is exactly the + // case this reach-guard exists to make visible rather than silent. + // 129 ⇒ 130 is ADJUDICATED on the same terms: upstream #7382 ("choose pre-entry opponent + // controller") added `EntryControllerChoice { player: PlayerId, candidates: Vec }` + // (CR 614.12a). Measured, not inferred from the diff: that body holds NO `DecisionTemplate`, + // so it is not a third carrier, and both the carrier vec and the redaction loop below are + // unchanged by it. The count itself was read from THIS assertion's own failure (`left: 130`) + // rather than from a hand-written variant counter — one was tried and returned 49 while + // contradicting itself, and a second instrument that disagrees with the `syn` parse is worth + // less than no second instrument. The reach-guard did its whole job here: this drift produced + // no merge conflict and could not have, so CI was the only thing between it and shipping. + + let carriers = carriers_in_source(&enum_src, "WaitingFor", &corpus, &marker, true); + assert_eq!( + carriers, + vec![ + ("LoopShortcut".to_string(), CarrierKind::Direct), + ( + "RespondToShortcut".to_string(), + CarrierKind::Via("ShortcutProposal".to_string()) + ), + ], + "CR 732.2a / CR 723.4: exactly TWO `WaitingFor` variants carry a `DecisionTemplate` — \ + `LoopShortcut` directly and `RespondToShortcut` through `ShortcutProposal` — and both \ + are named rather than counted, because a census asserting `>= 1 carrier` is vacuous. A \ + THIRD carrier is a new per-viewer redaction obligation: the dispatch in \ + `filter_state_for_viewer` is `if let`s, so nothing will fail to compile. got {carriers:?}" + ); + + // ── the redaction half, read from the production source ── + for (name, kind) in &carriers { + assert!( + redaction_arm_present(&visibility_src, name), + "CR 723.4: every `DecisionTemplate` carrier needs its own dispatch arm inside \ + `filter_state_for_viewer`; `{name}` ({kind:?}) has none" + ); + } + + // ── PLANT 1 — a 3rd DIRECT and a 4th VIA carrier, into COPIES ── + let planted_src = plant_into_enum( + &enum_src, + "WaitingFor", + &format!( + " ProbeThirdCarrier {{\n template: Option<{marker}>,\n }},\n\ + \x20 ProbeFourthCarrier {{\n holder: ProbeViaHolder,\n }},\n" + ), + ); + let mut planted_corpus = corpus.clone(); + planted_corpus.push(( + "planted.rs".to_string(), + format!("pub struct ProbeViaHolder {{\n pub template: Option<{marker}>,\n}}\n"), + )); + let planted = carriers_in_source(&planted_src, "WaitingFor", &planted_corpus, &marker, true); + assert_eq!( + planted + .iter() + .map(|(n, k)| (n.as_str(), k.clone())) + .collect::>(), + vec![ + ("LoopShortcut", CarrierKind::Direct), + ( + "RespondToShortcut", + CarrierKind::Via("ShortcutProposal".to_string()) + ), + ("ProbeThirdCarrier", CarrierKind::Direct), + ( + "ProbeFourthCarrier", + CarrierKind::Via("ProbeViaHolder".to_string()) + ), + ], + "ANTI-VACUITY: the census must NAME a planted third (direct) and fourth (transitive) \ + carrier, else the two-carrier answer above is what a dead instrument returns. \ + got {planted:?}" + ); + + // ── PLANT 2 — the depth-1 VIA step disabled ── + let no_via = carriers_in_source(&enum_src, "WaitingFor", &corpus, &marker, false); + assert_eq!( + no_via, + vec![("LoopShortcut".to_string(), CarrierKind::Direct)], + "the transitive step is LOAD-BEARING: without it `RespondToShortcut` is invisible and \ + the shipped set assertion above would be a one-element claim. got {no_via:?}" + ); + + // ── PLANT 3 — a synthetic enum with no carrier ── + let synthetic = "pub enum WaitingFor {\n Alpha {\n player: PlayerId,\n },\n \ + Beta {\n x: u32,\n },\n}\n"; + let none = carriers_in_source(synthetic, "WaitingFor", &corpus, &marker, true); + assert!( + none.is_empty(), + "a carrier-free enum must classify as carrier-free; an instrument that can only ever \ + return {{LoopShortcut, RespondToShortcut}} is what this arm forecloses. got {none:?}" + ); + + // ── PLANT 4 — the redaction arm deleted, on a COPY of `visibility.rs` ── + let mutated_visibility = visibility_src.replace( + &format!("if let {}::RespondToShortcut", "WaitingFor"), + &format!("if let {}::ZzzDeletedArm", "WaitingFor"), + ); + assert!( + redaction_arm_present(&mutated_visibility, "LoopShortcut") + && !redaction_arm_present(&mutated_visibility, "RespondToShortcut"), + "the redaction half must FLIP when its arm is deleted from the copy, and only for the \ + carrier whose arm was deleted — otherwise the `for` loop above asserts nothing" + ); + + // ── PLANT 5 — the four holder FORMS the retired string scan could not see, plus a + // non-carrier control. Each is a real declaration shape from this walk root: over + // `crates/engine/src` the old `pub struct {` shape matched 491 declarations + // while 654 `pub enum`, 108 `pub(crate) struct` and 12 generic `pub struct` heads were + // invisible — every one of them a carrier that would have scored a clean GREEN. ── + let forms = format!( + "pub(crate) struct CrateVisHolder {{\n pub template: Option<{marker}>,\n}}\n\ + pub struct GenericHolder<'a, T> {{\n pub template: &'a {marker},\n pub t: T,\n}}\n\ + pub enum EnumHolder {{\n WithTemplate({marker}),\n Without,\n}}\n\ + pub type AliasHolder = Option<{marker}>;\n\ + pub struct NotAHolder {{\n pub seat: PlayerId,\n}}\n\ + mod inner {{\n pub struct NestedHolder {{\n pub template: \ + Option,\n }}\n}}\n" + ); + let mut forms_corpus = corpus.clone(); + forms_corpus.push(("forms.rs".to_string(), forms)); + let forms_src = plant_into_enum( + &enum_src, + "WaitingFor", + " ProbeCrateVis {\n h: CrateVisHolder,\n },\n\ + \x20 ProbeGeneric {\n h: GenericHolder<'static, u8>,\n },\n\ + \x20 ProbeEnumHolder {\n h: EnumHolder,\n },\n\ + \x20 ProbeAlias {\n h: AliasHolder,\n },\n\ + \x20 ProbeNested {\n h: NestedHolder,\n },\n\ + \x20 ProbeNonCarrier {\n h: NotAHolder,\n },\n", + ); + let by_forms: Vec<(String, CarrierKind)> = + carriers_in_source(&forms_src, "WaitingFor", &forms_corpus, &marker, true) + .into_iter() + .filter(|(n, _)| n.starts_with("Probe")) + .collect(); + assert_eq!( + by_forms, + vec![ + ( + "ProbeCrateVis".to_string(), + CarrierKind::Via("CrateVisHolder".to_string()) + ), + ( + "ProbeGeneric".to_string(), + CarrierKind::Via("GenericHolder".to_string()) + ), + ( + "ProbeEnumHolder".to_string(), + CarrierKind::Via("EnumHolder".to_string()) + ), + ( + "ProbeAlias".to_string(), + CarrierKind::Via("AliasHolder".to_string()) + ), + ( + "ProbeNested".to_string(), + CarrierKind::Via("NestedHolder".to_string()) + ), + ], + "the depth-1 step must see a `pub(crate)`, a GENERIC, an ENUM, an ALIAS and a \ + `mod`-NESTED holder alike — and `ProbeNonCarrier` must be ABSENT, because an \ + instrument that reports every variant is as useless as one that reports too few. \ + got {by_forms:?}" + ); +} + +/// F4 (review finding): the THIRD carrier of the same `Vec` — +/// `GameState::last_loop_action_sequence[].pins` — routes through the same authority. +/// +/// It is serialized whenever non-empty (`skip_serializing_if = "Vec::is_empty"`, not `skip`) and +/// had zero hits in `visibility.rs` before this change. Its three production writers (the +/// `record_loop_pin` call sites: a mana-ability tap cost, a mana-color choice, a proliferate +/// target) can only name battlefield permanents and seats, so no board the engine mints today +/// reaches the redaction — this row constructs the pin a fourth writer would produce, which is +/// the only way to hold the seam closed before that writer exists. +/// +/// # Non-vacuity +/// +/// The owner arm (P1 sees their own hand card) is the paired positive: a sweep that cleared every +/// recorded pin would satisfy the negative and fail it. The step itself is asserted to survive in +/// both arms, so "the whole sequence was dropped" cannot masquerade as a pass. +/// +/// REVERT-PROBE: delete the `for step in &mut filtered.last_loop_action_sequence` sweep ⇒ P2 keeps +/// the hidden-hand pin ⇒ the `is_empty()` assertion FAILS while both positives stay green. +/// +/// *What wrong implementation would still pass this row?* One that clears `pins` unconditionally +/// for every non-owner — the owner arm is what rejects it. +#[test] +fn recorded_loop_pins_are_redacted_for_a_viewer_who_cannot_see_the_pinned_object() { + use engine::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + + let mut scenario = GameScenario::new_n_player(3, 7); + scenario.at_phase(Phase::PreCombatMain); + let hidden_hand = scenario.add_bolt_to_hand(P1); // a hidden card in P1's hand + let runner = scenario.build(); + + let mut state = runner.state().clone(); + let card_id = state.objects[&hidden_hand].card_id; + state.last_loop_action_sequence = vec![LoopActionContext { + card_id, + controller: P1, + action: LoopAction::Recast { + from_zone: engine::types::zones::Zone::Hand, + uses_buyback: BuybackUsage::NotUsed, + }, + convoke: None, + pins: vec![PinnedDecision::Targets { + slot: DecisionSlot { + source: YieldTarget::ThisObject { + source_id: ObjectId(999), + incarnation: None, + trigger_description: None, + }, + index: 0, + }, + targets: vec![ + TargetPin::ByIdentity(YieldTarget::ThisObject { + source_id: hidden_hand, + incarnation: None, + trigger_description: None, + }), + TargetPin::Player(P1), + ], + }], + }]; + + let pins_for = |viewer: PlayerId| -> Vec { + let seq = engine::game::visibility::filter_state_for_viewer(&state, viewer) + .last_loop_action_sequence; + assert_eq!( + seq.len(), + 1, + "the recorded step itself is never dropped — only its pin vector is redacted" + ); + seq[0].pins.clone() + }; + + assert_eq!( + pins_for(P1).len(), + 1, + "positive: the hand's OWNER keeps the recorded pin — `target_hidden` answers false for a \ + card this viewer may privately see" + ); + assert!( + pins_for(P2).is_empty(), + "a viewer who cannot see P1's hand receives no pin naming that card — all-or-nothing, so \ + the public seat pin in the same vector goes with it" + ); +} + /// T6 (serde): the schema rides the `WaitingFor::LoopShortcut` serialization as `data.schema` /// (tag/content) and round-trips equal — the FE contract that lets the frontend read the offer's /// decision schema off the wire without any engine-side special casing. @@ -5738,6 +6464,215 @@ fn a_wire_zero_shortcut_bound_fails_the_load_and_a_wire_five_does_not() { ); } +/// R0e — the wire pair NO PRODUCER MINTS: a persisted `LoopShortcut` offer that NARROWS its +/// repetition bound (`schema.is_bounded()`) while recording the PROPOSER'S OWN driving period +/// (`loop_period_controller() == Some(proposer)`) must fail the load. +/// +/// The engine's three mints partition that cross-product and none lands in this cell: the +/// object-growth and Path A drain mints both publish `MAX_SHORTCUT_CYCLES` (never +/// `is_bounded()`), and the bounded mint's gate (1b) refuses `ProposerHasDrivingPeriod`. Accepting +/// the pair anyway routes the accepted proposal through `materialize_fixed_shortcut`'s +/// period-ownership early return into `materialize_object_growth_shortcut` — the table agreed to +/// `n` cycles and gets NONE. +/// +/// ⚠ NOT A CR REFUSAL, and the row asserts on the engine-invariant message accordingly. CR 732.2a's +/// Example is a proposer repeating THEIR OWN activation a specified 999,999 more times, so this +/// state class is legal at the table; what it violates is producer reachability in this engine. +/// +/// THE PERIOD IS A PRODUCTION-SERIALIZED VALUE lifted whole out of the real object-growth capture, +/// never hand-authored JSON — the discipline the `frames_per_period` row above states. +/// +/// MATCHED REVERT-PROBE TABLE — each conjunct has its own failing arm, and the three +/// single-conjunct reverts produce three DISTINCT failing sets: +/// +/// | mutation to `reject_zero_bound_shortcut_offer` | flips | stays green | +/// |---|---|---| +/// | delete the whole own-period `if` block | A1 → `Ok` | A2, A3, A4, A5, A6 | +/// | delete `schema.is_bounded() &&` | A3, A5 → `Err` | A1, A2, A4, A6 | +/// | delete `&& loop_period_controller() == …` | A2, A4 → `Err` | A1, A3, A5, A6 | +/// | hoist the block ABOVE the `max_iterations == 0` block | A6's message | A1–A5 | +#[test] +fn a_wire_bounded_offer_carrying_the_proposers_own_period_fails_the_load() { + let json = gunzip_dump(include_bytes!( + "../fixtures/tenacity_exquisite_blood_4p.json.gz" + )); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("dump envelope parses as JSON"); + let base = envelope["gameState"].clone(); + + // ── REACH-GUARDS ON THE BASE: both splices below must CREATE their key ────────────── + assert_eq!( + base["waiting_for"]["type"].as_str(), + Some("LoopShortcut"), + "the invariant is scoped to the one variant that carries a schema AND a proposer" + ); + assert!( + base["waiting_for"]["data"]["schema"].is_object(), + "the tenacity offer carries a schema object for the bound to live on" + ); + assert!( + base["waiting_for"]["data"]["schema"] + .get("max_iterations") + .is_none(), + "the fixture predates the field, so the bound splice CREATES the key (absent ⇒ \ + MAX_SHORTCUT_CYCLES ⇒ NOT is_bounded, which is what arms A3/A5 rest on)" + ); + assert!( + base.get("last_loop_action_sequence").is_none(), + "the fixture records no driving period, so the period splice CREATES the key" + ); + let proposer = base["waiting_for"]["data"]["proposer"].clone(); + + let donor_json = gunzip_dump(include_bytes!( + "../fixtures/combo_infinite_pile_4p_offer.json.gz" + )); + // The combo capture is BARE (no `gameState` envelope) — it is the other decode ingress, and + // A5 rides it as itself below. + let donor_state: serde_json::Value = + serde_json::from_str(&donor_json).expect("the combo dump parses as JSON"); + let donor_period = donor_state["last_loop_action_sequence"].clone(); + let donor_steps = donor_period + .as_array() + .expect("the real object-growth capture records a driving period to donate"); + assert!( + !donor_steps.is_empty(), + "an empty donated sequence would make loop_period_controller() None and every arm vacuous" + ); + assert!( + donor_steps + .iter() + .all(|step| step["controller"] == proposer), + "the donated period must belong to the SAME seat as the tenacity proposer, or A1 would \ + be testing a FOREIGN period — which is A4's job, not A1's" + ); + + let spliced = |bound: Option, period: Option<&serde_json::Value>| { + let mut v = base.clone(); + if let Some(n) = bound { + v["waiting_for"]["data"]["schema"]["max_iterations"] = serde_json::json!(n); + assert_eq!( + v["waiting_for"]["data"]["schema"]["max_iterations"].as_u64(), + Some(n), + "the bound splice must reach schema.max_iterations" + ); + } + if let Some(seq) = period { + v["last_loop_action_sequence"] = seq.clone(); + assert_eq!( + &v["last_loop_action_sequence"], seq, + "the period splice must reach last_loop_action_sequence" + ); + } + v + }; + let decode_persisted = |value: serde_json::Value| { + serde_json::from_value::(value) + }; + + // ── A1 — THE GUARD FIRES. Also the reach-guard for A2/A3/A4: the predicate reads the period + // from the state AS DECODED FROM THE WIRE, so an `Err` here is proof the splice landed and + // survived `decode_persisted_resolution_state`. Were it dropped, this would be `Ok` and the + // three `Ok` arms below would mean nothing. + let message = decode_persisted(spliced(Some(5), Some(&donor_period))) + .expect_err("a narrowed bound carrying the proposer's own period must fail the load") + .to_string(); + assert!( + message.contains("narrows its repetition bound"), + "the rejection must NAME the invariant it enforces and must not be either sibling zero \ + guard firing instead, got: {message}" + ); + + // ── A6 — ORDERING PROBE. `0 < MAX_SHORTCUT_CYCLES`, so a zero bound is ALSO `is_bounded()`: + // the two blocks are not disjoint and the zero check must keep answering first. No pre-existing + // row observes this — the sibling zero row's fixture carries no period, so the new predicate is + // false there regardless of order. + let message = decode_persisted(spliced(Some(0), Some(&donor_period))) + .expect_err("a zero bound must still fail the load when a period rides with it") + .to_string(); + assert!( + message.contains("max_iterations 0"), + "ORDERING: hoisting the own-period block above the zero-bound block relabels a corrupt \ + zero with the wrong invariant, got: {message}" + ); + + // ── A2 — THE PERIOD CONJUNCT. A narrowed bound ALONE is the ordinary bounded offer. + assert!( + decode_persisted(spliced(Some(5), None)).is_ok(), + "a narrowed bound with NO recorded period is exactly what the bounded mint publishes" + ); + + // ── A3 — THE `is_bounded()` CONJUNCT. Own period ALONE is the object-growth route's own + // admission condition; rejecting it would refuse every legitimate growth capture. + assert!( + decode_persisted(spliced(None, Some(&donor_period))).is_ok(), + "an UNNARROWED offer (absent bound ⇒ MAX_SHORTCUT_CYCLES) carrying the proposer's own \ + period is the legitimate object-growth shape and must still load" + ); + + // ── A4 — SEAT-RELATIVITY. It must be THIS proposer's period, not merely A period. + let foreign_period = { + let mut seq = donor_period.clone(); + for step in seq + .as_array_mut() + .expect("the donated period is an array of steps") + { + step["controller"] = serde_json::json!(1); + } + assert!( + seq.as_array() + .expect("still an array") + .iter() + .all(|step| step["controller"] != proposer), + "the controller rewrite must reach every step, or A4 would re-run A1" + ); + seq + }; + assert!( + decode_persisted(spliced(Some(5), Some(&foreign_period))).is_ok(), + "a period recorded from a DIFFERENT seat describes no sequence this proposer can take \ + (SITE B's seat-relative form), so it must not reject the offer" + ); + + // ── A5 — THE REAL OBJECT-GROWTH CAPTURE, UNMUTATED, ON THE OTHER GUARDED INGRESS ────── + // `reject_zero_bound_shortcut_offer` is called from BOTH decoders; A1-A4 ride + // `decode_persisted_resolution_state`, this one rides `GameStateDecode::decode` through + // `impl Deserialize for GameState`. + let combo: GameState = serde_json::from_str(&donor_json) + .expect("the real object-growth capture must still load through the bare ingress"); + // REACH-GUARD, INLINE — A1 cannot stand in for it (different fixture, different ingress). + // Without these three, `Ok` would also be explained by the period never surviving THIS + // decode, and the `is_bounded()` revert (delete it ⇒ this arm must flip to `Err`) would not + // fire. + let WaitingFor::LoopShortcut { + proposer: combo_proposer, + schema, + .. + } = &combo.waiting_for + else { + panic!( + "fixture precondition: the combo capture is AT a LoopShortcut offer, got {:?}", + combo.waiting_for + ) + }; + assert!( + !combo.last_loop_action_sequence.is_empty(), + "the period must SURVIVE this decode, or A5's Ok is unattributable" + ); + assert!( + combo + .last_loop_action_sequence + .iter() + .all(|step| step.controller == *combo_proposer), + "the surviving period must be homogeneous on the PROPOSER's seat — that is what makes \ + loop_period_controller() == Some(proposer) and puts this arm on the guard's own predicate" + ); + assert!( + !schema.is_bounded(), + "and the offer must be UNNARROWED, so A5's Ok is attributable to the is_bounded() \ + conjunct alone rather than to a missing period" + ); +} + /// Opponents the ENGINE considers living. `Player::is_eliminated` is the authority the /// CR 732.2a detector uses when it builds its `living` set — `eliminated_players` and /// `life > 0` are not sufficient on their own, so this reads the field the detector reads. @@ -5783,6 +6718,19 @@ fn dump_beat_actor(state: &GameState) -> Option<(PlayerId, Vec)> { /// Returns the beat's `GameEvent`s so a caller can key on what the beat actually DID /// (`CombatDamageDealtToPlayer` / `DamageDealt` for the CR 510.2 rows) instead of /// inferring it from phase and life deltas. Callers that only need liveness ignore it. +/// +/// ⚠ THE `pin` PREFERENCE IS INERT ON EVERY TRACKED DUMP, and that is MEASURED, not +/// inferred from this function's body. Driving all five tracked 4p dumps for 60 beats each: +/// only `dellian_emblem_conqueror_4p` reaches a `WaitingFor::TriggerTargetSelection` window +/// at all (seven of them, at beats 0/9/18/27/36/45/54, all on `ObjectId(541)`), and every +/// one of those windows enumerates **`GameAction::ChooseTarget` ×3 and +/// `GameAction::SelectTargets` ×0** — so the `SelectTargets` preference above never fires +/// and the fallback answers with a `ChooseTarget`. `dina`, `tenacity`, +/// `witherbloom_sprout_lumaret` and `witherbloom_sprout_lumaret_simple` reach NO +/// `TriggerTargetSelection` window in 60 beats. This is the same trap +/// `fantastic_four_bounded_loop.rs` already records for the F4 dump, and it means NO TRACKED +/// FIXTURE CAN EXERCISE THE `SelectTargets` REDUCER ARM at the wire tier — see +/// [`c2a_row_t1b_both_trigger_target_selection_arms_route_through_the_single_writer`]. fn dump_drive_one_beat( state: &mut GameState, pin: Option, @@ -6291,6 +7239,7 @@ fn template_none_against_a_pin_consuming_schema_falls_back_to_manual_play() { predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema: schema.clone(), + declaration: None, }; runner .act(GameAction::DeclareShortcut { @@ -8134,6 +9083,7 @@ fn bounded_offer_parts( predicted_winner: None, certificate, schema, + declaration: _, } => (*proposer, certificate, schema), other => panic!("expected a bounded LoopShortcut offer, got {other:?}"), } @@ -8374,6 +9324,92 @@ fn dina_untargeted_drain_4p_offers_at_three_live_opponents() { must not be written by raising a bounded offer; got {:?}", state.unbounded_resources ); + + // ── F6: THE PREVIEW REACHES A REAL GAME ── + // + // Every other row that asserts anything about the CR 732.2a count preview hand-builds a + // `WaitingFor` and projects it. That leaves one hole none of them can see: the preview is + // published only when the offer pairs `per_cycle: Some` with a FINITE count, and the + // producer that pairs them is the bounded one. Route the bound through + // `shortcut_iteration_count` — which returns `UntilLethal` for `LethalDamage | PoisonLoss`, + // and this fixture's `win_kind` is exactly `LethalDamage` — and the preview vanishes from + // EVERY real game while all three hand-built rows stay green. This row closes that by + // reading the preview off the real 4p dump the engine itself raised the offer on. + // + // WHAT WRONG IMPLEMENTATION WOULD STILL PASS THIS ROW? One that publishes the preview only + // on this fixture's exact per-period shape (the hand-built rows cover the shape space), and + // one that previews the right numbers for a count the player did not pick — the reserved + // per-selected-count question, deliberately not answered here. + // + // REVERT-PROBE, RUN: mint this offer's count through `shortcut_iteration_count` (i.e. + // `UntilLethal` for a lethal drain) ⇒ `preview` is `None` ⇒ the expect below FAILS. + let suggested = i64::from(schema.max_iterations); + let life_deltas: Vec<(PlayerId, i64)> = per_cycle + .delta + .life + .iter() + .filter(|(_, delta)| **delta != 0) + .map(|(seat, delta)| (*seat, *delta)) + .collect(); + assert!( + life_deltas.len() >= 2, + "reach-guard: the preview's per-seat fold only discriminates when the period moves \ + MORE THAN ONE seat's life — a single-seat period is satisfiable by an implementation \ + that keys every entry to the proposer; measured {life_deltas:?}" + ); + + engine::game::interaction::bind_interaction_authority( + &mut state, + engine::types::interaction::InteractionSessionId("dina-preview".to_string()), + ) + .expect("the offer beat binds an interaction authority"); + let filtered = engine::game::visibility::filter_state_for_viewer(&state, proposer); + let view = engine::game::interaction::derive_viewer_interaction(&state, &filtered, proposer); + let engine::types::interaction::InteractionOpportunityResponse::Schema { + spec: engine::types::interaction::InteractionResponseSpec::Shortcut { preview, .. }, + .. + } = &view.opportunities[0].response + else { + panic!("the bounded offer publishes a shortcut schema to its proposer"); + }; + let preview = preview.as_ref().expect( + "CR 732.2a: the offer the engine raised on a REAL 4p drain must publish what its \ + declared count does. A `None` here means every preview has vanished from every real \ + game while the hand-built projection rows stayed green.", + ); + assert_eq!( + i64::from(preview.count), + suggested, + "the magnitudes are stated for the offer's own suggested count and no other" + ); + + let mut expected: Vec<(Option, i32)> = life_deltas + .iter() + .map(|(seat, delta)| (Some(seat.0), (delta * suggested) as i32)) + .collect(); + let mut published: Vec<(Option, i32)> = preview + .entries + .iter() + .filter(|entry| { + entry.family == engine::types::interaction::InteractionShortcutPreviewFamily::Life + }) + .map(|entry| (entry.player, entry.amount)) + .collect(); + expected.sort_unstable(); + published.sort_unstable(); + assert_eq!( + published, expected, + "CR 119.3: every seat the certified period moves life on is previewed at that seat, \ + multiplied out by the declared count — recomputed here from the offer-beat certificate" + ); + for (seat, _, loss) in losses.iter().filter(|(id, _, _)| *id != proposer) { + assert!( + published.contains(&(Some(seat.0), (-loss * suggested) as i32)), + "CR 704.5a: victim seat {seat:?} loses {loss} per cycle, so its previewed life \ + entry must be the NEGATIVE finished magnitude on that seat's own key — a \ + proposer-keyed subject map publishes it on the wrong HUD; got {published:?}" + ); + } } /// The dina 4p drain DRIVEN through `apply()` to the beat the engine itself raises the @@ -10794,6 +11830,34 @@ fn bounded_fixed_drive_rolls_back_a_partial_crossing_cycle() { crossing eliminates at most one of three, so there is no winner to crown and the \ aborted drive hands back ordinary priority rather than ending the game" ); + + // (d) R3-a's ABORT ARM — the drive-end seam is the CR 732.2a ending point for this + // entry path too, and it discards the detection window before handing back. + // MEASURED: this fixture enters that seam with a LIVE ring (`ring=16`), so the + // emptiness below is a CLEARED ring and not an absent one. Its journal is + // ALREADY empty there (`answers=0`) — the populated-journal half of the same + // seam is pinned on the f4 dump by + // `fantastic_four_bounded_loop::r3a_the_accepted_drive_ends_at_the_priority_point_with_the_window_cleared`, + // the only fixture measured reaching this seam with answers recorded. + assert!( + doctored.loop_detect_ring.is_empty(), + "n={n}: CR 732.2a — the aborted drive ends at the priority handback with the \ + detection window DISCARDED, so a later beat re-detects genuinely instead of this \ + same `apply()` re-offering the interrupted loop; ring still carries {} sample(s)", + doctored.loop_detect_ring.len() + ); + assert_eq!( + doctored.loop_answers_recorded(), + 0, + "n={n}: CR 603.5 — the recorded `may` answers describe the window that just ended, \ + and the same seam drops them together with the ring. ⚠ FORWARD TRIPWIRE, not a \ + co-equal half of that claim: MEASURED non-discriminating on THIS fixture — under a \ + mutant neutering only the seam's `loop_answer_journal = None` this clause stays \ + green (the journal already reads 0 when this fixture reaches the seam) while the \ + f4 row fails `left: 3, right: 0`. It earns its place by failing if a future writer \ + ever populates the journal on this entry path and the seam stops clearing it; the \ + DISCRIMINATING statement of the journal half is the f4 row named above" + ); } } @@ -11063,7 +12127,9 @@ fn g1_declare_verdict( if pin_twice { // E-neg: two pins against a `min_targets == max_targets == 1` point. The cardinality // check sits OUTSIDE the per-index loop, so it must still refuse at count 0. - targets.push(TargetPin::Scheduled(TargetSchedule::Constant(a.clone()))); + targets.push(TargetPin::Scheduled(TargetSchedule::Constant(obj_rank( + a.clone(), + )))); } let template = DecisionTemplate { owner: P0, @@ -11079,6 +12145,7 @@ fn g1_declare_verdict( predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema, + declaration: None, }; runner .act(GameAction::DeclareShortcut { @@ -11090,15 +12157,15 @@ fn g1_declare_verdict( } fn piecewise_a_then_b(a: &YieldTarget, b: &YieldTarget) -> TargetSchedule { - TargetSchedule::Piecewise(vec![(0, a.clone()), (5, b.clone())]) + TargetSchedule::Piecewise(vec![(0, obj_rank(a.clone())), (5, obj_rank(b.clone()))]) } fn piecewise_b_then_a(a: &YieldTarget, b: &YieldTarget) -> TargetSchedule { - TargetSchedule::Piecewise(vec![(0, b.clone()), (5, a.clone())]) + TargetSchedule::Piecewise(vec![(0, obj_rank(b.clone())), (5, obj_rank(a.clone()))]) } fn round_robin_a_b(a: &YieldTarget, b: &YieldTarget) -> TargetSchedule { - TargetSchedule::RoundRobin(vec![a.clone(), b.clone()]) + TargetSchedule::RoundRobin(vec![obj_rank(a.clone()), obj_rank(b.clone())]) } /// R6 arms A / B / C — the validated range must COVER the driven range. @@ -11766,6 +12833,7 @@ fn r28_empty_schema_offer(runner: &mut GameRunner) { predicted_winner, certificate, schema, + declaration: _, } = runner.state().waiting_for.clone() else { panic!("staged from the live offer, never from thin air"); @@ -11778,6 +12846,11 @@ fn r28_empty_schema_offer(runner: &mut GameRunner) { points: vec![], ..schema }, + // `None` is a RULING, not a default. Passing the live field through would stage the R5 + // board's measured `Some` declaration into a control whose whole purpose is to be + // declaration-free — and it would contradict the invariant that an empty schema + // publishes no declaration, at the very fixture that stages an empty schema. + declaration: None, }; } @@ -11793,6 +12866,7 @@ fn r28_nonempty_schema_offer(runner: &mut GameRunner, slot: DecisionSlot) { predicted_winner, certificate, schema, + declaration: _, } = runner.state().waiting_for.clone() else { panic!("staged from the live offer, never from thin air"); @@ -11817,6 +12891,10 @@ fn r28_nonempty_schema_offer(runner: &mut GameRunner, slot: DecisionSlot) { }], ..schema }, + // Same ruling as [`r28_empty_schema_offer`]: both helpers exist to stage `schema.points` + // and NOTHING else, so the two must keep differing in exactly one field. Staging a live + // `Some` here would add a second axis to a pair whose whole value is being one apart. + declaration: None, }; } @@ -12139,22 +13217,41 @@ fn r28_c_a_restored_proposal_with_a_foreign_template_owner_is_refused_at_consump /// candidate was emitted), and the answer-beat sampling site now announces the Bond's trigger /// entry, so it publishes one `Targets` point. This row is the pin for that transition. /// -/// * **arm (a), the live board:** one published point ⇒ the candidate set is `DeclineShortcut` -/// alone, and specifically carries NO `Fixed` declaration. +/// * **arm (a), the live board:** one published point, and the offer carries the engine's own +/// declaration for it ⇒ the `Fixed` candidate is emitted CARRYING THAT DECLARATION. /// * **arm (b), the POSITIVE CONTROL, same board one field apart:** stage the schema's `points` -/// empty ([`r28_empty_schema_offer`]) ⇒ the `Fixed` candidate RETURNS. Without this arm, -/// arm (a) would be satisfied by a generator that had stopped emitting `Fixed` for any -/// reason at all — including not running. +/// empty ([`r28_empty_schema_offer`]) ⇒ the `Fixed` candidate is emitted with `template: None`. +/// Without this arm, arm (a) would be satisfied by a generator that emitted `Fixed` +/// unconditionally. +/// +/// ⚠ **ARM (a)'S PREVIOUS CLAIM WAS THE OPPOSITE, AND IT IS SUPERSEDED, NOT BROKEN.** As +/// `ai1_the_bounded_declare_candidate_withdraws_when_the_offer_publishes_a_pin` it asserted +/// `assert_eq!(live, vec![GameAction::DeclineShortcut])` — that a published pin set WITHDREW the +/// declare candidate, because the only declaration the generator could emit carried +/// `template: None` and would be accepted-then-discarded. item-4 C2b gives the generator the +/// offer's own declaration to carry, so the withdrawal is exactly the behaviour this commit +/// replaces, and the name had to stop saying "withdraws". +/// +/// **ARM (b) IS BYTE-IDENTICAL AND THAT IS EARNED, NOT LUCK.** [`r28_empty_schema_offer`] is a +/// rest-less destructure plus a rebuild literal, so C2b had to CHOOSE a value for `declaration` +/// there; it passes `None`. Threading the live field through would stage this board's measured +/// `Some` declaration into a control that exists to be declaration-free, and arm (b)'s +/// `template: None` match would fail. See that helper's own comment. /// /// Both arms read the ENGINE's candidate set through `legal_actions`, the same seam /// `phase-ai`'s search calls, so this is not a re-implementation of the gate agreeing with /// itself. The row is deliberately NOT `#[ignore]`d: the two pre-existing `phase-ai` bounded /// rows are, and an ignored row reports `ok` while executing nothing. #[test] -fn ai1_the_bounded_declare_candidate_withdraws_when_the_offer_publishes_a_pin() { +fn ai1_the_bounded_declare_candidate_carries_the_offers_own_pin_when_one_is_published() { // ── arm (a): the LIVE offer, which now publishes one point ── let (mut runner, _slot, _bond, _hexproof, _lives) = r5_reach_offer(); - let WaitingFor::LoopShortcut { schema, .. } = runner.state().waiting_for.clone() else { + let WaitingFor::LoopShortcut { + schema, + declaration, + .. + } = runner.state().waiting_for.clone() + else { panic!("r5_reach_offer returns at the offer"); }; assert!( @@ -12168,13 +13265,23 @@ fn ai1_the_bounded_declare_candidate_withdraws_when_the_offer_publishes_a_pin() "REACH-GUARD: the published pin set is the conjunct this row is about; got {:?}", schema.points ); + let declaration = declaration.expect( + "REACH-GUARD: this board's proposer answered its one published point, so the offer \ + publishes a declaration — without one arm (a) would measure the fail-closed path \ + `d6n_a_points_carrying_offer_without_a_declaration_enumerates_only_decline` covers", + ); let live = engine::ai_support::legal_actions(runner.state()); assert_eq!( live, - vec![GameAction::DeclineShortcut], - "AI1(a): against a points-carrying bounded offer the ONLY legal candidate is the \ - decline — a `template: None` declaration is accepted and then discarded by \ - `handle_declare_shortcut`, which is worse than no candidate at all" + vec![ + GameAction::DeclareShortcut { + count: IterationCount::Fixed(schema.max_iterations), + template: Some(declaration), + }, + GameAction::DeclineShortcut, + ], + "AI1(a): against a points-carrying bounded offer that HAS a declaration, the generator \ + emits it — carrying the ENGINE's own pin set, never one the AI built" ); // ── arm (b): the POSITIVE CONTROL — the same board with an EMPTY point set ── @@ -12199,6 +13306,110 @@ fn ai1_the_bounded_declare_candidate_withdraws_when_the_offer_publishes_a_pin() ); } +/// **Row D7 — a PRE-DECLARATION save decodes with `declaration: None`, i.e. today's refusal.** +/// +/// CR 732.2a. `WaitingFor::LoopShortcut.declaration` carries `#[serde(default)]`, following +/// `schema`'s precedent on the same variant. The consequence is CHOSEN, not discovered: a +/// snapshot written before this field existed decodes with `None`, the AI's declare candidate +/// stays withheld (`declaration.is_some()` is false) and the human path is unchanged — the same +/// behaviour that shipped before the field. Fail-closed by construction. +/// +/// # Non-vacuity +/// +/// The positive control is the round-trip WITH the key present: a decoder that always produced +/// `None` — or a `declaration` that never serialized at all — fails it. And the key's removal is +/// asserted to have actually removed something, so a typo in the field name cannot make the +/// "old save" arm pass by decoding an unmodified payload. +/// +/// # ⚠ REVERT-PROBE, MEASURED — and the OBVIOUS probe is INERT, which is why it is named here +/// +/// Deleting `#[serde(default)]` from the field does **NOT** red this row: measured, the stripped +/// payload still decodes and this test still passes. `serde_derive` routes a missing field +/// through `serde::__private::de::missing_field`, whose deserializer answers `deserialize_option` +/// with `visit_none` — so an `Option` field is already missing-tolerant, and the attribute is +/// belt-and-braces here (it follows `schema`'s precedent on the same variant and states the +/// intent explicitly; it becomes load-bearing the moment the field stops being an `Option`). +/// +/// The two probes that DO red this row, one per arm, both RUN: +/// +/// * `#[serde(skip)]` in place of `#[serde(default)]` ⇒ the declaration never reaches the wire +/// ⇒ the POSITIVE CONTROL round-trip fails (a `Some(..)` decodes back as `None`); +/// * `#[serde(default = "…")]` pointing at a function returning `Some(..)` ⇒ the stripped +/// payload decodes with a fabricated declaration ⇒ the old-save arm's `matches!` fails. +#[test] +fn d7_a_pre_declaration_save_decodes_with_no_declaration() { + let slot = DecisionSlot::target(YieldTarget::ThisObject { + source_id: ObjectId(881), + incarnation: Some(1), + trigger_description: None, + }); + let offer = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: None, + certificate: synthetic_lethal_cert(), + schema: ShortcutDecisionSchema { + iteration_count: IterationCount::Fixed(3), + max_iterations: 3, + points: vec![DecisionPoint { + slot: slot.clone(), + kind: DecisionPointKind::Targets { + legal_targets: vec![TargetRef::Player(P1)], + min_targets: 1, + max_targets: 1, + ordered: false, + }, + }], + convoke_tappable_count: 0, + }, + declaration: Some(DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot: slot.clone(), + targets: vec![TargetPin::Player(P1)], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(3), + }, + key: DecisionGroupKey::from_sources(&[slot.source], DecisionKind::LoopChoice), + }), + }; + + let mut json = serde_json::to_value(&offer).expect("the offer serializes"); + // POSITIVE CONTROL: with the key present the declaration survives the wire intact. + assert_eq!( + serde_json::from_value::(json.clone()).expect("round-trips"), + offer, + "a live offer's declaration must survive serialization — otherwise the `None` below \ + would prove nothing about the DEFAULT" + ); + + // The pre-C2b payload: the same offer with no `declaration` key at all. + let removed = json["data"] + .as_object_mut() + .expect("the adjacently-tagged payload is an object") + .remove("declaration"); + assert!( + removed.is_some(), + "reach-guard: the key must have been present to remove, else the 'old save' arm below \ + decodes an unmodified payload and asserts nothing" + ); + let decoded: WaitingFor = serde_json::from_value(json).expect( + "an OLD save must still decode (CR 732.2a offers \ + predate this field)", + ); + assert!( + matches!( + decoded, + WaitingFor::LoopShortcut { + declaration: None, + .. + } + ), + "the forward-compatible default is `None`, which is today's refusal — fail-closed. got \ + {decoded:?}" + ); +} + // ───── PR #7005 maintainer item: the answer-beat sampler records the SYNCHRONIZED window ───── /// CR 732.2a. `game::engine::apply_action`'s forced-window ANSWER sampler (the site gated on @@ -12788,3 +13999,103 @@ fn a_clearing_beat_rebuilds_the_ring_inside_the_same_beat() { it can never name `minted`" ); } + +/// **Row T1b — STRUCTURAL, and the tier is FORCED.** CR 608.2b + CR 601.2c (reached via +/// CR 603.3d): BOTH `WaitingFor::TriggerTargetSelection` reducer arms route their +/// announcement through the single write authority `record_trigger_target_answer`. +/// +/// # ⚠ WHY THIS IS A SOURCE CENSUS AND NOT A WIRE ROW — measured, not conceded +/// +/// The `ChooseTarget` arm is covered end-to-end at the wire tier by +/// `fantastic_four_bounded_loop.rs`'s +/// `c2a_row_t1_the_announced_target_is_journalled_at_the_f4_offers_published_slot` and its +/// P2 provenance sibling. **The `SelectTargets` arm has NO tracked fixture that reaches it.** +/// Driving all five tracked 4p dumps in this file for 60 beats each through production +/// `apply()`: only `dellian_emblem_conqueror_4p` reaches a `TriggerTargetSelection` window, +/// it reaches seven of them, and every one enumerates `ChooseTarget` ×3 / `SelectTargets` ×0; +/// the other four reach none. See [`dump_drive_one_beat`]'s doc for the per-dump numbers. +/// A wire row for that arm is therefore not writable from this repo's fixtures today — +/// recorded as a BACKLOG item (needs a dump whose trigger declares a multi-slot or +/// object-target announcement), never as a silently-absent row. +/// +/// This census covers exactly what it can: that the arm is WIRED. The writer's BEHAVIOUR is +/// proven separately and at a tier that can carry it — `game::engine`'s +/// `c2a_row_t5_an_unresolvable_target_abandons_the_whole_journal_write` drives the helper +/// itself, and both arms call that one helper, which is the point of it being one helper. +/// It is the same instrument class, and the same reasoning, as +/// `fantastic_four_bounded_loop.rs`'s ring-clear census. +/// +/// # Discrimination +/// +/// Delete the `record_trigger_target_answer(..)` call from EITHER arm ⇒ that arm lands in +/// `unwired` and this row reds NAMING the arm and its line. The mutation compiles (the other +/// caller survives), so it reds on the assert, not on a compile error. Without this row, r2's +/// own finding stands: deleting the `SelectTargets` call reds nothing in the suite. +/// +/// # Reach-guard +/// +/// The arm COUNT is asserted first and is independent of the call: a pattern reflow that hid +/// an arm from this scanner would read 1 or 0 and fail here rather than passing on a census +/// that found nothing to check. +#[test] +fn c2a_row_t1b_both_trigger_target_selection_arms_route_through_the_single_writer() { + use std::path::Path; + + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("game/engine.rs"); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + let lines: Vec<&str> = text.lines().collect(); + + let (mut wired, mut unwired) = (Vec::new(), Vec::new()); + for (i, line) in lines.iter().enumerate() { + let action = if line.contains("GameAction::SelectTargets {") { + "SelectTargets" + } else if line.contains("GameAction::ChooseTarget {") { + "ChooseTarget" + } else { + continue; + }; + // A reducer arm's `WaitingFor` half sits a few lines above its `GameAction` half in + // the tuple pattern; anything further away is a different construct. + if !lines[i.saturating_sub(10)..i] + .join("\n") + .contains("WaitingFor::TriggerTargetSelection {") + { + continue; + } + if lines[i..(i + 14).min(lines.len())] + .join("\n") + .contains("record_trigger_target_answer(") + { + wired.push(action); + } else { + unwired.push(format!("game/engine.rs:{} ({action})", i + 1)); + } + } + + assert_eq!( + wired.len() + unwired.len(), + 2, + "reach-guard: `apply_action` has exactly TWO `WaitingFor::TriggerTargetSelection` \ + reducer arms (`SelectTargets` and `ChooseTarget`). A different count means an arm \ + was added, removed, or reflowed out of this scanner's reach — re-derive this census, \ + do not re-number it. wired={wired:?} unwired={unwired:?}" + ); + assert!( + unwired.is_empty(), + "CR 608.2b: every `TriggerTargetSelection` reducer arm must journal its announcement \ + through `record_trigger_target_answer`, the single write authority. Unwired: \ + {unwired:?}" + ); + assert_eq!( + { + let mut w = wired.clone(); + w.sort_unstable(); + w + }, + vec!["ChooseTarget", "SelectTargets"], + "both arms by NAME, not just by count: a census that found the same arm twice would \ + satisfy a bare count while leaving the other one unmeasured" + ); +} diff --git a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs index b973a188ea..429792f221 100644 --- a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs +++ b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs @@ -684,6 +684,7 @@ fn loop_action_sequence_conditional_load_migration() { per_cycle: None, }, schema: ShortcutDecisionSchema::default(), + declaration: None, }; at_offer.last_loop_action_sequence = vec![pinned_step()]; let json = serde_json::to_string(&at_offer).expect("serialize offer save"); diff --git a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs index f2255f7c8e..9bdd6730da 100644 --- a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs @@ -9,9 +9,35 @@ //! number written into a plan cannot fire; this can. //! //! WHAT IT PINS, and why it is an INVARIANCE claim rather than a re-measurement: -//! 22 production + 14 test sites across `crates/engine/src` and -//! `crates/phase-ai/src`. A failure reads *"5d (or a successor) changed the -//! offer-writer surface"*, not *"someone re-measured"*. +//! the offer-writer surface across `crates/engine/src` and `crates/phase-ai/src`. +//! NO NUMBER IS RESTATED HERE — not even to narrate the incident that caused this +//! rewrite. This header once carried a production/test pair that had drifted from +//! the assert below; quoting that pair *here* would reintroduce the defect, +//! because probe-pin validates only between its BEGIN/END markers and any number +//! above them is unanchored by construction. The incident, with both figures, is +//! recorded in `probe-pin/engine-census.toml`, whose anchors an adjudicator has to +//! edit anyway. The assert below is where the pair is authoritative. NO rustdoc in +//! this file states either half of it — the adjudication history names SUPERSEDED +//! test-half values only, each chain deferring to the assert rather than restating +//! it (the chains end at "the value the assert below pins", "the pinned value", and +//! "the assert below is the authority for the pair"; no single phrase is shared, so +//! none is quoted as if it were). Both +//! figures do recur inside the assert's own failure MESSAGE, beside the literal +//! they describe, where an adjudicator editing one has the other in view; P1's +//! anchors cover that literal and one sentence of that message. NOTHING covers +//! rustdoc, which is why the rustdoc now carries no figure OF THE PINNED PAIR. +//! ⚠ READ THAT SCOPE LITERALLY — it was written wider once and was false. This file's +//! SIBLING test (`the_cfg_scope_classifier_...`) still has its OWN asserted figures +//! restated in its rustdoc, and NOTHING pins them: that test builds its +//! input as an in-binary String, so no bind-mount can reach it and the manifest +//! declares it unpinnable. Those restatements can go stale exactly as the pinned +//! pair's did. They are disclosed rather than struck because striking them would edit +//! a test this commit does not pin and was not reviewed against. The +//! PROBE-PIN block below re-measures both halves on every `probe-pin check` — its +//! rows anchor the pinned tuple and the adjudication sentence inside the assert — +//! so moving either without regenerating the block turns the pin red. A failure +//! reads *"5d (or a successor) changed the offer-writer surface"*, not +//! *"someone re-measured"*. //! //! THE ANCHOR IS BARE — `WaitingFor::LoopShortcut {`, with no `= ` / `Ok(` //! qualifier. A prefix-anchored regex cannot be completed by adding prefixes: @@ -25,9 +51,55 @@ //! for a `#[test]` that reads the source tree through //! `Path::new(env!("CARGO_MANIFEST_DIR"))` and asserts a structural invariant. +// ── PROBE-PIN ──────────────────────────────────────────────────────────────── +// The claims below are MEASURED, not asserted in prose: each row is a mutation of +// the walked tree plus the verdict it produced. Regenerate with +// cargo probe-pin run --write probe-pin/engine-census.toml +// A row whose anchor stops matching is a number that moved without an adjudication. +// +// DISCLOSURE — the SHAPE named as the anchor lint's residual now occurs in a MANIFEST for the +// first time. The predicate is stated because a bare "first time" is false: `docs/probe-pin.md` +// already prints an anchor of this shape as a worked example, so the shape's first appearance +// in-tree is the doc's, not this file's. What is new is the first one AUTHORED IN A SHIPPING +// MANIFEST. `docs/probe-pin.md` rejects anchors embedding a line number, and names one +// shape it cannot reject: a positional integer in a `("", )` slot. The block +// below carries anchors of that shape. In each, the integer is a COUNT (a per-file multiset +// entry), which is the legitimate form the lint deliberately admits — never a line. Said +// here because no instrument distinguishes the two. The doc's REVISIT CONDITION is narrower +// than the shape and is NOT met by this commit: it asks for a revisit only "if an anchor of +// the `("", )` shape is ever authored with a *line* in the integer slot". No +// revisit is owed here; what is owed is this disclosure. +// ⚠ THAT QUOTATION IS UNANCHORED, and saying so is the point: no probe in the manifest matches +// it, and `docs/probe-pin.md` is not one of this resource's deps, so editing the doc leaves this +// quotation silently stale. It is quoted rather than paraphrased because a paraphrase was wrong +// here once; it is disclosed rather than pinned because pinning another file's prose from this +// file would make an unrelated doc edit fail the census pin. +// +// VENUE LIMIT — the block below is checked by ONE venue: the Tiltfile's `probe-pin-census` +// resource, locally, on engine-source edits. It is NOT checked in GitHub CI, and CI +// enrollment is policy-blocked (`.agents/pr-review-policy.toml` `[hard_stops]` lists +// `.github/workflows/**`). A green block in a merged commit is not a CI-verified block. +// PROBE-PIN:BEGIN manifest=probe-pin/engine-census.toml digest=sha256:38836c2e1f2fddb8 +// instrument rustc = rustc 1.97.0-nightly (0febdbab2 2026-04-18) +// | probe | mutation | expect | verdict | firing assertion (anchor) | provenance | +// |---|---|---|---|---|---| +// | P0_control | (none) | pass | pass | (control; no mounts) | — | +// | P1_production_site_removed | scenario.rs ×1 | fail | fail | left: (21, 21) / right: (22, 21) / THE TEST HALF HAS BEEN ADJUDICATED FIVE TIMES | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P2_test_site_removed | projection.rs ×1 | fail | fail | left: (22, 20) / right: (22, 21) | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P3_walk_reaches_phase_ai_and_skips_comments | lib.rs ×1 | fail | fail | left: (23, 21) / right: (22, 21) | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P4_counting_is_per_line | lib.rs ×1 | fail | fail | left: (24, 21) / right: (22, 21) | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P5_relocation_preserves_the_count | scenario.rs ×1, interaction.rs ×1 | fail | fail | the COUNT can be preserved by a move that relocates a writer / ("engine/src/game/interaction.rs", 6) | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P6_second_validate_pins_consumer | scenario.rs ×1 | fail | fail | expected `validate_pins(` to appear in production exactly twice | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P7_coverage_half_unpaired | decision_template.rs ×1 | fail | fail | validating pin VALUES without also running | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P8_authority_call_site_removed | engine.rs ×1 | fail | fail | expected 1 definition + 3 call sites / ("engine/src/game/engine.rs", 1) | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// probe-pin validates only the lines between BEGIN and END. Prose outside is never checked. +// PROBE-PIN:END + use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use super::source_census::code; + /// The bare anchor, ASSEMBLED AT RUNTIME. /// /// This file lives under `crates/engine/tests/`, which the census does not walk, @@ -53,6 +125,9 @@ struct Hit { file: String, line: usize, in_test: bool, + /// The trimmed source line, so a hit can be selected by ROLE (definition vs. consumer) + /// instead of by line ORDER. Mirrors the sibling seat-pin census's `Site::text`. + text: String, } /// CR-neutral source classification: which lines of `src` sit inside a @@ -115,24 +190,33 @@ pub(super) fn cfg_test_scoped_lines(src: &str) -> Vec { /// U1–U6 introduce no `WaitingFor::LoopShortcut {` token. Measured on this tree: /// 5d U2's declare-time owner firewall added the DOC LINE /// `// copied from `WaitingFor::LoopShortcut { proposer }`.` to `game/engine.rs`, -/// which a comment-blind bare anchor counts as a 23rd production site. A comment +/// which a comment-blind bare anchor counts as one MORE production site. A comment /// is not a code surface — it writes no offer and consumes none — so counting it /// would make the tripwire fire on prose and would force the pinned number to be /// re-measured by the very commit that ships the row. Excluding comment lines -/// restores the plan's PRODUCTION count of 22 exactly, INCLUDING its per-file +/// restores the plan's PRODUCTION count exactly, INCLUDING its per-file /// production multiset. (It does not restore the plan's original test-half count -/// of 12: that half has since been adjudicated to 14, twice, and the assert below -/// is the authority for the pair. Prose that repeats a number is prose that can go -/// stale — this defers to the assert rather than restating it.) +/// of 12: that half has since been adjudicated repeatedly, and the assert +/// below is the authority for the pair. Prose that repeats a number is prose that +/// can go stale — this defers to the assert rather than restating it.) +/// THE COMMENT RULE IS THE CODE HALF OF THE LINE, not the whole line. Rejecting only lines that +/// OPEN with `//` left a needle sitting AFTER a trailing `//` counted as a writer, which breaks +/// the exclusion in both directions: a pure prose edit moves the pinned number with no code +/// change, and deleting a real writer while naming the same spelling in a trailing comment on a +/// surviving line HOLDS the number — the substitution class this census exists to catch. +/// [`super::source_census::code`] is the one home of that rule, shared by every census in +/// fail-CLOSED (a `//` preceded by a `"` on the same line is left in the code half, so a URL in +/// a string literal cannot hide a real writer behind it). fn classify(src: &str, needle: &str, file: &str) -> Vec { let scoped = cfg_test_scoped_lines(src); src.lines() .enumerate() - .filter(|(_, line)| line.contains(needle) && !line.trim_start().starts_with("//")) - .map(|(n, _)| Hit { + .filter(|(_, line)| code(line).contains(needle)) + .map(|(n, line)| Hit { file: file.to_string(), line: n + 1, in_test: scoped[n], + text: line.trim().to_string(), }) .collect() } @@ -183,11 +267,12 @@ fn census(needle: &str) -> Vec { hits } -/// R8 CONJUNCT 1 — the offer-writer surface, pinned BIDIRECTIONALLY (`== 22` / -/// `== 14`, so a REMOVED site fails too) and by per-file multiset. +/// R8 CONJUNCT 1 — the offer-writer surface, pinned BIDIRECTIONALLY (an EQUALITY +/// on each half, so a REMOVED site fails too) and by per-file multiset. /// -/// ⚠ THE `#[cfg(test)]` HALF HAS MOVED TWICE, 12 ⇒ 13 ⇒ 14, AND EACH -/// ADJUDICATION IS RECORDED RATHER THAN THE ASSERT RELAXED. +/// ⚠ THE `#[cfg(test)]` HALF HAS MOVED REPEATEDLY — 12 ⇒ 13 ⇒ 14 ⇒ 16 ⇒ 17 ⇒ the +/// value the assert below pins — AND EACH ADJUDICATION IS RECORDED RATHER THAN +/// THE ASSERT RELAXED. /// * 12 ⇒ 13: §6 R27 (b) /// (`analysis::resource::tests::r27_b_a_stored_may_auto_choice_survives_the_ring`) /// destructures the offer the mint RETURNED to count its published CR 603.5 @@ -197,19 +282,37 @@ fn census(needle: &str) -> Vec { /// arm (b) can assert that the DECLARE firewall refuses a hostile /// `template.owner` — i.e. that arm (b)'s drive-seam configuration is /// production-unreachable. +/// * 14 ⇒ 16: both in `phase-ai/src/policies/loop_shortcut.rs`'s `#[cfg(test)]` +/// module — `bounded_offer_with_period`, a builder minting an offer whose +/// certificate carries a real `per_cycle` so the proposer-elimination arm can +/// be driven, and `certificate_of`, a READ accessor for the same rows. +/// * 16 ⇒ 17: the cap-round row +/// `the_bounded_offer_charges_a_forced_victim_it_publishes_no_point_for` in +/// `engine/src/analysis/resource.rs` — A READ, NOT A WRITER: it destructures +/// the offer it minted to assert an EMPTY `schema.points` beside a +/// `victim_slot` that still names the forced victim. +/// * 17 ⇒ the pinned value: the CR 732.2a declaration change's two in-crate +/// rows spell the anchor FOUR +/// times between them — `game/visibility.rs` row D5-h's mint and its projection +/// read, and `ai_support/candidates.rs` row D6-n's mint and its reach-guard read. /// -/// Both are WRITES in a `#[cfg(test)]` scope, which is the benign case this -/// row's own failure message names: a test fixture cannot make the period -/// machinery certify. The PRODUCTION half is unchanged at 22 and so is the -/// per-file multiset below, which is the half §10 ruling condition (2) is about. +/// All five are in a `#[cfg(test)]` scope — mints and reads both — which is the +/// benign case this row's own failure message names: a test fixture cannot make +/// the period machinery certify. The PRODUCTION half is UNCHANGED across all five +/// — and so is the per-file multiset below, which is the half §10 ruling condition +/// (2) is about. Its VALUE is the assert's, not this comment's. /// -/// R8 CONJUNCT 2, same test — every production `validate_pins(` site is a -/// declare-time gate paired with `predictability_gate`. +/// R8 CONJUNCT 2, same test — pin VALUE-legality has exactly ONE production +/// consumer (`analysis::decision_template::declaration_conforms`), that consumer +/// also runs `predictability_gate`'s COVERAGE half, and every production site +/// that publishes or accepts a declaration routes through it. Superseded shape, +/// recorded at the assert: a per-call-site `validate_pins`/`predictability_gate` +/// pairing rule, which could not see a site that ran neither. /// /// ON FAILURE, the named consequence (§10 ruling condition (2)): a new -/// production site in a certification-path file, or a declare site without its -/// `validate_pins` pairing, means the period machinery may have created a path -/// that CERTIFIES WITHOUT DECLARING OR DRIVING. That converts +/// production site in a certification-path file, or a declare site that does not +/// route through the shared authority, means the period machinery may have +/// created a path that CERTIFIES WITHOUT DECLARING OR DRIVING. That converts /// answer-legality-at-certification from a doc note into owed work, and the /// U-series stops until it is carried. Adjudication is a human step; this is not /// a test to relax. A new *read* site is the benign case and the message says so. @@ -230,7 +333,7 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid assert_eq!( (production.len(), in_test.len()), - (22, 16), + (22, 21), "CR 732.2a OFFER-WRITER SURFACE CHANGED (not re-measured — this number is an \ INVARIANCE pin over the whole 5d U-series).\n\ The three CERTIFICATION-PATH writers are `reconcile_terminal_result` (object-growth \ @@ -243,7 +346,7 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid certify without declaring or driving — §10 ruling condition (2), i.e. \ answer-legality-at-certification becomes OWED WORK and the U-series stops. A new READ \ site is the benign case; adjudicate, do not relax the assert.\n\ - THE TEST HALF HAS BEEN ADJUDICATED THREE TIMES (12 ⇒ 13, §6 R27 (b)'s schema read in \ + THE TEST HALF HAS BEEN ADJUDICATED FIVE TIMES (12 ⇒ 13, §6 R27 (b)'s schema read in \ `engine/src/analysis/resource.rs`; 13 ⇒ 14, 5d U4's `u4_park_on_offer` fixture in \ `engine/src/game/engine.rs`, which parks a constructed board on an offer so §6 R28 \ arm (b) can assert the DECLARE firewall refuses a hostile `template.owner`; 14 ⇒ 16, \ @@ -252,8 +355,36 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid real `per_cycle` so the proposer-elimination arm can be driven, and `certificate_of`, \ a read accessor for the same rows. PRODUCTION STAYED AT 22 across that change, which \ is the half this pin exists to protect: the new policy arm READS the certificate and \ - writes no offer); if it moves again, name the new site here too rather than only \ - moving the number.\n\ + writes no offer). FOURTH ADJUDICATION, 16 => 17: the cap-round row \ + `the_bounded_offer_charges_a_forced_victim_it_publishes_no_point_for` in \ + `engine/src/analysis/resource.rs`, whose `WaitingFor::LoopShortcut` DESTRUCTURE reads the \ + offer it minted to assert the combination that decoupling CR 732.2a publication from CR \ + 704.5a charging makes reachable: `schema.points` EMPTY while `victim_slot` still names the \ + forced victim. A READ, not a writer. PRODUCTION STAYED AT 22 with an IDENTICAL per-file \ + multiset, and that conjunct is what makes this the benign case rather than a surface \ + change; if it moves again, name the new site here too rather than only moving the \ + number.\n\ + FIFTH ADJUDICATION, 17 => 21: publishing the bounded offer's own CR 732.2a \ + `declaration` on `WaitingFor::LoopShortcut` added two in-crate rows that spell the anchor \ + FOUR times between them. Named individually, because this census counts LINES (its \ + `classify()` is `line.contains(needle)`, deliberately replacing a construction-shaped \ + detector), so a mint and a read of the same fixture are two counted sites: (1) \ + `engine/src/game/visibility.rs` row D5-h's MINT, `d5h_offer` — one local helper called \ + twice, staging a declaration whose pins are all-seat vs. one naming a hidden object; \ + (2) `engine/src/game/visibility.rs` row D5-h's READ, `d5h_projected_declaration` — one \ + local helper destructuring `filter_state_for_viewer(..).waiting_for`, called three \ + times (hidden/viewer, hidden/proposer, public); (3) \ + `engine/src/ai_support/candidates.rs` row D6-n's MINT, `d6n_offer` — one local helper \ + called twice, `declaration: None` vs `Some(..)` and nothing else; (4) \ + `engine/src/ai_support/candidates.rs` row D6-n's READ — the reach-guard destructure \ + that asserts the staged offer really `is_bounded()` and really publishes a point \ + BEFORE the negative claim, without which that row would pass on the wrong conjunct. \ + Site (4) was NOT predicted (the plan budgeted 20 on the reading that D6-n's assertions \ + touch only `legal_actions`); the measurement wins and the site is named rather than the \ + row contorted to hit the budget. ALL FOUR ARE `#[cfg(test)]` FIXTURES: none writes an \ + offer the period machinery can certify. PRODUCTION STAYED AT 22 with an IDENTICAL \ + per-file multiset — C2b adds the field INSIDE existing literals and patterns and \ + introduces no new production anchor line.\n\ measured per-file production multiset: {multiset:?}\n\ production: {production:?}\n\ test: {in_test:?}" @@ -276,45 +407,104 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid certification-path file, so the per-file multiset is pinned too" ); - // ── CONJUNCT 2: every production `validate_pins(` site is a declare-time gate ── - // UNQUALIFIED anchor, deliberately: the fully-qualified + // ── CONJUNCT 2: pin VALUE-legality has exactly ONE production consumer, and it + // is the one that also runs the COVERAGE half ── + // + // ⚠ THE SHAPE OF THIS CONJUNCT CHANGED, AND THE OLD ONE IS RECORDED RATHER + // THAN OVERWRITTEN. It used to pin `validate_pins(` at 3 production sites (1 + // definition + 2 declare-time call sites) and assert each CALL SITE had a + // `predictability_gate(` hit within two lines. That pairing rule was a + // per-call-site *convention*: it could only catch a site that forgot the + // coverage half, never a site that ran BOTH gates against a differently-derived + // `required` list — and it could not see `build_bounded_declaration`, which + // PUBLISHED a declaration while running NEITHER gate (the C2b review's finding + // F1). The two gates are now composed once, in + // `analysis::decision_template::declaration_conforms`, and the invariant this + // conjunct pins is the stronger one: production validates pin values in exactly + // ONE place, that place runs both halves, and every declare-time site routes + // through it. A relapse — a second `validate_pins(` consumer, or a declare site + // that stops routing through the authority — fails the counts below. + // + // UNQUALIFIED anchors, deliberately: the fully-qualified // `crate::analysis::decision_template::validate_pins(` form matches only the // `engine.rs` site and under-counts by one — this plan's own finding 5, // applied symmetrically. let pins = census("validate_pins("); let pins_production: Vec<&Hit> = pins.iter().filter(|h| !h.in_test).collect(); + let pins_files: Vec<&str> = pins_production.iter().map(|h| h.file.as_str()).collect(); assert_eq!( - pins_production.len(), - 3, - "expected 1 definition (`analysis/decision_template.rs`) + 2 declare-time call sites \ - (`game/engine.rs::handle_declare_shortcut`, \ - `game/interaction.rs::materialize_loop_shortcut_response`); got {pins_production:?}" + pins_files, + vec![ + "engine/src/analysis/decision_template.rs", + "engine/src/analysis/decision_template.rs" + ], + "expected `validate_pins(` to appear in production exactly twice, BOTH in \ + `analysis/decision_template.rs`: its own definition and its single consumer, \ + `declaration_conforms`. A hit in any other file is a declare-time site that \ + re-derives the pin firewall instead of routing through the shared authority — the \ + divergence C2b's F1 closed, where a PUBLISHER emitted a declaration under a weaker \ + predicate than the HANDLER accepts under. got {pins_production:?}" ); - let definition = pins_production - .iter() - .filter(|h| h.file == "engine/src/analysis/decision_template.rs") - .count(); - assert_eq!(definition, 1, "exactly one definition: {pins_production:?}"); - // Each CALL SITE is paired with `predictability_gate` — the coverage half of - // the same declare-time gate. Pairing is asserted WITHIN the enclosing - // statement, i.e. a `predictability_gate` hit within two lines of the call. + // The one consumer runs the COVERAGE half too, asserted the way the old + // per-call-site rule did: a `predictability_gate` hit within two lines. + // + // THE CONSUMER IS SELECTED BY ROLE, NOT BY LINE ORDER. `max_by_key(|h| h.line)` picked the + // consumer only while the definition happened to sit ABOVE it; moving `pub fn validate_pins` + // below `declaration_conforms` — a legal refactor this census has no business objecting to — + // silently made the coverage assertion below check the DEFINITION line instead, i.e. measure + // the wrong thing and report an unrelated failure. The definition is the hit whose text + // declares the fn; the consumer is the other one, and both counts are asserted so a shape + // this rule cannot classify fails loudly instead of defaulting. let gates = census("predictability_gate("); - for site in pins_production + let (definitions, consumers): (Vec<&&Hit>, Vec<&&Hit>) = pins_production .iter() - .filter(|h| h.file != "engine/src/analysis/decision_template.rs") - { - let paired = gates + .partition(|h| h.text.contains("fn validate_pins(")); + assert_eq!( + (definitions.len(), consumers.len()), + (1, 1), + "the two production `validate_pins(` hits must split by ROLE into exactly one \ + DEFINITION (`fn validate_pins(`) and exactly one CONSUMER. Anything else means the \ + role rule stopped classifying this surface and the coverage assertion below would be \ + measuring an unknown hit. definitions: {definitions:?}; consumers: {consumers:?}" + ); + let consumer = consumers[0]; + assert!( + gates .iter() - .any(|g| g.file == site.file && g.line.abs_diff(site.line) <= 2); - assert!( - paired, - "CR 732.2a: a declare site that validates pin VALUES without also running \ - `predictability_gate`'s COVERAGE check can accept a proposal that leaves a \ - published choice unpinned — the certifies-without-declaring shape §10 condition \ - (2) names. Unpaired site: {site:?}; gates: {gates:?}" - ); + .any(|g| g.file == consumer.file && g.line.abs_diff(consumer.line) <= 2), + "CR 732.2a: validating pin VALUES without also running `predictability_gate`'s \ + COVERAGE check can accept a proposal that leaves a published choice unpinned — the \ + certifies-without-declaring shape §10 condition (2) names. Unpaired: {consumer:?}; \ + gates: {gates:?}" + ); + + // Every production site that asks "is this declaration legal?" — the declare + // handler, the human ingress, and the bounded PUBLISHER — routes through the + // authority. The publisher is the site F1 added: it is what makes + // `declaration.is_some()`, the predicate `ai_support::candidates` gates its + // `DeclareShortcut` candidate on, mean "the handler will accept this". + let authority = census("declaration_conforms("); + let authority_production: Vec<&Hit> = authority.iter().filter(|h| !h.in_test).collect(); + let mut authority_per_file: BTreeMap<&str, usize> = BTreeMap::new(); + for h in &authority_production { + *authority_per_file.entry(h.file.as_str()).or_default() += 1; } + assert_eq!( + authority_per_file.into_iter().collect::>(), + vec![ + ("engine/src/analysis/decision_template.rs", 1), + ("engine/src/game/engine.rs", 2), + ("engine/src/game/interaction.rs", 1), + ], + "expected 1 definition + 3 call sites: \ + `game/engine.rs::handle_declare_shortcut` (accept), \ + `game/engine.rs::build_bounded_declaration` (publish), \ + `game/interaction.rs::materialize_loop_shortcut_response` (human emit). A MISSING \ + call site is a path that publishes or accepts a declaration under its own predicate; \ + a NEW one is benign but must be named here rather than absorbed. \ + got {authority_production:?}" + ); } /// R8 ANTI-VACUITY ARM 2 — THE FOREIGN-FORM PLANT. @@ -339,8 +529,12 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid /// anchor): remove the cfg-scope filter — i.e. make `cfg_test_scoped_lines` /// return all-`false` — and the four mod-scoped plants count as production, so /// `(4, 4)` becomes `(8, 0)` and this test FLIPS TO FAIL. The classifier is also -/// measured keyed on the real tree: it returns BOTH 22 production AND 12 test -/// above, so it is not constant in either direction. +/// measured keyed on the real tree: the census assert above resolves a production +/// half and a test half that differ from each other and from this test's `(4, 4)`, +/// so it is not constant in either direction. THE PAIR IS NOT RESTATED HERE — this +/// sentence used to restate it, the test half moved under adjudication, and nothing +/// went red. The assert above is where that pair is authoritative, and the PROBE-PIN +/// block below re-measures it on every `probe-pin check`. #[test] fn the_cfg_scope_classifier_sees_four_foreign_forms_the_construction_anchor_misses() { let bare = anchor(); diff --git a/crates/engine/tests/integration/loop_shortcut_ranking.rs b/crates/engine/tests/integration/loop_shortcut_ranking.rs new file mode 100644 index 0000000000..603aca5042 --- /dev/null +++ b/crates/engine/tests/integration/loop_shortcut_ranking.rs @@ -0,0 +1,399 @@ +//! Cross-episode CR 732.2a ranking rows that need a whole board rather than a resolver fixture. +//! +//! **Row R2-d** lives here: a ranked `AnnouncementSubject::Seat` is judged as a CR 601.2c +//! TARGET, not merely as PRESENT — and the CR 115.10a CHOICE class keeps its existence-only +//! authority on the very same board. The resolver-level zone sampling for the same arm is +//! `analysis::decision_template`'s `r1ghi_*`; this file is the board-level statement, which is +//! where the two authorities can be contrasted on ONE state. + +use engine::analysis::decision_template::{ + resolve, AnnouncementSubject, ConcreteDecision, ConcreteTarget, DecisionGroupKey, DecisionKind, + DecisionSlot, DecisionTemplate, IterationCount, PinnedDecision, Ranking, ReplayFailure, + ReplayMode, TargetPin, TargetSchedule, +}; +use engine::game::scenario::GameScenario; +use engine::types::ability::{ControllerRef, StaticDefinition, TargetFilter, TypedFilter}; +use engine::types::game_state::{GameState, LayersDirty, YieldTarget}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::player::PlayerId; +use engine::types::statics::StaticMode; +use engine::types::zones::Zone; + +const P0: PlayerId = PlayerId(0); +const P1: PlayerId = PlayerId(1); +const P2: PlayerId = PlayerId(2); + +/// A 3-player board carrying one P0-controlled ability source on the battlefield. +/// +/// The source's ZONE is load-bearing, not scenery: the `Seat` arm asks +/// `player_is_legal_target(state, seat, src_id, src_controller)`, and both of its trailing +/// arguments come from re-binding the SLOT's source through `resolve_ability_instance`. A slot +/// whose source does not resolve fails closed before any hexproof question is asked, which would +/// make every arm below refuse for the wrong reason. +fn board_with_source() -> (GameState, ObjectId) { + let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); + // Production `zones::create_object`, never a raw `objects.insert`: a raw insert never joins + // `state.battlefield`, so `game_functioning_statics` would not see it and the grant applied + // in `grant_hexproof` below would silently never apply. + let source = engine::game::zones::create_object( + &mut state, + CardId(950), + P0, + "Ranked Seat Ability Source".to_string(), + Zone::Battlefield, + ); + (state, source) +} + +/// "You have hexproof" (the Leyline of Sanctity shape), on a permanent `player` controls. +fn grant_hexproof(state: &mut GameState, player: PlayerId) { + let grantor = engine::game::zones::create_object( + state, + CardId(951), + player, + "You Have Hexproof Source".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&grantor) + .expect("the grantor was just created") + .static_definitions = + vec![ + StaticDefinition::new(StaticMode::Hexproof).affected(TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::You), + )), + ] + .into(); + // Fixture bookkeeping, not a rule: a new continuous-effect source needs a layer pass to be + // seen, which an ETB would have requested. MEASURED on this lane: + // `create_object` does NOT re-dirty `layers_dirty`, so on a board whose pass has already run + // (`Clean`) a bare `flush_layers` returns immediately, `refresh_static_mode_presence` never + // runs, and the O(1) `static_mode_presence` gate answers `false` for `Hexproof` regardless of + // what the grantor carries. Marking `Full` is what an ETB would have requested. + state.layers_dirty = LayersDirty::Full; + engine::game::layers::flush_layers(state); +} + +fn slot_for(source: ObjectId, state: &GameState) -> DecisionSlot { + DecisionSlot { + source: YieldTarget::ThisObject { + source_id: source, + // CR 400.7: bind the LIVE incarnation, read from state rather than hard-coded — a + // hard-coded one would fail closed the moment the harness re-enters the object and + // the row would refuse for a bookkeeping reason instead of the rules one. + incarnation: Some(state.objects[&source].incarnation), + trigger_description: None, + }, + index: 0, + } +} + +fn one_pin_template(slot: DecisionSlot, pin: TargetPin) -> DecisionTemplate { + let sources = vec![slot.source.clone()]; + DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot, + targets: vec![pin], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(1), + }, + key: DecisionGroupKey::from_sources(&sources, DecisionKind::LoopChoice), + } +} + +fn ranked_seat(player: PlayerId) -> TargetPin { + TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(player), + ))) +} + +/// **Row R2-d.** CR 601.2c + CR 702.11c: a ranked `Seat` is judged as a TARGET. The same seat, +/// on the same board, at the same slot, is REFUSED in the TARGET-class spelling and ADMITTED in +/// the CR 115.10a CHOICE-class one — which is the whole content of the provenance split, stated +/// as one measurement. +/// +/// # The three arms, and why none of them is redundant +/// +/// * **(a) paired positive** — the ranked seat resolves to `ConcreteTarget::Player(P1)` on the +/// board with NO hexproof. Without it, arm (b) is equally explained by a `Seat` arm that +/// never resolves anything (a dead accessor, a slot source that does not re-bind, a +/// fail-closed branch taken for a bookkeeping reason). +/// * **(b) the claim** — the identical template on the identical board plus one "You have +/// hexproof" permanent P1 controls is `ReplayFailure::IllegalTarget`. +/// * **(c) the CHOICE-class sibling, on the SAME hexproofed board** — a `TargetPin::Player(P1)` +/// at the same slot still resolves. This is the arm that proves (b) is AUTHORITY SELECTION +/// and not a newly-strict engine: CR 115.10a says a player who is not identified by the word +/// "target" is not a target, so applying hexproof to a merely CHOSEN seat would be an +/// over-veto that refuses legal CR 732.2a proposals. Losing arm (c) is the failure mode the +/// shipped `a_shrouded_player_pin_is_still_published_by_the_offer_builder` guards from the +/// other side. +/// +/// # Discrimination +/// +/// * swap `evaluate_schedule`'s `Seat` arm from `targeting::player_is_legal_target` to +/// `players::player_exists_for_choice` ⇒ the hexproofed seat resolves ⇒ **(b) FAILS** while +/// (a) and (c) stay green — the exact asymmetry that makes this row about the AUTHORITY and +/// not about the board; +/// * conversely, route `resolve_target`'s `TargetPin::Player` arm through +/// `player_is_legal_target` ⇒ **(c) FAILS** ⇒ the over-veto is caught here too. +/// +/// # Reach-guards +/// +/// The hexproof is asserted to bite at the TARGET seam for THIS source and controller before +/// (b) is claimed, and asserted NOT to bite for a third seat on the same board — so the +/// exclusion is the hexproof rather than a blanket refusal. CR 702.11c is opponent-scoped, so +/// the source's controller is asserted to be P1's opponent. +#[test] +fn r2d_a_ranked_seat_is_judged_as_a_target_while_the_choice_class_keeps_existence_only() { + let (clean, source) = board_with_source(); + let slot = slot_for(source, &clean); + + // ── (a) PAIRED POSITIVE: no hexproof ⇒ the ranked seat resolves ── + let ranked = one_pin_template(slot.clone(), ranked_seat(P1)); + assert_eq!( + resolve(&ranked, 0, &clean), + Ok(vec![ConcreteDecision::Targets { + slot: slot.clone(), + targets: vec![ConcreteTarget::Player(P1)], + }]), + "CR 601.2c: a ranked seat whose slot source is a live battlefield object RESOLVES — \ + without this arm the refusal below is satisfied by a `Seat` arm that never resolves \ + at all" + ); + + // ── the hostile board: "You have hexproof" on a permanent P1 controls ── + let mut hostile = clean.clone(); + grant_hexproof(&mut hostile, P1); + let controller = hostile.objects[&source].controller; + assert!( + engine::game::players::is_opponent(&hostile, P1, controller), + "reach-guard: CR 702.11c excludes only OPPONENTS' spells and abilities, so the ability \ + source's controller {controller:?} must be P1's opponent" + ); + assert!( + engine::game::static_abilities::player_cannot_be_targeted_by( + &hostile, P1, source, controller + ), + "reach-guard: the grant must actually bite at the TARGET seam for THIS source, else \ + arm (b) proves nothing" + ); + assert!( + !engine::game::static_abilities::player_cannot_be_targeted_by( + &hostile, P2, source, controller + ), + "reach-guard: a third seat on the same board is still targetable, so the exclusion is \ + the hexproof and not an empty legal space" + ); + + // ── (b) THE CLAIM: the TARGET-class spelling is refused ── + assert_eq!( + resolve(&ranked, 0, &hostile), + Err(ReplayFailure::IllegalTarget { + slot: slot.clone(), + pin: ranked_seat(P1), + }), + "CR 601.2c + CR 702.11c: an ANNOUNCED seat is a target, so hexproof makes it an \ + ILLEGAL one. `player_exists_for_choice` would say yes here — that is the authority \ + this spelling exists to move off" + ); + + // ── (c) THE CHOICE-CLASS SIBLING on the SAME board: existence only, still admitted ── + let chosen = one_pin_template(slot.clone(), TargetPin::Player(P1)); + assert_eq!( + resolve(&chosen, 0, &hostile), + Ok(vec![ConcreteDecision::Targets { + slot, + targets: vec![ConcreteTarget::Player(P1)], + }]), + "CR 115.10a: a seat that is CHOSEN rather than targeted is not subject to CR 702.11c, \ + so the choice class still resolves on the very board the target class refuses. \ + Applying the targeting exclusions here would be the over-veto — it would refuse legal \ + CR 732.2a proposals, e.g. a CR 701.34a proliferate choice" + ); +} + +/// One `(kind × ephemerality)` cell of R3-b's grid. Ephemerality is a property of the KEY's +/// source — `ThisObject` is per-incarnation (CR 400.7) and therefore ephemeral, `AllCopies` +/// latches card identity and is persistent — so the cell is built by choosing the source, never +/// by setting a flag. +/// +/// `pub(super)` because `fantastic_four_bounded_loop`'s cross-episode-carrier row plants the +/// same cells on the REAL 4-player board: two builders would be two definitions of +/// "ephemeral", and the one thing this grid must not have is a second opinion about its axis. +pub(super) fn grid_template( + owner: PlayerId, + kind: DecisionKind, + ephemeral: bool, + anchor: ObjectId, +) -> DecisionTemplate { + let source = if ephemeral { + YieldTarget::ThisObject { + source_id: anchor, + incarnation: Some(1), + trigger_description: None, + } + } else { + YieldTarget::AllCopies { + card_id: CardId(9_002), + trigger_description: None, + } + }; + DecisionTemplate { + owner, + decisions: vec![], + replay: ReplayMode::Static, + key: DecisionGroupKey::from_sources(&[source], kind), + } +} + +/// **Row R3-b.** The CROSS-EPISODE CARRIER: a `DecisionKind::LoopChoice` template SURVIVES the +/// CR 603.3b batch boundary, and it is owner-scoped per viewer under CR 723.4. This is P4's +/// precondition, pinned before P4 leans on it. +/// +/// The seam is `GameState::clear_ephemeral_trigger_order_templates`, whose retain predicate is +/// `!(kind == TriggerOrdering && is_ephemeral())` — scoped on BOTH axes. +/// +/// # Reachability was established before cause was attributed +/// +/// The real 4-player drive reaches this boundary, and that is a SHIPPED ROW rather than a +/// retired probe: `fantastic_four_bounded_loop::r3b_driven_a_loop_choice_carrier_survives_a_ +/// whole_accepted_f4_drive` plants the cells on the f4 dump and drives an accepted CR 732.2a +/// shortcut through `apply()` — MEASURED `3 → 2`, survivors +/// `[(LoopChoice, ephemeral), (TriggerOrdering, persistent)]`. So the boundary is reached in +/// production, and the grid below states WHICH cell it removes. +/// +/// # The row is the 2×2 GRID, not one cell +/// +/// A fixture planting only `LoopChoice` cannot distinguish "kind-scoped" from +/// "ephemerality-scoped": both readings keep it. All four `(kind × ephemerality)` cells are +/// planted and exactly one — `TriggerOrdering` + ephemeral — must be removed. The +/// `TriggerOrdering` EPHEMERAL cell is therefore also this row's paired positive: without it a +/// retain predicate that kept everything would pass. +/// +/// # Discrimination — REVERT-PROBE, RUN +/// +/// Widen the retain predicate to cover `LoopChoice` (drop the `kind ==` conjunct) ⇒ MEASURED: +/// the planted `LoopChoice` ephemeral template is gone, survivors +/// `[(TriggerOrdering, persistent)]` and the count drops `2 → 1` ⇒ the first assertion fails. +/// +/// # The CR 603.5 journal half is pinned on the f4 dump, and that is a MEASURED constraint +/// +/// The contrast this carrier lives inside is "the template survives, the answer journal does +/// not". The journal half is asserted by +/// `fantastic_four_bounded_loop::r3a_the_accepted_drive_ends_at_the_priority_point_with_the_window_cleared` +/// — with the `> 0` reach-guard that makes it non-vacuous — and NOT here. THE REASON IS THE +/// BOARD, NOT VISIBILITY, and the distinction matters to whoever reads this next: the READER +/// accessors are `pub` and reachable from this binary — `natural_balance.rs` calls +/// `runner.state().loop_answers_recorded()` (`:648`) and `runner.state().loop_answer(..)` +/// (`:654`). Only the WRITER, `GameState::record_loop_answer`, is `pub(crate)`, and the journal +/// field with it. What makes an assertion here vacuous is that this file's synthetic board +/// drives no answer through `apply()` at all, so `loop_answers_recorded() == 0` would be a +/// negative with no reachable paired positive — not an unreachable accessor. +/// +/// # The hostile arm is MULTI-AUTHORITY, and it says so structurally +/// +/// `viewer_has_private_access_to_player` is +/// `player == viewer || authorized_submitter_for_player(state, player) == viewer`, and +/// `authorized_submitter_for_player` has THREE arms (`LatchedController`, `SearcherFallback`, +/// `effective_authority_for_player`). A fixture that only says "no turn control" silences one +/// arm and leaves the search-decision arm live, which would read post-unification behaviour and +/// call it pre-unification. Both widening conjuncts are asserted absent below. +#[test] +fn r3b_a_loop_choice_carrier_survives_the_batch_boundary_and_is_owner_scoped_per_viewer() { + let (mut state, source) = board_with_source(); + + // ── the 2×2 grid: every (kind × ephemerality) cell, planted on one board ── + state.decision_templates = vec![ + grid_template(P0, DecisionKind::LoopChoice, true, source), + grid_template(P0, DecisionKind::TriggerOrdering, true, source), + grid_template(P0, DecisionKind::TriggerOrdering, false, source), + grid_template(P0, DecisionKind::LoopChoice, false, source), + ]; + let cells = |state: &GameState| -> Vec<(DecisionKind, bool)> { + state + .decision_templates + .iter() + .map(|t| (t.key.kind, t.key.is_ephemeral())) + .collect() + }; + // Reach-guard on the INSTRUMENT: both axes must be genuinely distinguishable on this board, + // else "exactly one cell removed" could be an artefact of four identical keys. + assert_eq!( + cells(&state), + vec![ + (DecisionKind::LoopChoice, true), + (DecisionKind::TriggerOrdering, true), + (DecisionKind::TriggerOrdering, false), + (DecisionKind::LoopChoice, false), + ], + "reach-guard: the planted grid must present all four cells, with `is_ephemeral()` \ + tracking the KEY SOURCE (CR 400.7 `ThisObject` vs latched `AllCopies`)" + ); + assert!( + state + .decision_templates + .iter() + .all(|t| t.key.is_ephemeral() != t.key.is_persistent()), + "reach-guard: the two predicates must be complementary on every planted cell, else the \ + boundary's second conjunct is being read off a degenerate axis" + ); + + state.clear_ephemeral_trigger_order_templates(); + + assert_eq!( + cells(&state), + vec![ + (DecisionKind::LoopChoice, true), + (DecisionKind::TriggerOrdering, false), + (DecisionKind::LoopChoice, false), + ], + "CR 603.3b: the batch boundary drops EXACTLY the `TriggerOrdering` + ephemeral cell. \ + The ephemeral `LoopChoice` surviving is the CR 732.2a cross-episode carrier P4 rides; \ + the ephemeral `TriggerOrdering` being dropped is the paired positive that stops a \ + keep-everything predicate from passing. A `LoopChoice` missing here means the \ + predicate lost its KIND conjunct; a `TriggerOrdering`/ephemeral survivor means it lost \ + its EPHEMERALITY conjunct" + ); + + // ── the hostile arm: CR 723.4 owner scoping, on a board with NO second authority ── + let (mut projected_board, anchor) = board_with_source(); + projected_board.decision_templates = vec![ + grid_template(P0, DecisionKind::LoopChoice, true, anchor), + grid_template(P1, DecisionKind::LoopChoice, true, anchor), + ]; + // BOTH widening conjuncts of `authorized_submitter_for_player` asserted absent + // STRUCTURALLY. Asserting only "no turn control" leaves the search-decision arm live. + assert!( + projected_board.turn_decision_controller.is_none(), + "reach-guard (arm 3): no player controls another's turn decisions on this board" + ); + assert!( + projected_board.active_search_decision_controls.is_empty() + && projected_board.pending_search_found_batch.is_none(), + "reach-guard (arms 1 and 2): no LATCHED search-decision controller and no pending \ + search batch, so `authorized_submitter_for_player` cannot widen private access \ + through the search path either" + ); + let owners = |viewer: PlayerId| -> Vec { + engine::game::visibility::filter_state_for_viewer(&projected_board, viewer) + .decision_templates + .iter() + .map(|t| t.owner) + .collect() + }; + assert_eq!( + owners(P0), + vec![P0], + "CR 723.4: a viewer is projected their OWN carrier and not a non-owner's — the retain \ + is keyed to private access, and on this board the only access is self-access" + ); + assert_eq!( + owners(P1), + vec![P1], + "paired positive, taken on the same board: the other seat sees THEIR carrier, so the \ + absence above is owner scoping and not a sweep that dropped every template" + ); +} diff --git a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs new file mode 100644 index 0000000000..9e99bdcf79 --- /dev/null +++ b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs @@ -0,0 +1,502 @@ +//! **Row R2-b — the PROVENANCE census.** CR 601.2c vs CR 115.10a: a seat pin's SPELLING is its +//! provenance, so no TARGET-class producer may construct a `TargetPin::Player`. +//! +//! # Why a census and not a validator narrowing +//! +//! The alternative — teaching `validate_pins` / `declaration_conforms` to REJECT a +//! `TargetPin::Player` on a player-valued `Targets` slot — is refused for a measured reason. +//! `game::engine`'s `a_shrouded_player_pin_is_still_published_by_the_offer_builder` carries the +//! revert-probe *"route `resolve_target`'s `TargetPin::Player` arm through +//! `targeting::player_is_legal_target` ⇒ both assertions FAIL"*: that narrowing restores the +//! CR 115.10a over-veto, where a merely CHOSEN player (a CR 701.34a proliferate choice) is +//! refused for a targeting-only exclusion it is not subject to. It would also put a rejection +//! rule on a WIRE-VISIBLE type in order to stop a FUTURE producer from picking the wrong +//! spelling. A source census stops the same thing at no wire-compatibility cost, which is what +//! this file is. +//! +//! # What it asserts, in three conjuncts +//! +//! 1. **Neither TARGET-class producer constructs a `TargetPin::Player`.** The two producers are +//! named individually — `game::engine::record_trigger_target_answer` (the engine's own +//! CR 601.2c announcement journal) and +//! `game::interaction::materialize_loop_shortcut_response` (the human ingress of the same +//! point kind). Naming them individually is the point: a census asserting only a TOTAL +//! production count would stay green with EITHER producer reverted, since a revert removes +//! a ranked construction and adds a `TargetPin::Player` one, leaving the total unchanged. +//! 2. **The CHOICE-class and pass-through sites are still PRESENT and classified.** A census +//! that counts zero of everything is vacuous, so every surviving production site is +//! enumerated with its disposition and the list is pinned exactly. +//! 3. **Both producers do construct the ranked spelling**, keyed to the same instrument — one +//! census returning a non-zero answer for one needle and a zero for another, on the same +//! files, is what makes conjunct 1's zero a measurement rather than a broken grep. +//! +//! # Anti-vacuity: the instrument is validated before it is trusted +//! +//! [`the_seat_pin_census_instrument_reports_both_answers_on_planted_input`] feeds the classifier +//! a synthetic source carrying BOTH spellings inside and outside a `#[cfg(test)]` scope and +//! requires it to separate them. Without that arm, a needle that silently matched nothing would +//! report conjunct 1 as a pass. +//! +//! The `#[cfg(test)]` scope classifier and the directory walk are REUSED from +//! [`super::loop_shortcut_offer_writer_census`] rather than re-derived: that file records a +//! measured defect in the naive "nearest preceding attribute" rule, and a second copy of the +//! rule is a second place for it to be got wrong. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use super::loop_shortcut_offer_writer_census::{cfg_test_scoped_lines, rs_files}; +use super::source_census::code; + +/// The CHOICE-class needle, ASSEMBLED AT RUNTIME for the same reason the sibling census +/// assembles its own: this file lives under `crates/engine/tests/`, which the walk does not +/// visit, but an instrument that would report its own text as a finding after a future move is +/// an instrument that lies about the surface it measures. +fn choice_needle() -> String { + format!("{}::{}(", "TargetPin", "Player") +} + +/// The TARGET-class needle — the subject kind that only ever reaches the resolver through a +/// `Ranking`, i.e. the spelling that IS the CR 601.2c provenance. +fn target_needle() -> String { + format!("{}::{}(", "AnnouncementSubject", "Seat") +} + +/// One classified construction/match site. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Site { + file: String, + line: usize, + text: String, +} + +/// Non-comment, production-scope OCCURRENCES of `needle` in `src`, one [`Site`] each. +/// +/// THE SINGLE HOME OF THIS CENSUS'S MATCHING RULE. Source-parameterized, so the anti-vacuity +/// and matched-pair rows below exercise THE SHIPPED RULE rather than a copy of it — a copy +/// validates a rule the file no longer ships. Mirrors +/// [`super::loop_shortcut_offer_writer_census`]'s `classify(src, needle, file)` shape, and +/// computes its own `#[cfg(test)]` scope map for the same reason that one does: a +/// caller-supplied map is a second thing a call site can get wrong. +/// +/// OCCURRENCE granularity, not line granularity: `line.matches(needle).count()` (the `std` +/// building block for non-overlapping matches) emits one `Site` per construction, so two +/// constructions co-located on one line are two counted sites. The boolean rule this replaced +/// counted them once — measured on this tree, 146 non-comment lines already carry two or more +/// occurrences of the same `Enum::Variant(` spelling, including the +/// `TargetRef::Object(id) => Some(TargetRef::Object(*id))` match-arm-plus-construction shape a +/// producer revert would take. +/// +/// COMMENT TEXT IS EXCLUDED — whole-line AND trailing, which is the half this rule got wrong. +/// Prose writes no pin and reads none, so a doc mentioning a spelling is not a construction site; +/// the doc surface is swept separately (the commit's per-property bucket table). Rejecting only +/// lines that OPEN with `//` still counted every occurrence after a trailing `//`, and that fails +/// in BOTH directions: a pure prose edit moves the pinned multiset with no code change, and +/// deleting one construction while naming the same spelling in a trailing comment on a surviving +/// construction line HOLDS the count — the substitution class conjunct 1 exists to catch. +/// Occurrences are therefore counted in the line's CODE half only, via +/// [`super::source_census::code`] (the one home of the rule, shared +/// with both sibling censuses). It is fail-CLOSED: a `//` preceded by a `"` on the same line — +/// `let u = "http://x"; ..` — stays in the code half, so a URL in a string literal cannot hide a +/// real construction behind it, which a naive `split("//")` would. +/// +/// MEASURED at the time of the fix: no line in any walked root carries a needle after a trailing +/// `//`, so this repaired NO pinned number. The repo-scanning rows are therefore blind to it and +/// [`the_seat_pin_census_ignores_a_trailing_comment_mention`] — synthetic input, both directions +/// — is the only thing that measures the rule. +fn sites_in_source(src: &str, needle: &str, file: &str) -> Vec { + let scoped = cfg_test_scoped_lines(src); + let mut out = Vec::new(); + for (n, line) in src.lines().enumerate() { + if scoped[n] { + continue; + } + for _ in 0..code(line).matches(needle).count() { + out.push(Site { + file: file.to_string(), + line: n + 1, + text: line.trim().to_string(), + }); + } + } + out.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line))); + out +} + +/// The LINE count the pre-S279 boolean rule produced, derived from the SAME `Vec`. +/// +/// NOT a second copy of the rule: [`sites_in_source`] emits one `Site` per OCCURRENCE, so the +/// distinct `(file, line)` keys are exactly the lines `line.contains(needle)` would have +/// counted — provably equal to the old rule without shipping the old rule. The key is +/// `(file, line)` and not `line`, so two files sharing a line number cannot collapse. +fn distinct_lines(sites: &[Site]) -> usize { + sites + .iter() + .map(|s| (s.file.as_str(), s.line)) + .collect::>() + .len() +} + +/// Production-scope OCCURRENCES of `needle` across the three walked crate roots — the walk, +/// with the matching rule delegated to [`sites_in_source`]. +fn production_sites(needle: &str) -> Vec { + let engine_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let server_src = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("server-core") + .join("src"); + let ai_src = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("phase-ai") + .join("src"); + let mut out = Vec::new(); + for (root, prefix) in [ + (engine_src, "engine/src"), + (server_src, "server-core/src"), + (ai_src, "phase-ai/src"), + ] { + for path in rs_files(&root) { + let src = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + let rel = path + .strip_prefix(&root) + .expect("walked path is under its root") + .to_string_lossy() + .replace('\\', "/"); + out.extend(sites_in_source(&src, needle, &format!("{prefix}/{rel}"))); + } + } + out.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line))); + out +} + +/// CONJUNCT 1 + 2 — neither TARGET-class producer constructs a `TargetPin::Player`, and every +/// surviving production site is CHOICE class or a pass-through arm. +/// +/// # Discrimination — TWO independent revert-probes, one per producer +/// +/// * restore `game::engine::record_trigger_target_answer`'s `TargetRef::Player(pl)` arm to +/// `Some(TargetPin::Player(*pl))` ⇒ `engine/src/game/engine.rs` gains an unclassified +/// `TargetPin::Player` construction ⇒ the pinned site list below FAILS with its file:line; +/// * restore `game::interaction::materialize_loop_shortcut_response`'s +/// `Target(TargetRef::Player(player))` arm to `Ok(TargetPin::Player(*player))` ⇒ +/// `engine/src/game/interaction.rs` re-appears in the list ⇒ FAILS. +/// +/// A census asserting only a TOTAL would catch NEITHER: each revert swaps one ranked +/// construction for one `TargetPin::Player` construction and leaves the sum where it was. +/// +/// # Paired positive — the CHOICE class must still be THERE +/// +/// The surviving sites are asserted PRESENT, not merely bounded: the CR 701.34a proliferate arm +/// in `engine.rs`, `resolve_target`'s CHOICE authority arm and the drive-period arm, the +/// redaction arm in `visibility.rs`, and the wire-bound arm in `server-core`. A census that +/// counted zero of everything — a broken needle, a walk that visited nothing — would satisfy +/// conjunct 1 alone. +#[test] +fn no_target_class_producer_constructs_a_choice_class_player_pin() { + let sites = production_sites(&choice_needle()); + let files: Vec<&str> = sites.iter().map(|s| s.file.as_str()).collect(); + + // CONJUNCT 1: what is compared is the FILE MULTISET — file identity AND multiplicity, in + // sorted order. Line numbers are not compared against any pinned value; they order the sites + // (`production_sites` sorts by `(file, line)`) and appear in the failure message. That is + // deliberate: a line pin fails on any insertion above a site, which is drift burden without + // discrimination — measured: a 200-line insertion above every site leaves this census green. + // Multiplicity carries the load instead — a new construction anywhere THE WALK VISITS in + // production scope changes the compared value, and multiplicity is counted per OCCURRENCE, + // not per line, so a construction co-located with an existing one on the same line still + // moves the compared value. That includes a THIRD `engine.rs` hit from + // reverting `record_trigger_target_answer`, and `interaction.rs` is pinned by ABSENCE, + // which no rearrangement inside that file can satisfy. The walk visits three roots only + // (`engine/src`, `server-core/src`, `phase-ai/src`), so a construction added in a crate + // outside them — `engine-wasm`, `seat-reducer` and `phase-server` all depend on the engine — + // is invisible here. That gap is latent, not live: today `TargetPin` appears in no crate + // other than `engine` and `server-core`, and both of their `src` roots are walked. + // + // The doubled `engine.rs` entry is guarded by MULTIPLICITY, not by the text pins below: a + // third construction in that file fails THIS assertion, before the text pins are reached — + // including one written onto a line that already carries a construction, since the count is + // per OCCURRENCE. + // Do NOT relax `files` to a de-duplicated set on the theory that the text pins cover the + // doubling — they are a different layer, and layer 3 below exists because a change can pass + // both of them. Three measured layers, in the order they fire: + // 1. this multiset — a THIRD `engine.rs` construction, and, via ABSENCE, either producer + // reverted; + // 2. the text pins below — a SUBSTITUTION at either arm that holds the count at 2, and a + // change in the two arms' relative ORDER (relocating arm 0 below arm 1 fails the first + // text pin with the count and both texts otherwise unchanged); + // 3. conjunct 3's per-file `AnnouncementSubject::Seat` count — a substitution that ALSO + // preserves both texts (producer reverted AND the positive control dropped) passes both + // text pins and is caught only there. + // + // LIMITATION: the three singly-listed files (`decision_template.rs`, `visibility.rs`, + // `server-core/...`) are pinned by file identity and count alone. Swapping one of their + // constructions for a DIFFERENT one in the same file leaves this census green. Those are + // not the producers this row is about, and none of them is text-pinned. + assert_eq!( + files, + vec![ + "engine/src/analysis/decision_template.rs", + "engine/src/game/engine.rs", + "engine/src/game/engine.rs", + "engine/src/game/visibility.rs", + "server-core/src/game_action_payload_guard.rs", + ], + "CR 601.2c / CR 115.10a PROVENANCE SPLIT VIOLATED, or a new unclassified \ + `TargetPin::Player` production site appeared.\n\ + The FIVE surviving production sites and their dispositions:\n\ + 1. `analysis/decision_template.rs` — `resolve_target`'s CHOICE authority arm \ + (CR 115.10a existence-only). This IS the class's authority; do not narrow it, \ + `a_shrouded_player_pin_is_still_published_by_the_offer_builder` is the shipped \ + guard against exactly that.\n\ + 2. `game/engine.rs` — `shortcut_drive_period`'s period arm; the migrated pin lands on \ + the `Scheduled(Constant(_))` arm of the SAME match, which also returns 1.\n\ + 3. `game/engine.rs` — `apply_action`'s CR 701.34a proliferate arm feeding \ + `record_loop_pin`. A proliferate choice is NOT a target, so this construction is \ + correct and is this census's positive control.\n\ + 4. `game/visibility.rs` — `pins_name_hidden_source`'s redaction arm (`false`: seat \ + identity is public in this engine), kept for wire-sourced pins.\n\ + 5. `server-core/src/game_action_payload_guard.rs` — the wire pass-through arm.\n\ + A hit in `game/interaction.rs` means the HUMAN ingress reverted to the choice-class \ + spelling; a THIRD hit in `game/engine.rs` means the announcement journal did. Either \ + re-creates two authorities selected by WHO SUBMITTED an answer rather than by WHAT IT \ + IS. got {sites:?}" + ); + + // The `engine.rs` pair is pinned to its two ARMS by text: a SUBSTITUTION at either arm that + // holds the file count at 2 fails here, as does relocating the arms relative to each other. + // An ADDED construction in this file does not reach here — the multiset above fails first, + // and it fails per OCCURRENCE, so co-locating the addition on an existing hit line does not + // slip past it either. + let engine_texts: Vec<&str> = sites + .iter() + .filter(|s| s.file == "engine/src/game/engine.rs") + .map(|s| s.text.as_str()) + .collect(); + assert_eq!( + engine_texts.len(), + 2, + "the file-level list above is pinned at two `engine.rs` sites; if that changes the \ + text pin below is measuring the wrong things. got {engine_texts:?}" + ); + assert!( + engine_texts[0].starts_with("| TargetPin::Player(_) =>"), + "site 2 must remain `shortcut_drive_period`'s MATCH arm — a match arm reads a pin, it \ + cannot produce one. got {:?}", + engine_texts[0] + ); + assert!( + engine_texts[1].contains("TargetPin::Player(*pl)"), + "site 3 must remain the CR 701.34a proliferate CONSTRUCTION — this census's positive \ + control, and the one production construction of the choice class. got {:?}", + engine_texts[1] + ); + + // CONJUNCT 3, keyed to the SAME instrument: both TARGET-class producers construct the + // ranked spelling. One needle returning five sites and the other returning the two + // producers, over the same walk, is what makes conjunct 1's absence a measurement. + let ranked = production_sites(&target_needle()); + let mut ranked_per_file: BTreeMap<&str, usize> = BTreeMap::new(); + for s in &ranked { + *ranked_per_file.entry(s.file.as_str()).or_default() += 1; + } + let ranked_files: Vec<(&str, usize)> = ranked_per_file.into_iter().collect(); + assert_eq!( + ranked_files, + vec![ + ("engine/src/analysis/decision_template.rs", 1), + ("engine/src/game/engine.rs", 1), + ("engine/src/game/interaction.rs", 1), + ("engine/src/game/visibility.rs", 1), + ], + "the TARGET-class spelling must be CONSTRUCTED by BOTH producers — `game/engine.rs` \ + (`record_trigger_target_answer`) and `game/interaction.rs` \ + (`materialize_loop_shortcut_response`) — beside its two READ sites: \ + `evaluate_schedule`'s CR 601.2c resolver arm in `analysis/decision_template.rs` and \ + the wildcard-free redaction arm in `game/visibility.rs`. A MISSING producer is the \ + revert this row exists to catch; an EXTRA file is a new producer that must be \ + classified rather than absorbed. got {ranked:?}" + ); + assert!( + !sites.is_empty(), + "keying control: the choice-class needle must return a NON-EMPTY set, or its \ + per-producer absence above would be the answer a dead instrument gives" + ); +} + +/// ANTI-VACUITY ARM — the instrument returns BOTH answers on planted input. +/// +/// The classifier is fed one synthetic source carrying the choice-class spelling and the +/// target-class spelling, each in production scope, inside a `#[cfg(test)] pub(crate) mod`, and +/// on a comment line. It must report exactly the production, non-comment hits. +/// +/// # Discrimination +/// +/// * delete the `!scoped[n]` conjunct ⇒ the mod-scoped plants count ⇒ `(1, 1)` becomes +/// `(2, 2)` ⇒ FAILS; +/// * delete the comment filter ⇒ the prose plants count ⇒ `(1, 1)` becomes `(2, 2)` ⇒ FAILS. +/// +/// It is measured on the real tree too, in the row above: the same classifier returns five +/// sites for one needle and four files for the other, so it is not constant in either +/// direction. +#[test] +fn the_seat_pin_census_instrument_reports_both_answers_on_planted_input() { + let choice = choice_needle(); + let target = target_needle(); + let src = format!( + "fn production_side() {{\n\ + \x20 let a = {choice}PlayerId(0));\n\ + \x20 let b = Ranking::one({target}PlayerId(1)));\n\ + \x20 // prose about {choice}..) and {target}..) that constructs nothing\n\ + }}\n\ + \n\ + #[cfg(test)]\n\ + pub(crate) mod tests {{\n\ + \x20 fn test_side() {{\n\ + \x20 let a = {choice}PlayerId(0));\n\ + \x20 let b = Ranking::one({target}PlayerId(1)));\n\ + \x20 }}\n\ + }}\n" + ); + + // Routed through THE SHIPPED RULE, never a local re-implementation of it: an inline copy + // here would validate a rule this file no longer ships, which is the drift the census + // itself exists to catch. + let count = |needle: &str| sites_in_source(&src, needle, "planted.rs").len(); + + assert_eq!( + (count(&choice), count(&target)), + (1, 1), + "the classifier must see exactly the ONE production, non-comment construction of each \ + spelling: the `#[cfg(test)] pub(crate) mod` copies belong in the test column and the \ + prose line is not a construction site. Dropping either filter makes this (2, 2).\n\ + src:\n{src}" + ); +} + +/// TRAILING-COMMENT ARM — a needle mention after `//` on a REAL CODE LINE neither inflates the +/// count nor masks a deleted construction. +/// +/// SYNTHETIC INPUT BY NECESSITY, not by preference. Measured when this rule was repaired: no line +/// in any of the three walked roots carries a needle after a trailing `//`, so every row that +/// scans the real tree passes byte-identically with the rule and without it. A repo-scanning +/// assertion for this property is vacuous BY CONSTRUCTION; only planted input can separate the +/// two rules. [`the_seat_pin_census_instrument_reports_both_answers_on_planted_input`] plants its +/// prose on its OWN line, which the whole-line filter already rejected, so it never reached this +/// path. +/// +/// # The three arms, and what each one would be under the old rule +/// +/// 1. SUBSTITUTION, the arm that matters and therefore the one asserted FIRST — the construction +/// is DELETED and the trailing-comment mention survives: 0, not 1. Under the old rule the +/// census counted a removed construction as still present, which is exactly the substitution +/// class conjunct 1 exists to catch. MEASURED by revert-probe: restoring +/// `line.matches(needle)` makes this arm fail `left: 1 / right: 0`. +/// 2. INFLATION — one real construction plus a mention of the same spelling in a trailing +/// comment on that same line: 1, not 2. Under the old rule a pure PROSE edit moved the pinned +/// multiset with no code change (revert-probe: `left: 2 / right: 1`). +/// 3. FAIL-CLOSED — a `//` inside a STRING LITERAL preceding a real construction: 1, not 0. A +/// naive `split("//")` truncates there and UNDER-counts, silently dropping a real site; +/// [`code`] leaves a `//` that follows a `"` in the code half, so the miss cannot happen. +#[test] +fn the_seat_pin_census_ignores_a_trailing_comment_mention() { + let choice = choice_needle(); + let count = |src: &str| sites_in_source(src, &choice, "planted.rs").len(); + + // FIRST, because it is the arm that matters: a revert-probe must show THIS one failing, not + // merely the cheaper inflation arm that would short-circuit ahead of it. + let substitution = format!("fn f() {{\n let a = 0; // was {choice}PlayerId(0))\n}}\n"); + assert_eq!( + count(&substitution), + 0, + "THE SUBSTITUTION ARM: the construction is gone and only a trailing-comment mention \ + survives, so the count must fall to 0. Counting the whole line makes this 1 — a \ + DELETED producer reported as still present, which is the failure conjunct 1 exists to \ + catch.\nsrc:\n{substitution}" + ); + + let inflation = + format!("fn f() {{\n let a = {choice}PlayerId(0)); // also {choice}PlayerId(9))\n}}\n"); + assert_eq!( + count(&inflation), + 1, + "a needle in a TRAILING comment is prose: the code half of this line holds exactly ONE \ + construction. Counting the whole line makes this 2, and a pure prose edit then moves \ + the pinned multiset with no code change.\nsrc:\n{inflation}" + ); + + let in_string = + format!("fn f() {{\n let u = \"http://x\";\n let a = {choice}PlayerId(0));\n}}\n"); + let in_string_one_line = + format!("fn f() {{\n let u = \"http://x\"; let a = {choice}PlayerId(0));\n}}\n"); + assert_eq!( + (count(&in_string), count(&in_string_one_line)), + (1, 1), + "FAIL-CLOSED: a `//` inside a string literal is not a comment opener. A naive \ + `split(\"//\")` truncates at the URL and reports 0 for the one-line form — a real \ + construction silently dropped, the one direction a census must never fail in.\n\ + src:\n{in_string_one_line}" + ); +} + +/// S279 INSTRUMENT — the census counts OCCURRENCES, and the two rules are separated on input +/// that distinguishes them. +/// +/// The pre-S279 rule was `line.contains(needle)`, a BOOLEAN: two constructions on one line +/// counted once. That is not a synthetic worry — measured over this census's own three walk +/// roots, 146 non-comment lines already carry two or more occurrences of the same +/// `Enum::Variant(` spelling, two of them in exactly the match-arm-plus-construction shape a +/// producer revert would take (`ability_utils.rs`'s `TargetRef::Object(id) => +/// Some(TargetRef::Object(*id))` and `targeting.rs`'s `Player` sibling). `TargetPin::Player(` +/// reads at line/occurrence parity today BY ACCIDENT, not by property. +/// +/// # The matched pair, and why the second number is a PROJECTION +/// +/// Each arm asserts BOTH numbers: `sites_in_source(..).len()` (occurrences) and +/// [`distinct_lines`] (the lines the old boolean rule would have counted). `distinct_lines` is +/// derived from the SAME `Vec`, so the two numbers can diverge only because the DATA +/// differs — never because two instruments differ. A second inline implementation of the line +/// rule is the defect this row exists to remove, not a shortcut it may take. +/// +/// # Discrimination — one arm flips, two do not +/// +/// Revert `sites_in_source` to the boolean form (`if line.contains(needle) { push once }`) ⇒ +/// SAME-LINE's occurrence count becomes 1 ⇒ THIS ROW FAILS, while BASE and DISTINCT-LINE stay +/// green. That asymmetry is what makes it a matched pair rather than a single-sided assertion. +/// +/// # Wiring requirement +/// +/// The arms call `sites_in_source` with their OWN source. They must never call +/// `production_sites`, which takes no source, reads the real tree, and would report the same +/// answer under both rules — measuring nothing. +#[test] +fn the_seat_pin_census_counts_occurrences_and_not_lines() { + let needle = choice_needle(); + let base = format!("fn production() {{\n let a = {needle}PlayerId(0));\n}}\n"); + // The SECOND construction is added TO THE EXISTING HIT LINE — the whole point of the arm. + let same_line = format!( + "fn production() {{\n let a = match t {{ {needle}p) => {needle}*p), _ => x }};\n}}\n" + ); + // The same second construction, on its OWN line. + let distinct = format!( + "fn production() {{\n let a = {needle}PlayerId(0));\n let b = \ + {needle}PlayerId(1));\n}}\n" + ); + + for (label, src, expected) in [ + ("BASE", &base, (1, 1)), + ("SAME-LINE", &same_line, (2, 1)), + ("DISTINCT-LINE", &distinct, (2, 2)), + ] { + let sites = sites_in_source(src, &needle, "synthetic.rs"); + assert_eq!( + (sites.len(), distinct_lines(&sites)), + expected, + "arm {label}: (occurrences, lines) must be {expected:?}. BASE is the reach-guard — \ + both rules agree on the trivial case; SAME-LINE is the arm that FLIPS, and it \ + reads (1, 1) if `sites_in_source` is reverted to `line.contains(needle)`; \ + DISTINCT-LINE proves the flip is about CO-LOCATION and not about the second \ + construction existing. got {sites:?}\nsrc:\n{src}" + ); + } +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 8e86b7e7b2..fda6125963 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1,3 +1,12 @@ +// THE SHARED COMMENT RULE for every source census in this binary, compiled from the SAME FILE the +// crate's own unit-test censuses use. `#[path]` rather than a copy: `src/source_census.rs` is +// `#![cfg(test)]`, and `cfg(test)` HOLDS in this venue too (an integration target is built with +// `--test`), so one implementation serves both without shipping guard code in a release build. +// MEASURED, not assumed — the alternative already in this tree is `test_support.rs` / `support.rs`, +// twin files kept equal by a comment asking a human to remember. +#[path = "../../src/source_census.rs"] +mod source_census; + mod abigale_integration; mod abundance_optional_draw_replacement; mod ad_nauseam_repeat; @@ -175,6 +184,7 @@ mod dig_impossible_keep_count; mod dig_rest_pile_stranding_on_etb_pause; mod diligent_farmhand_counts_as_named; mod diluvian_primordial_6754; +mod dina_noff_turn5_loader; mod disjunctive_state_change_head_coverage_honesty; mod disorder_in_the_court_5955; mod divine_visitation_token_substitution; @@ -1184,6 +1194,8 @@ mod loop_shortcut; mod loop_shortcut_activation; mod loop_shortcut_mana_engine; mod loop_shortcut_offer_writer_census; +mod loop_shortcut_ranking; +mod loop_shortcut_seat_pin_census; mod lose_control_this_turn_delayed_trigger; mod lost_mine_fungi_cavern_duration_runtime; mod lost_mine_storeroom_targeting_runtime; diff --git a/crates/engine/tests/integration/natural_balance.rs b/crates/engine/tests/integration/natural_balance.rs index ca698ac20b..8fde3b6bce 100644 --- a/crates/engine/tests/integration/natural_balance.rs +++ b/crates/engine/tests/integration/natural_balance.rs @@ -477,3 +477,204 @@ fn natural_balance_collects_two_local_x_searches_before_one_shuffle_each() { "each accepted searcher must shuffle exactly once; a duplicate completion would add another shuffle" ); } + +/// **Row 2c′ — WIRE TIER.** CR 603.5 + CR 732.2a: two seats answering the SAME "may" +/// source inside ONE loop-detection window keep two independent journal entries, so no +/// seat's answer can fill another's slot and no two seats can manufacture a false +/// disagreement. +/// +/// # Why this board, and what it does and does not prove +/// +/// This is a production path, not a storage-contract unit: Natural Balance's scoped +/// acceptance cascade (`game::effects::scoped_library_search::advance_acceptance`) captures +/// ONE `source_id` from the pending ability and mints one +/// `WaitingFor::OptionalEffectChoice { player, source_id, .. }` per scoped seat, so both +/// answers reach the reducer's `DecideOptionalEffect` arm — the journal's only write site — +/// through `apply()`. Both land in ONE window: `OptionalEffectChoice` is a member of +/// `WaitingFor::is_forced_cascade_window`, and `apply_action`'s pre-action clear is skipped +/// for a forced window, so the ring (and with it the journal) is not cleared between them. +/// This row asserts that chain rather than assuming it: the two prompts are asserted to +/// carry the SAME `source_id` and DIFFERENT seats before either is answered. +/// +/// **NOT CLAIMED:** that the seat component changes an OFFER. That additionally requires +/// the publisher to publish a `MayChoice` point for this source in a bounded window, which +/// this board does not do. WHAT IS MEASURED IS THE TERMINAL STATE AND ONLY THAT: the final +/// assertion reads `runner.state().waiting_for` once, after the drive, so it says the drive does +/// not END on a `WaitingFor::LoopShortcut`. It CANNOT exclude an offer minted and cleared at an +/// earlier beat — no intermediate beat is sampled. The offer-level claim is +/// therefore unmeasured in either direction and this row does not make it. The pair key is +/// DEFENSE IN DEPTH PLUS A CODE DELETION, not a live-bug fix: the pin injector already +/// aborts a replay whose prompt recipient differs from the template owner, so a +/// source-only key's two harms are firewalled downstream even on a board that reached +/// them. What this row proves is the storage invariant, on a production path. +/// +/// # Discrimination +/// +/// Collapse the journal key to the bare `DecisionSlot` (keep both signatures; build the +/// key from `slot` alone in `record_loop_answer`/`loop_answer`) and the two writes land +/// in ONE entry: `loop_answers_recorded()` is 1, not 2, and the second write's differing +/// value latches `Conflicted`, so the per-seat lookups no longer hold either. The +/// cardinality assertion is value-independent and is asserted FIRST, so an empty journal — +/// what a missing `loop_detection` setting would produce — fails the row before any +/// content assertion can pass vacuously. +#[test] +fn natural_balance_two_scoped_seats_journal_one_may_source_under_two_independent_keys() { + use engine::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, MayChoiceOption, + }; + use engine::types::game_state::{LoopDetectionMode, YieldTarget}; + + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + + let mut spell = scenario.add_spell_to_hand_from_oracle( + P0, + "Natural Balance", + false, + NATURAL_BALANCE_ORACLE, + ); + spell.with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green, ManaCostShard::Green], + generic: 2, + }); + let natural_balance = spell.id(); + + // P0 sacrifices down to exactly five lands, so P0 is never a searcher and the two + // prompts below belong to the scoped seats alone. + let kept: Vec = (0..6) + .map(|_| scenario.add_basic_land(P0, engine::types::mana::ManaColor::Green)) + .collect(); + for _ in 0..4 { + scenario.add_basic_land(P1, engine::types::mana::ManaColor::Blue); + } + for _ in 0..3 { + scenario.add_basic_land(P2, engine::types::mana::ManaColor::White); + } + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Green, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Green, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ], + ); + + let mut runner = scenario.build(); + add_basic_land_to_library(runner.state_mut(), P1); + add_basic_land_to_library(runner.state_mut(), P2); + // The journal is written only while the detector samples; without this the board is + // identical and every journal assertion below would pass on an empty map. + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + + // ── the shared slot, captured at the prompt beats rather than reconstructed ── + // The CR 400.7 source half is still hand-rolled (`object_decision_source` is + // `pub(crate)`), but the CR 603.5 sub-index now routes through the engine's own + // `DecisionSlot::may`, so this key cannot drift from the publisher's. + let decision_source = |state: &GameState, id: ObjectId| { + DecisionSlot::may(YieldTarget::ThisObject { + source_id: id, + incarnation: Some(state.objects[&id].incarnation), + trigger_description: None, + }) + }; + + let outcome = runner.cast(natural_balance).resolve(); + assert!( + matches!( + outcome.final_waiting_for(), + WaitingFor::KeepExactPermanentsChoice { .. } + ), + "reach-guard: the six-land seat's exact-keeper choice is the beat that precedes the \ + scoped searches; got {:?}", + outcome.final_waiting_for() + ); + drop(outcome); + runner + .act(GameAction::ChooseKeptPermanents { + kept: kept[..5].to_vec(), + }) + .expect("five distinct controlled lands must be a legal exact keeper choice"); + + let WaitingFor::OptionalEffectChoice { + player: first_seat, + source_id: first_source, + .. + } = runner.state().waiting_for.clone() + else { + panic!( + "the first four-or-fewer-land player must receive the scoped optional search, got {:?}", + runner.state().waiting_for + ); + }; + let first_key = decision_source(runner.state(), first_source); + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("the first scoped seat may accept its library search"); + + let WaitingFor::OptionalEffectChoice { + player: second_seat, + source_id: second_source, + .. + } = runner.state().waiting_for.clone() + else { + panic!( + "every scoped seat is prompted before any library is exposed, got {:?}", + runner.state().waiting_for + ); + }; + let second_key = decision_source(runner.state(), second_source); + + // ── the multi-seat premise, asserted before it is relied on ── + assert_eq!( + first_source, second_source, + "CR 101.4: the scoped acceptance cascade prompts every seat for ONE source; two \ + source ids would make this row a two-key test and prove nothing about seats" + ); + assert_eq!( + first_key, second_key, + "one source and no intervening zone change ⇒ one CR 400.7 incarnation ⇒ one \ + DecisionSource, so the seat is the only axis separating the two entries" + ); + assert_ne!( + first_seat, second_seat, + "the two prompts must go to DIFFERENT seats, else there is no seat axis to test" + ); + + runner + .act(GameAction::DecideOptionalEffect { accept: false }) + .expect("the second scoped seat may decline its library search"); + + // ── cardinality first: this is the value-independent bar a collapsed key fails ── + assert_eq!( + runner.state().loop_answers_recorded(), + 2, + "two seats answering one source must occupy TWO (source, seat) keys. A journal \ + keyed by the source alone holds 1; an unsampled detector holds 0" + ); + assert_eq!( + runner.state().loop_answer(&first_key, first_seat), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "the accepting seat's own entry records Take" + ); + assert_eq!( + runner.state().loop_answer(&second_key, second_seat), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Decline + ))), + "the declining seat's own entry records Decline, uncorrupted by the other seat's \ + differing answer — under a source-only key this second write would instead latch \ + Conflicted over the first" + ); + + // ── the offer-mint non-claim, measured rather than asserted in prose. TERMINAL STATE ONLY: + // one read of `waiting_for` after the drive. It cannot exclude an offer minted and cleared + // at an earlier beat, and the rustdoc's non-claim is scoped to match. ── + assert!( + !matches!(runner.state().waiting_for, WaitingFor::LoopShortcut { .. }), + "this board journals two seats and does not END on a CR 732.2a offer, which is why \ + this row's claim stops at the journal" + ); +} diff --git a/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs b/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs index 931e7d12da..819453f8da 100644 --- a/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs +++ b/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs @@ -51,7 +51,13 @@ fn gunzip(gz: &[u8]) -> String { /// Load the realistic 4p dump's `["gameState"]` through the REAL production restore chokepoint /// `PersistedGameState::into_game_state` (the same path server `from_persisted` and WASM -/// `decode_restored_game_state` funnel through). The migration drops the primed loop sequence +/// `decode_restored_game_state` funnel through). The chokepoint now rehydrates the ChaCha20 +/// stream, which only `engine-wasm`'s `restore_game_state` used to do on its own — a load that +/// ENDED at the chokepoint, like this one, was left with a word-0 stream under this dump's saved +/// `rng_word_pos` of 291. WASM's own call is now an idempotent repeat. Callers may still diverge +/// afterwards: `GameSession::from_persisted` re-seeds and zeroes `rng_word_pos` with it, +/// discarding the saved position rather than resuming it as this load does. +/// The migration drops the primed loop sequence /// because the dump sits at empty-stack Priority (NOT a shortcut window), so the offer must be /// rebuilt by a live cast below. fn load_realistic_dump() -> GameState { diff --git a/crates/lobby-broker/src/protocol.rs b/crates/lobby-broker/src/protocol.rs index e00e578a68..bcc48dfb3e 100644 --- a/crates/lobby-broker/src/protocol.rs +++ b/crates/lobby-broker/src/protocol.rs @@ -43,6 +43,23 @@ pub enum ServerErrorCode { /// handshake. When making such changes, plan a deprecation window where /// both the old and new variants coexist, then bump and remove the old. /// +/// 31 — `WaitingFor::LoopShortcut` publishes the engine-issued `declaration`, and +/// `InteractionResponseSpec::Shortcut` publishes `preview`, the per-axis +/// consequence of the offered count. Both are `Option` and neither type sets +/// `deny_unknown_fields`, so a v30 peer still *parses* the frame — this is a +/// capability bump like 24, not a parse bump. UNLIKE 24, no pairing is left to +/// exercise the gap, so this entry names no silent-drop hazard. Full-game floors +/// are exact-match on both sides (`server_core::MIN_SUPPORTED_PROTOCOL == +/// PROTOCOL_VERSION`, and `MIN_SUPPORTED_SERVER_PROTOCOL` in +/// `client/src/adapter/ws-adapter.ts`), so a v31/v30 full-game pair is refused +/// at the handshake and never sends an action frame. The one-version window is +/// this file's `MIN_SUPPORTED_PROTOCOL` below, and it is lobby-only: +/// `DeclareShortcut` rides `ClientMessage::Action`, which `LobbyClientMessage` +/// has no variant for at all, and which `reject_if_disabled` in +/// `crates/phase-server/src/main.rs` answers under `ServerMode::LobbyOnly` with +/// an explicit rejection rather than a silent drop. The P2P games this broker +/// matchmakes are gated tighter still, on build-commit equality +/// (`check_build_commit`), not on a protocol window. /// 30 — Serialized player-action completion provenance and modal continuations. /// 29 — Added requester-correlated `ResolveAllRejected` response frames. /// 28 — Added native `ResolveAll` request/result frames. @@ -77,7 +94,7 @@ pub enum ServerErrorCode { /// payload; mulligan bottoming folded into a /// `MulliganDecisionPhase::BottomCards` sub-phase on /// `WaitingFor::MulliganDecision`. -pub const PROTOCOL_VERSION: u32 = 30; +pub const PROTOCOL_VERSION: u32 = 31; /// Minimum protocol version accepted by lobby-only brokers at the hello /// handshake. Lobby traffic has a one-version rollout window; full game servers @@ -414,12 +431,12 @@ mod tests { #[test] fn protocol_version_tracks_full_game_wire_additions() { - assert_eq!(PROTOCOL_VERSION, 30); + assert_eq!(PROTOCOL_VERSION, 31); // Lobby keeps its one-version rollout window; full-game servers stay // current-only (`server_core::MIN_SUPPORTED_PROTOCOL == PROTOCOL_VERSION`), // which is what refuses an older full-game peer whose GameState cannot // understand a success acknowledgment the submitting client awaits. - assert_eq!(MIN_SUPPORTED_PROTOCOL, 29); + assert_eq!(MIN_SUPPORTED_PROTOCOL, 30); } #[test] diff --git a/crates/phase-ai/src/policies/loop_shortcut.rs b/crates/phase-ai/src/policies/loop_shortcut.rs index 6038175d21..01ae2ce7ea 100644 --- a/crates/phase-ai/src/policies/loop_shortcut.rs +++ b/crates/phase-ai/src/policies/loop_shortcut.rs @@ -70,8 +70,13 @@ //! needing no crown — so a reject that ignored the count would be wrong for the class. Today's AI //! candidate generator only ever emits `UntilLethal`, but `Fixed(n)` is reachable through the //! public `GameAction` surface: `handle_declare_shortcut` moves `count` into the proposal with -//! ZERO validation (the fail-closed firewall validates only `template` pins, and is skipped -//! entirely when `template` is `None`). +//! ZERO validation (the fail-closed firewall validates only `template` pins, and it runs against +//! the RESOLVED template rather than the payload's: the handler shadows it with +//! `template.or_else(|| offer.declaration.cloned())` before the `match`, so a payload carrying +//! `None` against an offer that PUBLISHED a declaration reaches the `Some` arm and IS +//! pin-validated by `declaration_conforms`. The firewall is skipped only when the payload carried +//! none AND the offer published none — the arm that still refuses unless the proposer controls +//! the recorded loop period). //! //! ## Why the verdict reads `proposer` from the state, never `ctx.ai_player` //! @@ -138,6 +143,10 @@ impl TacticalPolicy for LoopShortcutPolicy { predicted_winner, schema, certificate, + // Scoring reads the offer's BOUND and its certificate, never its pins: the + // engine-published declaration is what the candidate already carries, so re-reading + // it here would score the same value twice. + declaration: _, } = &ctx.state.waiting_for else { return na(); @@ -439,6 +448,7 @@ mod tests { predicted_winner, certificate: cert(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; state } @@ -629,6 +639,7 @@ mod tests { max_iterations, ..Default::default() }, + declaration: None, }; state } @@ -649,6 +660,7 @@ mod tests { max_iterations, ..Default::default() }, + declaration: None, }; state } diff --git a/crates/phase-ai/src/projection.rs b/crates/phase-ai/src/projection.rs index a4d25687b0..f9fb4983cd 100644 --- a/crates/phase-ai/src/projection.rs +++ b/crates/phase-ai/src/projection.rs @@ -1218,6 +1218,7 @@ mod tests { per_cycle: None, }, schema: engine::analysis::decision_template::ShortcutDecisionSchema::default(), + declaration: None, }; let (_actor, action, is_policy_choice, _successor) = diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index ff0b940332..7de95de3ab 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -5256,6 +5256,7 @@ mod tests { per_cycle: None, }, schema: engine::analysis::decision_template::ShortcutDecisionSchema::default(), + declaration: None, }; assert_eq!( diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index 6259c1337e..f3e4e72aed 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -477,8 +477,10 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { // ("`count` is a small enum — nothing unbounded") was FALSE: `IterationCount::Fixed` // wraps an unbounded `u32` and IS the real DoS vector — bounded here as a coarse // WS-level belt (mirrors `ChooseManaColor.count`; the engine's MAX_SHORTCUT_CYCLES is - // the authoritative cap). The nested template vecs (a `Targets` pin's `Vec` - // and each `Scheduled` pin's schedule `Vec`) are bounded as DEFENSE-IN-DEPTH: the + // the authoritative cap). The nested template vecs (a `Targets` pin's `Vec`, + // each `Scheduled` pin's schedule `Vec`, and each schedule step's `Ranking` — three + // levels, not two, since a step's subject became a list) are bounded as + // DEFENSE-IN-DEPTH: the // 8 KB inbound WS frame cap (phase-server/src/main.rs:409/1420) already keeps a remote // nested payload to a few hundred structs, and this guard runs POST-deserialize // (client_message_wire_guard.rs:50), so it bounds downstream compute/clone work — not @@ -486,7 +488,7 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { // Exhaustive matches (no wildcard) force a future variant to be classified here. GameAction::DeclareShortcut { count, template } => { use engine::analysis::decision_template::{ - IterationCount, PinnedDecision, TargetPin, TargetSchedule, + IterationCount, PinnedDecision, Ranking, TargetPin, TargetSchedule, }; // Exhaustive (no wildcard): a future `IterationCount` count variant build-breaks // here so its wire bound is a conscious decision, not a silent gap. @@ -501,16 +503,36 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { PinnedDecision::Targets { targets, .. } => { bound_list("DeclareShortcut.template.targets", targets.len())?; for target in targets { + // CR 732.2a: each schedule STEP now carries a `Ranking` — its + // own `Vec` — so the outer schedule bound + // no longer covers the whole payload. Every arm bounds its + // rankings, INCLUDING `Constant`: it was a no-op only while it + // carried no vector, and leaving it out would make the one arm + // a hostile client can send unbounded. `Ranking::iter` is the + // newtype's length surface (the field is private). + let bound_ranking = |ranking: &Ranking| { + bound_list( + "DeclareShortcut.template.ranking", + ranking.iter().count(), + ) + }; match target { + TargetPin::Scheduled(TargetSchedule::Constant(r)) => { + bound_ranking(r)?; + } TargetPin::Scheduled(TargetSchedule::RoundRobin(v)) => { bound_list("DeclareShortcut.template.schedule", v.len())?; + for ranking in v { + bound_ranking(ranking)?; + } } TargetPin::Scheduled(TargetSchedule::Piecewise(v)) => { bound_list("DeclareShortcut.template.schedule", v.len())?; + for (_, ranking) in v { + bound_ranking(ranking)?; + } } - TargetPin::Scheduled(TargetSchedule::Constant(_)) - | TargetPin::ByIdentity(_) - | TargetPin::Player(_) => {} + TargetPin::ByIdentity(_) | TargetPin::Player(_) => {} } } } diff --git a/crates/server-core/src/protocol.rs b/crates/server-core/src/protocol.rs index 115af10106..db11691d25 100644 --- a/crates/server-core/src/protocol.rs +++ b/crates/server-core/src/protocol.rs @@ -2324,8 +2324,8 @@ mod tests { } #[test] - fn protocol_version_is_30() { - assert_eq!(PROTOCOL_VERSION, 30); + fn protocol_version_is_31() { + assert_eq!(PROTOCOL_VERSION, 31); } /// The bump alone is inert — a version number nobody enforces prevents no @@ -2335,7 +2335,7 @@ mod tests { /// understand. /// /// REVERT-PROBE: relax to `PROTOCOL_VERSION - 1` — the exact regression - /// this guards — and this test reds while `protocol_version_is_30` stays + /// this guards — and this test reds while `protocol_version_is_31` stays /// green, which is why the two are separate assertions. #[test] fn full_game_floor_is_current_only_not_a_rollout_window() { diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index 58a1132623..f9aebd2542 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -926,6 +926,10 @@ impl GameSession { /// - card characteristics from the card database /// - `log_player_names` from the persisted display names /// - `rng` re-seeded with fresh randomness + /// + /// The fresh seed also resets `rng_word_pos` — a `#[serde(default)]` field, not a skipped + /// one. It is the saved high-water of the stream the old seed generated, and has no + /// meaning against the new one. pub fn from_persisted(ps: PersistedSession, db: &CardDatabase) -> Result { let mut state = ps.state.into_game_state(); state @@ -945,6 +949,14 @@ impl GameSession { let fresh_seed: u64 = rand::rng().random(); state.rng_seed = fresh_seed; state.rng = rand_chacha::ChaCha20Rng::seed_from_u64(fresh_seed); + // A fresh stream starts at word 0, so the saved high-water — which indexes into the + // OLD keystream and is meaningless against this one — has to go with the old seed. + // Leaving it behind is what broke restore: the chokepoint's `rehydrate_rng` had put + // live and high-water in agreement, the re-seed above rewound live to 0, and the next + // `capture_rng_word_pos` (`game::library::resolve_and_apply_library_shuffle` performs + // one before every shuffle) `.expect`-panicked `HighWaterRegression`. Same three- + // statement resume policy as `engine-wasm`'s `resume_multiplayer_host_state`. + state.rng_word_pos = 0; finalize_public_state(&mut state); // Re-bind rather than trusting any id the blob carries, on the same // principle that `restore_session` re-stamps `hosting` and revokes an @@ -4783,6 +4795,114 @@ mod tests { assert_eq!(session.state.debug_permitted, BTreeSet::from([PlayerId(0)])); } + /// The high-water this row plants before persisting. Deliberately NOT block-aligned + /// (ChaCha20 block 18, word 3), so a fast-forward that only lands on block boundaries + /// cannot pass by accident. `set_word_pos`/`get_word_pos` round-trip any position exactly. + const SAVED_WORD_POS: u128 = 291; + + /// `from_persisted` re-seeds `rng` from fresh entropy — deliberately, so restored games do + /// not all share one deterministic sequence — and must drop `rng_word_pos` in the same step. + /// A fresh stream starts at word 0, so a surviving high-water leaves `advance_rng_high_water` + /// guarding a position the live cursor is BEHIND, and the next `capture_rng_word_pos` + /// `.expect`-panics `HighWaterRegression`. `resolve_and_apply_library_shuffle` performs that + /// capture before every shuffle, so this bit every restored server game that had shuffled. + /// + /// Non-vacuity: the premise assertions measure that the blob really carried a NON-ZERO + /// position AND that the engine chokepoint really resumed it, so the `== 0` below can only be + /// this function discarding it — not serde dropping a field that was never there. + /// Discrimination: deleting `state.rng_word_pos = 0` reds the high-water assertion with + /// `291 != 0` and panics the shuffle underneath it. + #[test] + fn restore_reseeds_the_rng_and_drops_the_saved_stream_position() { + let db = engine::database::CardDatabase::default(); + let mut mgr = SessionManager::new(); + let code = single_ai_opponent_game(&mut mgr); + + let session = mgr.sessions.get_mut(&code).unwrap(); + // Guard, not an assumption: if setup ever consumes past the planted position the + // capture below would panic in the fixture rather than in the code under test. + assert!( + session.state.rng_word_pos < SAVED_WORD_POS, + "fixture premise: setup must leave the high-water below the planted position", + ); + // Plant it the way a shuffle does — advance the live cursor, then promote it through + // the engine's own monotonic primitive. Never by writing the field. + session.state.rng.set_word_pos(SAVED_WORD_POS); + session.state.capture_rng_word_pos(); + let saved_seed = session.state.rng_seed; + assert_eq!( + session.state.rng_word_pos, SAVED_WORD_POS, + "fixture premise: a NON-ZERO saved high-water, or this row measures nothing", + ); + + let json = serde_json::to_string(&mgr.sessions.get(&code).unwrap().to_persisted()).unwrap(); + let blob: crate::persist::PersistedSession = serde_json::from_str(&json).unwrap(); + + // Premise 2, measured: the position survives disk AND the chokepoint resumes it. + let chokepoint_only = blob.state.clone().into_game_state(); + assert_eq!( + chokepoint_only.rng_word_pos, SAVED_WORD_POS, + "premise: the persisted blob carries the position across disk", + ); + assert_eq!( + chokepoint_only.rng.get_word_pos(), + chokepoint_only.rng_word_pos, + "premise: `into_game_state` resumes it, so a zero below is this session's own \ + policy and not a lost field", + ); + + let mut restored = + GameSession::from_persisted(blob, &db).expect("supported persisted format config"); + + assert_ne!( + restored.state.rng_seed, saved_seed, + "the fresh-seed policy must survive this fix", + ); + assert_eq!( + restored.state.rng_word_pos, 0, + "a freshly seeded stream must not inherit the old stream's high-water", + ); + assert_eq!( + restored.state.rng.get_word_pos(), + restored.state.rng_word_pos, + "live cursor and persisted high-water must agree after restore", + ); + + assert!( + !restored.state.players[0].library.is_empty(), + "reach-guard: the shuffle below must have a library to act on", + ); + engine::game::library::resolve_and_apply_library_shuffle( + &mut restored.state, + PlayerId(0), + &mut Vec::new(), + ) + .expect("a restored session must be able to shuffle"); + } + + /// Paired reach-guard. It does NOT red when the fix is reverted, and is not meant to: its job + /// is to prove the panic is REACHABLE through the production shuffle seam on a restored + /// session, so the row above's "the shuffle succeeds" is evidence rather than a statement + /// about a call that could never have failed. + #[test] + #[should_panic(expected = "HighWaterRegression")] + fn a_restored_session_that_kept_the_saved_stream_position_panics_on_its_next_shuffle() { + let db = engine::database::CardDatabase::default(); + let mut mgr = SessionManager::new(); + let code = single_ai_opponent_game(&mut mgr); + let mut restored = round_trip_through_disk(mgr.sessions.get(&code).unwrap(), &db); + + // Re-create the pre-fix pairing on a RESTORED state: a fresh word-0 stream under a + // surviving high-water. Exactly what `from_persisted` used to hand back. + restored.state.rng_word_pos = SAVED_WORD_POS; + engine::game::library::resolve_and_apply_library_shuffle( + &mut restored.state, + PlayerId(0), + &mut Vec::new(), + ) + .expect("unreachable: the capture panics first"); + } + // CR 107.1c: "remove any number of counters" — a human's intermediate submit // ("remove 2 of 3") is not one of the coarse AI candidates (remove-none / // remove-all), but the engine validates the full legal space directly. diff --git a/crates/server-core/tests/game_action_payload_guard.rs b/crates/server-core/tests/game_action_payload_guard.rs index e6375bb114..d007511207 100644 --- a/crates/server-core/tests/game_action_payload_guard.rs +++ b/crates/server-core/tests/game_action_payload_guard.rs @@ -2,8 +2,9 @@ //! `server_core::game_action_payload_guard`). use engine::analysis::decision_template::{ - DecisionGroupKey, DecisionKind, DecisionSlot, DecisionTemplate, IterationCount, - MayChoiceOption, PinnedDecision, ReplayMode, TargetPin, TargetSchedule, + AnnouncementSubject, DecisionGroupKey, DecisionKind, DecisionSlot, DecisionTemplate, + IterationCount, MayChoiceOption, PinnedDecision, Ranking, ReplayMode, TargetPin, + TargetSchedule, }; use engine::types::ability::{ Comparator, TriggerBaseSetInstanceRef, TriggerDefinitionOccurrenceRef, @@ -440,9 +441,9 @@ fn rejects_over_cap_shortcut_schedule() { decisions: vec![PinnedDecision::Targets { slot, targets: vec![TargetPin::Scheduled(TargetSchedule::RoundRobin(vec![ - src; - MAX_ACTION_LIST_LEN + 1 - ]))], + Ranking::one(AnnouncementSubject::Object(src)); + MAX_ACTION_LIST_LEN + 1 + ]))], }], replay: ReplayMode::Static, key: DecisionGroupKey { @@ -456,3 +457,117 @@ fn rejects_over_cap_shortcut_schedule() { "an over-cap loop-shortcut schedule vec must be rejected (nested memory bound)" ); } + +/// **Row R1-e — the arm the guard used to skip.** Each schedule step now carries a `Ranking`, +/// its own `Vec`, so the outer schedule bound no longer covers the whole +/// payload — and `Constant` newly carries a vector where it previously carried none, which +/// made it the ONE `Scheduled` arm a hostile client could send unbounded. +/// +/// # Non-vacuity / discrimination +/// +/// Every other list in each fixture is deliberately in-bounds (one decision, one target, and +/// for the nested arm a schedule of exactly `MAX_ACTION_LIST_LEN` steps), so ONLY the ranking +/// bound can reject. The paired positive is an in-bounds `Constant` ranking that must be +/// ACCEPTED, without which a guard that refused every `DeclareShortcut` would pass. +/// +/// REVERT-PROBES: (a) drop the `Constant` arm's `bound_ranking` ⇒ the first assertion FAILS; +/// (b) drop the per-step `bound_ranking` loop in the `RoundRobin` arm ⇒ the nested assertion +/// FAILS while `rejects_over_cap_shortcut_schedule` above stays green, because that row's +/// rankings are all one element. +#[test] +fn rejects_over_cap_shortcut_ranking_on_every_scheduled_arm() { + let src = YieldTarget::AllCopies { + card_id: CardId(1), + trigger_description: None, + }; + let slot = DecisionSlot { + source: src.clone(), + index: 0, + }; + // `Ranking::new` refuses duplicates, so the entries must be distinct. `PlayerId` is a + // `u8` and the cap is 10_000, so seats cannot supply enough of them — card identities can. + let ranking_of = |len: usize| -> Ranking { + let subjects: Vec = (0..len as u64) + .map(|i| { + AnnouncementSubject::Object(YieldTarget::AllCopies { + card_id: CardId(i), + trigger_description: None, + }) + }) + .collect(); + Ranking::new(subjects).expect("distinct subjects are a legal ranking") + }; + let declare = |targets: Vec| GameAction::DeclareShortcut { + count: IterationCount::UntilLethal, + template: Some(DecisionTemplate { + owner: PlayerId(0), + decisions: vec![PinnedDecision::Targets { + slot: slot.clone(), + targets, + }], + replay: ReplayMode::Static, + key: DecisionGroupKey { + sources: vec![], + kind: DecisionKind::LoopChoice, + }, + }), + }; + + // ── the `Constant` arm: the payload the pre-parameterization guard could not see ── + assert!( + guard_game_action_payload(&declare(vec![TargetPin::Scheduled( + TargetSchedule::Constant(ranking_of(MAX_ACTION_LIST_LEN + 1)) + )])) + .is_err(), + "an over-cap `Constant` ranking must be rejected — this arm carries a Vec now" + ); + + // ── the NESTED arm on the OTHER two variants: the outer schedule is in bounds, so only + // the per-step ranking bound can refuse. The over-cap step is LAST, so a guard that + // inspected only the first step would accept. + // + // The row's shape is "an in-bounds schedule of over-cap rankings"; it is not + // materialized at MAX × (MAX+1) because that literal reading is 10^8 subjects (~5 GB) + // and would measure the allocator, not the guard. Three steps discriminate identically. + for (label, sched) in [ + ( + "RoundRobin", + TargetSchedule::RoundRobin(vec![ + ranking_of(1), + ranking_of(2), + ranking_of(MAX_ACTION_LIST_LEN + 1), + ]), + ), + ( + "Piecewise", + TargetSchedule::Piecewise(vec![ + (0, ranking_of(1)), + (5, ranking_of(MAX_ACTION_LIST_LEN + 1)), + ]), + ), + ] { + assert!( + guard_game_action_payload(&declare(vec![TargetPin::Scheduled(sched)])).is_err(), + "{label}: an over-cap ranking nested inside an in-bounds schedule must be \ + rejected — the outer `bound_list` passes, so only the per-step bound can refuse" + ); + } + + // ── the paired positives: in-bounds payloads on every arm ARE accepted ── + assert!( + guard_game_action_payload(&declare(vec![TargetPin::Scheduled( + TargetSchedule::Constant(ranking_of(MAX_ACTION_LIST_LEN)) + )])) + .is_ok(), + "a ranking at exactly the cap is honest traffic and must pass — without this the row \ + would be satisfied by a guard that rejected every declaration" + ); + assert!( + guard_game_action_payload(&declare(vec![TargetPin::Scheduled( + TargetSchedule::RoundRobin(vec![ranking_of(3), ranking_of(4)]) + )])) + .is_ok(), + "and an in-bounds nested schedule passes, so the nested refusals above are the LENGTH \ + and not the nesting" + ); +} diff --git a/probe-pin/engine-census.toml b/probe-pin/engine-census.toml new file mode 100644 index 0000000000..40f4643a56 --- /dev/null +++ b/probe-pin/engine-census.toml @@ -0,0 +1,245 @@ +# The engine-census pin: the offer-writer census's own claims, MEASURED rather than restated. +# +# Why this manifest exists: this census's module header carried "22 production + 14 test sites" +# while its assert read `(22, 21)`. The prose was hand-corrected across five adjudications +# (12 => 13 => 14 => 16 => 17 => 21) and drifted out of sync with its own live assert anyway. +# Rows P1-P4 below anchor `right: (22, 21)`, and P1 anchors the adjudication sentence inside the +# assert, so moving either without regenerating this block turns `probe-pin check` red. +# +# mode = "runtime-read" is the ENABLING CONDITION, not a formality: `census()` reads its sources +# with `std::fs::read_to_string` under `Path::new(env!("CARGO_MANIFEST_DIR")).join("src")`. `env!` +# bakes the PATH at compile time, the CONTENT is read at execution -- so a bind-mounted mutant is +# visible WITHOUT rebuilding the engine (measured end-to-end). Had the census used `include_str!`, +# probe-pin could not pin it at all. +# +# The walk roots are `crates/engine/src` and `crates/phase-ai/src`. NOTHING in this commit is +# written into either of them, and this manifest is deliberately NOT stored under them: a +# documentation-adding change over the very constructs a census counts is how a prose mention +# becomes a counted site. ⚠ State the REACHABLE form of that hazard, not a bigger one: `census()` +# collects through `rs_files()`, whose predicate is `extension == "rs"`, so a `.toml` under a walk +# root is never read at all -- the walk roots hold non-`.rs` files (insta snapshots, docs) that the +# green assert does not count. NO count of them is quoted here, for the same reason the workflow +# count below is omitted: nothing turns red when it moves, and `rs_files()`'s predicate is the +# whole proof. The real hazard is this file's TEXT being moved under a walk root **into a `.rs` file**, +# where P1's `find` string alone would add a production hit and move the number it pins. +# +# NOTE ON `assert_count`: it counts RAW substring occurrences over the whole mutant text +# (`str::matches`), including comments. The census counts NON-COMMENT LINES. The two numbers +# differ on purpose -- P3 asserts 4 raw occurrences for a mutation the census must see as +1. +# +# ============================ WHERE THIS IS ENFORCED, AND WHERE IT IS NOT ==================== +# ENFORCED: locally, by the Tiltfile's `probe-pin-census` resource, on every edit under +# `crates/engine/src` or `crates/phase-ai/src` -- in ANY running Tilt session, including a plain +# `tilt up`. MEASURED against the live session, because the weaker reading is the tempting one: +# the `lint` group gates `auto_init` ONLY, i.e. whether the resource also runs once at startup. +# It does not gate the file watch. RE-RUNNABLE CHECK rather than a quoted timing: in a `tilt up` +# started with no profile, `tilt get filewatch` lists `local:probe-pin-check` -- same +# `auto_init = 'lint' in enabled` shape as this resource -- and for any such resource the +# FileWatch `lastEventTime` is followed within milliseconds by a `buildHistory` entry, i.e. a +# file-change-triggered build on a resource that never auto-inits. Deliberately NO session +# timestamps are quoted: they die with the Tilt session, and the recipe reproduces without them. +# The Tiltfile header states the same rule in prose: "opt-in groups just control which auto-start". +# NOT ENFORCED IN CI. MEASURED, not assumed: ZERO files under `.github/workflows/` reference +# probe-pin (positive control: `clippy` matches at least one workflow, so the instrument +# discriminates and the zero is a result). NO workflow COUNT is quoted here: nothing in this +# repo turns red when a workflow is added, and a zero that went stale would go stale in the +# SAFE direction -- a workflow that DID reference probe-pin would make this pin more enforced +# than this comment claims, not less. CI enrollment is not merely undone, it is POLICY-BLOCKED +# -- `.agents/pr-review-policy.toml` `[hard_stops]` lists `.github/workflows/**`, so an agent +# change cannot add it. +# READ THAT AS A LIMIT, NOT AS COVERAGE. A merged pin is not a CI-enforced pin. If the local +# Tilt resource is not running, these claims are measured by NOTHING -- which is why the +# resource is an AUTOMATIC trigger and not a manual knob (see the Tiltfile comment there). +# This manifest also does NOT retire the OUT-OF-TREE `.r3-drift-gate.sh` (it lives in the working +# run directory and is not committed, so a reader will not find it here): FOUR of its rows (seat-pin assert +# hash, the `WaitingFor` variant set, the CR 603.5 producer pin, clear-site counts) are still +# uncovered here. This pins the INTEGRATION-venue census; the lib-venue CR 603.5 producer +# census is NOT pinned and remains covered by the shell drift-gate. +# ============================================================================================ +version = 1 + +[target] +mode = "runtime-read" +package = "phase-engine" # the PACKAGE; the lib target is named `engine` +test = "integration" +# Selects exactly ONE test. MEASURED, NOT ENFORCED -- the same distinction this file draws for CI +# above, drawn here because the opposite reading was written first and was WRONG. Nothing in the +# tool compels the "one": `DigestInput` (`crates/probe-pin/src/block.rs`) carries the [target] +# TABLE, the probes, the control, the projections and the instruments -- not the number of tests +# SELECTED -- so a filter that grew to match a second PASSING test yields `selected: 2` with +# `probe-pin check` still exit 0 and the digest UNMOVED (refuted by experiment; the claim that such +# a filter "would change the run and move the digest" was false). What IS enforced is the EXECUTION +# FLOOR in `verdict.rs`: `passed + failed == 0` aborts, i.e. AT LEAST one test body must have run +# (`NothingRan::NoneSelected` when the filter matches nothing, `AllSkipped` under an `#[ignore]`). +# One-ness is therefore a property of the MATCH SET, not of the string. Be precise about which, +# because the first correction here was wrong in the other direction: the filter STRING **is** +# digest-covered -- `DigestInput` carries `&manifest.target`, and `filter` is a field of that table, +# so editing the string turns the pin RED even when every verdict is identical +# (`crates/probe-pin/tests/pure_logic.rs::digest_target` asserts exactly this). What nothing guards +# is the string being left AS AUTHORED while its matched set GROWS -- a newly added test whose NAME +# happens to contain it. That is the unguarded direction, and it is the only one. +# No test-count denominator is quoted either: the binary's test population is a live external +# number nothing here turns red on. +# Deliberately NOT the whole census module: the +# sibling test `the_cfg_scope_classifier_sees_four_foreign_forms_...` builds its input as an +# in-binary String, so no bind-mount can reach it and no row here may imply it was pinned. +filter = "the_loop_shortcut_offer_writer_surface_is_pinned" +filter_match = "substring" +timeout_secs = 120 + +[output] +file = "crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs" +marker = "PROBE-PIN" + +[[probe]] +id = "P0_control" +claim = "no mounts, unmodified tree -- the instrument itself" + [probe.expect] + outcome = "pass" + +[[probe]] +id = "P1_production_site_removed" +claim = "the production half fails DOWNWARD, and the pinned tuple + adjudication prose are what they say" + [[probe.mutation]] + kind = "replace" + file = "crates/engine/src/game/scenario.rs" + find = " WaitingFor::LoopShortcut { .. } => \"LoopShortcut\",\n" + replace = "" + [[probe.assert_count]] + file = "crates/engine/src/game/scenario.rs" + text = "WaitingFor::LoopShortcut {" + count = 0 + [probe.expect] + outcome = "fail" + anchor = ["left: (21, 21)", + "right: (22, 21)", + "THE TEST HALF HAS BEEN ADJUDICATED FIVE TIMES"] + +[[probe]] +id = "P2_test_site_removed" +claim = "the cfg(test) half is pinned INDEPENDENTLY of the production half" + [[probe.mutation]] + kind = "replace" + file = "crates/phase-ai/src/projection.rs" + find = " state.waiting_for = WaitingFor::LoopShortcut {\n" + replace = "" + [[probe.assert_count]] + file = "crates/phase-ai/src/projection.rs" + text = "WaitingFor::LoopShortcut {" + count = 1 + [probe.expect] + outcome = "fail" + anchor = ["left: (22, 20)", "right: (22, 21)"] + +[[probe]] +id = "P3_walk_reaches_phase_ai_and_skips_comments" +claim = "the phase-ai root is walked, zero-hit files are visited, and COMMENT lines are not counted" + [[probe.mutation]] + kind = "prepend" + files = ["crates/phase-ai/src/lib.rs"] + text = "let _pp = WaitingFor::LoopShortcut { proposer };\n// pad WaitingFor::LoopShortcut { proposer };\n// pad WaitingFor::LoopShortcut { proposer };\n// pad WaitingFor::LoopShortcut { proposer };\n" + repeat = 1 + # FOUR raw occurrences in the mutant; the census must count ONE. That gap IS the claim. + [[probe.assert_count]] + file = "crates/phase-ai/src/lib.rs" + text = "WaitingFor::LoopShortcut {" + count = 4 + [probe.expect] + outcome = "fail" + anchor = ["left: (23, 21)", "right: (22, 21)"] + +[[probe]] +id = "P4_counting_is_per_line" +claim = "the count sums LINES, not files -- two planted lines in one file move it by two" + [[probe.mutation]] + kind = "prepend" + files = ["crates/engine/src/lib.rs"] + text = "let _pp = WaitingFor::LoopShortcut { proposer };\n" + repeat = 2 + [[probe.assert_count]] + file = "crates/engine/src/lib.rs" + text = "WaitingFor::LoopShortcut {" + count = 2 + [probe.expect] + outcome = "fail" + anchor = ["left: (24, 21)", "right: (22, 21)"] + +[[probe]] +id = "P5_relocation_preserves_the_count" +claim = "the per-file multiset is pinned INDEPENDENTLY of the total -- the relocation the count cannot see" + [[probe.mutation]] + kind = "replace" + file = "crates/engine/src/game/scenario.rs" + find = " WaitingFor::LoopShortcut { .. } => \"LoopShortcut\",\n" + replace = "" + [[probe.mutation]] + kind = "prepend" + files = ["crates/engine/src/game/interaction.rs"] + text = "let _pp = WaitingFor::LoopShortcut { proposer };\n" + repeat = 1 + [[probe.assert_count]] + file = "crates/engine/src/game/scenario.rs" + text = "WaitingFor::LoopShortcut {" + count = 0 + [[probe.assert_count]] + file = "crates/engine/src/game/interaction.rs" + text = "WaitingFor::LoopShortcut {" + count = 6 + [probe.expect] + outcome = "fail" + # The integer in the second anchor is a COUNT, not a line number -- see the DISCLOSURE in the + # census file's own prose. No lint distinguishes the two in that slot. + anchor = ["the COUNT can be preserved by a move that relocates a writer", + "(\"engine/src/game/interaction.rs\", 6)"] + +[[probe]] +id = "P6_second_validate_pins_consumer" +claim = "pin-VALUE legality has exactly ONE production consumer" + [[probe.mutation]] + kind = "prepend" + files = ["crates/engine/src/game/scenario.rs"] + text = "let _pp = validate_pins(schema, template, 1, state);\n" + repeat = 1 + [[probe.assert_count]] + file = "crates/engine/src/game/scenario.rs" + text = "validate_pins(" + count = 1 + [probe.expect] + outcome = "fail" + anchor = ["expected `validate_pins(` to appear in production exactly twice"] + +[[probe]] +id = "P7_coverage_half_unpaired" +claim = "the one consumer runs the COVERAGE half too -- adjacency is load-bearing" + [[probe.mutation]] + kind = "replace" + file = "crates/engine/src/analysis/decision_template.rs" + find = " predictability_gate(template, &required).is_ok()\n && validate_pins(schema, template, validated_range, state).is_ok()\n" + replace = " predictability_gate(template, &required).is_ok()\n // probe pad\n // probe pad\n // probe pad\n && validate_pins(schema, template, validated_range, state).is_ok()\n" + [[probe.assert_count]] + file = "crates/engine/src/analysis/decision_template.rs" + text = "// probe pad" + count = 3 + [probe.expect] + outcome = "fail" + anchor = ["validating pin VALUES without also running"] + +[[probe]] +id = "P8_authority_call_site_removed" +claim = "every declare-time site routes through the shared authority" + [[probe.mutation]] + kind = "replace" + file = "crates/engine/src/game/engine.rs" + find = " crate::analysis::decision_template::declaration_conforms(\n" + replace = "" + # RAW occurrences: 4 in the pristine file (one is a `///` line, one is cfg(test)-scoped), so 3 + # after the drop. The census's PRODUCTION count for the same file goes 2 -> 1. Two different + # instruments, two different numbers, both stated. + [[probe.assert_count]] + file = "crates/engine/src/game/engine.rs" + text = "declaration_conforms(" + count = 3 + [probe.expect] + outcome = "fail" + anchor = ["expected 1 definition + 3 call sites", + "(\"engine/src/game/engine.rs\", 1)"] diff --git a/scripts/check-protocol-version.mjs b/scripts/check-protocol-version.mjs index 3fad9222e1..64ed2758b9 100644 --- a/scripts/check-protocol-version.mjs +++ b/scripts/check-protocol-version.mjs @@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const EXPECTED_PROTOCOL_VERSION = 30; +const EXPECTED_PROTOCOL_VERSION = 31; function extractVersion(source, pattern, label) { const match = source.match(pattern);