diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 9fd64dde..f0c4a8b6 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,27 @@ open mode gates. ## Current checkpoint and work +**Basic native Mirror/Stone — 24 October. Prototyped / Partial.** +Bare Basic Ecaz/Moritani families, including selected Ix, now admit the physical +Mirror and original Stone through existing native resolution. Preserve fixed +Ecaz accounting, actual allied typed pools, copy-first independent choices and +physical winner retention. Basic E3 phase-module guards remain; existing +Advanced permissions do not expand. + +Disjoint source/control lanes, bounded review and owner integration pass +**17 files130/130**, types/lint/build and six actual gameplay/table-rendering +journeys. Both coalition leads and an actual Moritani combatant copy Tooth and +Stone; the original Ixian physical owner chooses6/9 undialed counters, and +Moritani's five physical counters beat four despite the opposing dial9. +Genuine six-native setup/alliance/battle admission retains explicit conserved +board/budget/card staging. Twelve existing-profile paths accept36 proposals +plus256 separate native continuations. Updated legacy fixtures close actual +final-priority controls; two old allocation-guard tests now explicitly model +unstamped battles. [Source, proof and limits](MIRROR_WEAPON_ENGINE_AUDIT.md#basic-native-e3-admission--24-october-2026). +Optional combinations, complete modes, full AI work and comprehensive/browser/ +deployed acceptance remain unfinished. No saved-room/D1/reset/restart/deployment +operation. + **Owned Ghola movement preparation — 24 October. Prototyped / Partial.** Held physical Ghola and known/paid Box recovery now prepare exact force returns before an unused affordable shipment and qualifying ground move. Keep normal diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 1b722e6d..9a651d84 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,35 @@ # Dune implementation status +## 24 October 2026 — Basic native Mirror and Stone + +Bare Basic native E3 with CHOAM/Richese, including selected Ix, now uses original +Mirror/Stone resolution. Actual fixed Ecaz strength/losses, variable allied +typed pools, public-only admission, physical-owner allocation and later native +cleanup remain separate. Copy-first and user-selected physical winner retention +are unchanged. Basic E3 phase modules remain guarded; existing Advanced no-Ix +phase-module permission is retained. + +Disjoint source/control lanes, bounded review and owner integration pass17 files +130/130, types/lint/build and six gameplay/GameTable SSR programmes across both +coalition leads and an actual Moritani combatant. Copied Tooth/Stone remain +independent,6/9 physical choices belong to Ix, and five Moritani counters beat +four opposing physical counters despite total dial9. Native setup/alliance/ +battle admission is genuine; conserved battle-board, thirty-spice and original +card staging is explicit. Twelve minimal-profile continuations accept36 actions +plus256 separate native steps. Rendered inline choices and actual event actions +are distinguished from direct physical-allocation component callback proof. + +The initial union exposed outdated final-priority fixture continuations and +old precommit expectations. A committed-profile probe confirms the old Mirror +failure predates this admission. Actual final Ready controls now precede result +assertions; two conservative guard tests explicitly model unstamped legacy +battles. New stamped cases retain real count commitments. A reserved lint +variable is renamed; final frozen proof passes. +[Authority, exact evidence and unfinished scope](MIRROR_WEAPON_ENGINE_AUDIT.md#basic-native-e3-admission--24-october-2026). +No full mode/combination, browser/deployed or comprehensive assurance certificate, +AI strategy/calibration or saved-room/D1/reset/restart/deployment operation. + + ## 24 October 2026 — Owned Ghola movement preparation Basic movement witnesses use current physical Ghola force choices and exact diff --git a/docs/MIRROR_WEAPON_ENGINE_AUDIT.md b/docs/MIRROR_WEAPON_ENGINE_AUDIT.md index 19a9f3ec..ed9eda31 100644 --- a/docs/MIRROR_WEAPON_ENGINE_AUDIT.md +++ b/docs/MIRROR_WEAPON_ENGINE_AUDIT.md @@ -1,9 +1,88 @@ # Mirror Weapon: source and engine integration audit -Audited 6 September 2026 as a proposed contract. The 28 September bounded runtime now admits canonical Mirror in CHOAM/Richese-deck classic/CHOAM/Richese battles without optional combined modules; the hook map and former blockers below describe the earlier audit, not current inactive status. Richese expansion starts remain gated. +The [24 October Basic native follow-up](#basic-native-e3-admission--24-october-2026) is the current bare-profile boundary. Older September audits, blocker descriptions and start restrictions below are dated evidence. Complete expansion rules and publication acceptance remain gated. **28 September ruling and runtime:** the user selected ordinary winner retention for the physical Mirror even when its copy activates Tooth or Artillery. This is an explicit product interpretation, not a located GF9 clarification. `game/effective-weapons.ts` keeps physical identity apart from copied attacks; the engine now connects copied Tooth/Stone choices, Stone comparison, custody, battle controls and legal AI in the bounded configuration. +## Basic native E3 admission — 24 October 2026 + +**Prototyped / Partial.** Bare Basic CHOAM/Richese plus Ecaz/Moritani now uses +the same canonical physical Mirror and original Stone handling as supported +Advanced tables. Selected Ix can remain present with its real Cyborg/Suboid +pools. This supersedes the older Advanced-only native-E3 boundary below. +The public profile admits the configured families without inspecting hands or +sealed plans. Homeworlds, Nexus, Leader Skills, Discoveries and active Sandtrout +remain guarded. Basic E3 with Tech Tokens or Stronghold Cards also stays outside +this slice; existing Advanced E3 without Ix retains its prior phase-module +permission. Mixed Advanced E3/Ix phase modules keep their existing guard. + +The actual battle actors, native fixed Ecaz commitment and variable allied +forces remain separate. Copied Tooth and Stone choose before their originals, +with original physical IDs in sealed/revealed plans. An ambiguous Stone total +is committed by the original physical force owner, even when Ecaz selected its +own leader and plan. Keep Basic zero support, actual typed casualties, later +Ix substitution and native cleanup. No new numerical or timing ruling is made: +printed copy-first, the existing provisional allocation procedure and the +user-selected physical-Mirror winner retention stay in force. + +New fixtures start through genuine public Basic native setup, original cards, +a real Nexus alliance and actual battle admission. They explicitly inherit a +conserved controlled battle board, thirty-spice budget and original-card +transfers; they are not unstaged acquisition or complete-game evidence. Both +coalition leads and an actual Moritani combatant receive their own Mirror. +Native controls remain explicit and persistent; minimal profiles use the +current public profile and own legal plan/decision views. + +### Basic native source and verification + +The original six-faction Basic setup retains Ecaz, Ixians, CHOAM, Richese, +Moritani and Tleilaxu, original native components and the actual Ecaz/Ix alliance. +Before real admission, the source explicitly conserves five Ecaz and eight +allied Ixian counters (including two Cyborgs), or replaces the opposing CHOAM +army with five conserved Moritani counters. It inherits the labelled thirty-spice +controlled budget and moves original physical cards between their actual +cache/deck/hand locations. Neither the phase, alliance, battle, plan nor finished +outcome is installed. This is genuine setup plus controlled rule-case staging, +not an unstaged full game or a naturally played Mirror acquisition. + +Both coalition leads copy Tooth before the original, keep physical Mirror after +winning and pay no Basic support. Real leader deaths supply their printed +bounty. Copied Stone independently chooses before its original; fixed Ecaz3 +and undialed2 remain separate from the allied pool. Two real physical-count +alternatives6/9 belong to Ix regardless of the selected plan actor; each extreme +settles its actual winner, typed losses, native substitution and cleanup. +Moritani's five undialed counters defeat the coalition's four despite its total +dial9. Original card inventory and physical pools remain conserved. + +The final frozen batch passes **17 files130/130**, npm run check:quick, +production build and six actual-state gameplay/GameTable SSR programmes. +Two native Stone-allocation component callbacks retain busy no-submit and +submit the original event/count. Copied Tooth/Stone inline GameTable controls +are rendered persistently with busy choices and exercised through actual +event-bound engine actions; their inline callbacks are not directly invoked. +Twelve existing-profile continuations accept36 proposals plus256 separate +native controls. No strategy change or strength calibration is performed. + +The first batch's legacy Mirror/Stone tests asserted results before the +existing final outcome window closed. One committed-profile probe reproduces +the old Mirror failure. Shared fixture continuations now submit the actual +two final Ready controls; two old ambiguity guards are explicitly limited to +legacy unstamped battles. Fresh v1 cases keep their physical commitments. +Later narrow feedback fixes one such old guard and a reserved lint identifier. +The final frozen source, including these repairs, passes all required checks. + +Private artifacts: /home/quintine/.local/share/dune/checkpoints/20261024-basic-native-mirror/, +final frozen-integrated-4/report.json, runtime-smoke.json, private HTML/logs, +committed-profile-baseline.log, verified-files.json and documentation-overlay.json. +Before final prose: base49992d322f543726ad858e0bed60226711f1c32d, +source tree512bef259fadc063d64325dfac227144b202d746d0cff8d19d4d3753ec24db06, +2661 tracked/nonignored files unchanged throughout verification. Only four +checkpoint-prose/reference overlays follow proof; links/evidence and reference +syntax/keys are reviewed separately. Framework aliases/CSS stubs do not prove +browser/visual/deployed acceptance. Full modes, optional combinations and +comprehensive assurance remain open. No saved-room/D1/reset/restart/deployment +operation occurs. + ## Original phase-module composition — 16 October 2026 Selected Ix/CHOAM now preserves original Tech3+ and/or Advanced Strongholds2+ diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 3c8a793f..e1519ce0 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -903,7 +903,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | | Homeworld effects | Keep original payment/revival/placement identities and qualification observations; observations alone do not choose an occupied-benefit owner. | [Payments](HOMEWORLD_PAYMENT_INCOME_RULES.md), [card economy](HOMEWORLD_CARD_ECONOMY_RULES.md), [Collection](HOMEWORLD_COLLECTION_RULES.md), [revival deployment](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md), [victory reinforcement](HOMEWORLD_VICTORY_REINFORCEMENT_RULES.md), [replacement](HOMEWORLD_REPLACEMENT_RULES.md), [Tupile](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md) | | Duke Vidal revival custody | The user selected living set-aside custody after Ecaz revives Duke; a separate Ambassador/Nexus acquisition grants battle control. Only Ecaz may revive him, including Ghola. Native repeated returns now use the separately labelled provisional identity-cohort policy, not a new publisher example or user number. Captured scope and broad combinations remain separately bounded. | [Selected custody](ECAZ_REVIVAL_RULES.md#selected-product-interpretation-28-september-2026), [current cohort policy](ECAZ_REVIVAL_RULES.md#identity-tracked-repeated-cohorts--14-october-2026) | -| Mirror Weapon copied-card cleanup | The user selected ordinary winner retention for the physical Mirror even if its effective copy activates Tooth or Artillery. The original Tooth/Artillery cards retain their named mandatory-discard exceptions. This is a product interpretation, **not** a located publisher ruling. Independent copy-first Tooth/Stone choices and physical cleanup now have a bounded CHOAM/Richese-deck battle runtime; combined modules and full Richese games remain gated. | [Printed Mirror face and source audit](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026) | +| Mirror Weapon copied-card cleanup | The user selected ordinary winner retention for the physical Mirror even if its effective copy activates Tooth or Artillery. The original Tooth/Artillery cards retain their named mandatory-discard exceptions. This is a product interpretation, **not** a located publisher ruling. Independent copy-first Tooth/Stone choices and physical cleanup have a bounded CHOAM/Richese-deck runtime, including bare Basic native E3 and selected Ix with original fixed/typed coalition accounting. Basic E3 phase modules and other combined modules/full games remain gated. | [Printed Mirror face and source audit](MIRROR_WEAPON_ENGINE_AUDIT.md#user-selected-cleanup-interpretation-28-september-2026) | | Advanced Kull specific effect | Supplied physical29/logical27 restricts the attempted Karama for a specific effect, unlike publisher Basic phase-wide wording. New signed Advanced attempts bind semantic purpose; purchase/payment and native special variants normalize, as do enhanced Prescience/advisor conversion. CHOAM effects and special-force faction powers stay distinct. Retaining turn/phase expiry is explicit provisional composition under the 14 October policy, not supplied wording. Legacy unstamped pending/restriction frames retain broad scope. Prior distinct counter, BG-before-conversion and deferred overbid selections stay. | [Source cutover and keys](CHOAM_KULL_SOURCE_UPDATE.md#advanced-specific-effect-scope--18-october-2026), [connected implementation and limits](CHOAM_KULL_DESIGN.md#advanced-effect-scope--18-october-2026) | | Kull Wahad preview timing | User selected a **distinct eligible counter** while the interrupted physical card stays reserved, and BG Worthless interception **before conversion/discard**. Pending declaration is not a ban; only success stamps turn/phase and retains original/special once-use. Decline or prevention resumes the original once. Old `kull` stays printed-only; fresh `nexus-kull` admits native CHOAM plus classic Basic/Advanced seats, CHOAM/Ix Treachery and Nexus alone, without physical Sandtrout. Nexus Use needs actual unallied CHOAM Nexus and canonical eligible own fuel: accepted declaration spends Nexus even if prevented, fuel stays held until success. Use requires source/event/card, with no old-source action shim or proactive Kull. Verified prior printed counter saves migrate only their selection binding; no new-choice retrofit. Reservations, promises and remaining winning-payment coverage constrain fuel/counters; ordinary retained Karama payment is not a canceled Cunning effect. Priority/custody/overbid are user policy; neutral timing/spend conventions are application policy. Bounded checks/CLI/runtime/phone cases pass; broader verification is Partial and no full/public/deployed gate opens. | [Selected preview policy](CHOAM_KULL_SOURCE_UPDATE.md#user-selected-preview-timing--30-september-2026), [source/cost boundary](NEXUS_CHOAM_RULES.md#kull-source-cost-and-action-boundary), [runtime and remaining limits](CHOAM_KULL_DESIGN.md), [new evidence](NEXUS_CHOAM_RUNTIME.md#five-part-status-for-the-new-interaction) | | Semuta full-hand exchange | User selected **Allow exchange**: acquisition followed by Semuta disposal is an atomic net-zero hand exchange at the faction limit. Existing incoming reservations still consume capacity; this does not permit an already overfull hand or settle Box/Richese purchase timing. | [Selected capacity policy](SEMUTA_SOURCE_UPDATE_20260907.md#user-selected-full-hand-exchange--30-september-2026) | diff --git a/docs/STONE_BURNER_ENGINE_AUDIT.md b/docs/STONE_BURNER_ENGINE_AUDIT.md index 1a9228e3..67e8778b 100644 --- a/docs/STONE_BURNER_ENGINE_AUDIT.md +++ b/docs/STONE_BURNER_ENGINE_AUDIT.md @@ -2,6 +2,16 @@ Audit: 2026-09-06. Scope: the canonical weapon, its revealed-plan choice and physical undialed-force comparison. No runtime files were changed in this audit. This is not a full optional-module or expansion certification. +## Basic native E3 follow-up — 24 October 2026 + +Bare Basic Ecaz/Moritani with CHOAM/Richese, including selected Ix, now admits +the original Stone and physical Mirror through the existing native calculator. +Fixed Ecaz dial/losses and undialed counters remain separate from allied typed +pools; actual physical owners retain scalar allocation and later casualty/ +substitution controls. This changes a public configuration boundary, not Stone +arithmetic, copy-first or provisional online timing. +[Source, bounded proof and unfinished scope](MIRROR_WEAPON_ENGINE_AUDIT.md#basic-native-e3-admission--24-october-2026). + ## Outcome-changing physical totals — 18 October 2026 **Prototyped / Partial.** New original battles now finish the previously guarded diff --git a/game/mirror-weapon-mode.ts b/game/mirror-weapon-mode.ts index 5842f39c..d0d68a7b 100644 --- a/game/mirror-weapon-mode.ts +++ b/game/mirror-weapon-mode.ts @@ -21,11 +21,11 @@ export function mirrorWeaponModeBlock(g: { }): string | null { if (!g.expansions.includes('choam') || g.expansions.some(id => id !== 'choam' && id !== 'ecaz' && id !== 'ix') || - (g.expansions.includes('ecaz') && g.advanced !== true) || g.players.some((p) => !supported.has(p.faction))) - return 'Mirror Weapon needs selected CHOAM/Richese with supported native factions; Ecaz/Moritani combinations require Advanced.'; - if (g.expansions.includes('ix') && g.expansions.includes('ecaz') && (g.techTokens || g.strongholdCards)) - return 'Mixed E3 Stone/Mirror with Tech Tokens or Stronghold Cards remains outside this profile.'; + return 'Mirror Weapon needs selected CHOAM/Richese with supported native factions.'; + if (g.expansions.includes('ecaz') && (!g.advanced || g.expansions.includes('ix')) && + (g.techTokens || g.strongholdCards)) + return 'This E3 Stone/Mirror combination with Tech Tokens or Stronghold Cards remains outside this profile.'; if (g.homeworlds || g.nexusCards || g.leaderSkills || g.discoveries || g.sandtrout) return 'Mirror Weapon with combined optional modules is still being integrated.'; return null; diff --git a/game/reference.ts b/game/reference.ts index 9e8939b1..feeb07b6 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1265,7 +1265,7 @@ export const RULE_TOPICS: RuleTopic[] = [ : definition.card.effect === 'stoneBurner' ? 'Use Stone Burner in the weapon selector and review public dial/support feasibility before sealing. Clean selected CHOAM/Ix now uses visibly provisional original Stone-first then uncopied Tooth order; copy-first and physical allocation guards remain.' : definition.card.effect === 'mirrorWeapon' - ? 'Choose physical Mirror in a supported CHOAM/Richese-deck table, including clean selected Ix and Advanced Ecaz/Moritani. Copied Tooth or Stone chooses before its original. The user-selected winning physical-Mirror retention still applies after copied Tooth or Artillery. Original Stone/Tooth provisionally uses Stone-first order. Other profile/module and public-start gates remain.' + ? 'Choose physical Mirror in a supported CHOAM/Richese-deck table, including clean selected Ix and bare Basic or Advanced Ecaz/Moritani. Copied Tooth or Stone chooses before its original. The user-selected winning physical-Mirror retention still applies after copied Tooth or Artillery. Original Stone/Tooth provisionally uses Stone-first order. Other profile/module and public-start gates remain.' : definition.card.effect === 'portableSnooper' ? 'Choose Portable Snooper as your ordinary poison defense, or use the late-defense panel after reveal before your own traitor decision. It uses ordinary winner retention and cannot stop Poison Tooth.' : definition.card.effect === 'semutaDrug' @@ -1405,20 +1405,20 @@ export const RULE_TOPICS: RuleTopic[] = [ : definition.card.effect === 'mirrorWeapon' ? { checklist: [ { area: 'Implementation' as const, status: 'Partial' as const, - detail: 'Canonical physical Mirror, copy-first answers and physical retention compose with clean selected Ixian/Tleilaxu pools and original Advanced Occupy. Public feasibility and other profile/module boundaries remain.', - evidence: ['game/mirror-weapon-mode.ts', 'game/effective-weapons.ts', 'game/engine.ts'] }, + detail: 'Canonical physical Mirror, copy-first answers and physical retention now compose with bare Basic native E3, both coalition leads and actual Moritani combatants, including selected Ix typed pools. Original fixed Ecaz accounting, physical-owner scalar allocation and later native cleanup remain. Other profile/module boundaries stay separate.', + evidence: ['game/mirror-weapon-mode.ts', 'game/effective-weapons.ts', 'game/engine.ts', 'tests/basic-native-mirror-engine.test.ts'] }, { area: 'Player controls' as const, status: 'Partial' as const, - detail: 'Actual weapon selectors, independent copied choices and original retention are usable. Ix original Stone/Tooth priority is visibly provisional before commitment and during revelation.', - evidence: ['components/battle-preparation.tsx', 'components/game-table.tsx'] }, + detail: 'Actual native Basic selectors admit Mirror. Copied Tooth/Stone choices remain explicit, persistent and busy-disabled; original event actions advance the independent queue. Physical-owner allocation callbacks retain their real event/count and busy no-submit. Inline GameTable callbacks are rendered rather than directly invoked. Ix original Stone/Tooth priority remains visibly provisional.', + evidence: ['components/battle-preparation.tsx', 'components/game-table.tsx', 'tests/basic-native-mirror-controls.test.tsx'] }, { area: 'AI' as const, status: 'Partial' as const, - detail: 'All four profiles have legal clean-Ix candidate and original/copy continuation paths. This is not strategy or difficulty calibration.', - evidence: ['game/bots.ts', 'tests/ix-revealed-weapons.test.ts'] }, + detail: 'Twelve existing-profile native Basic continuations across both coalition leads and Moritani accept36 proposals plus256 separate native controls. Legal Mirror commitments use only the current own view. Earlier clean-Ix paths remain. No strategy or difficulty calibration.', + evidence: ['game/bots.ts', 'tests/ix-revealed-weapons.test.ts', 'tests/basic-native-mirror-engine.test.ts'] }, { area: 'Documentation' as const, status: 'Partial' as const, detail: 'Printed copy-first, user-selected physical retention and provisional original Stone/Tooth priority are distinguished. Combined modules and full-mode acceptance remain separate.', evidence: ['docs/MIRROR_WEAPON_ENGINE_AUDIT.md', 'docs/STONE_BURNER_ENGINE_AUDIT.md'] }, { area: 'Verification' as const, status: 'Partial' as const, - detail: 'Original Matrix/engine proof plus genuine native setup with explicit conserved battle staging covers Basic/Advanced, both native force pools, original/copy modes, public ambiguity and Advanced fixed/variable Occupy. Mobile and policy proof is bounded, not unstaged complete-game or final assurance certification.', - evidence: ['tests/ix-revealed-weapons.test.ts', 'tests/fixture-ix-revealed-weapons.ts', 'tests/mirror-weapon-engine.test.ts'] }, + detail: 'Current17-file130/130, types/lint/build and six gameplay/GameTable SSR programmes pass. Genuine six-native Basic setup/alliance/battle with explicit conserved board/budget/card staging covers Ecaz, allied Ix and Moritani copied Tooth/Stone. Original Ix owner selects6/9 physical totals and later native losses; five Moritani counters beat four despite opposing dial9. Legacy fixture controls and unstamped guards are repaired before final frozen proof. Earlier proofs remain dated; browser/deployed, full modes/combinations and comprehensive assurance stay open.', + evidence: ['tests/ix-revealed-weapons.test.ts', 'tests/fixture-ix-revealed-weapons.ts', 'tests/mirror-weapon-engine.test.ts', 'tests/fixture-basic-native-mirror.ts', 'tests/basic-native-mirror-engine.test.ts', 'tests/basic-native-mirror-controls.test.tsx'] }, ] } : {}), related: [ diff --git a/tests/basic-native-mirror-controls.test.tsx b/tests/basic-native-mirror-controls.test.tsx new file mode 100644 index 00000000..acfac852 --- /dev/null +++ b/tests/basic-native-mirror-controls.test.tsx @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { battlePlanCardOptions } from '../components/battle-preparation'; +import { + createBasicNativeMirrorFixture, actBasicNativeMirror as act, + basicNativeMirrorPlayer as player, +} from './fixture-basic-native-mirror'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' + : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)] + .map(match => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +const { GameTable } = await import('../components/game-table'); +aliases.deregister(); + +const table = (game: Game, actor: string, busy = false) => renderToStaticMarkup( + createElement(GameTable, { game: viewGame(game, actor), busy, + send: async () => assert.fail('Rendering cannot submit a battle action'), onExit() {} }), +); +function physicalPlan(game: Game, actor: string, weapon: string): Action { + const view = viewGame(game, actor); + view.players.find(p => p.id === actor)!.bot = 'Easy'; + const plan = botActions(view).find(action => action.type === 'battlePlan' && + action.weapon === weapon && !action.defense && Number(action.support ?? 0) === 0); + assert.ok(plan, 'The actual Basic plan actor retains a legal plan for this physical weapon'); + return plan; +} +function toothButton(html: string, label: string): string { + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const button = html.match(new RegExp(`]*>${escaped}[\\s\\S]*?`))?.[0]; + assert.ok(button, `The current decision retains its ${label} control`); + return button; +} + +// These exercise actual-state rendering and event-bound engine actions. The +// Tooth controls are inline GameTable callbacks, not separately invoked here. +for (const role of ['ecaz', 'moritani'] as const) { + void test(`Basic native ${role} Mirror appears in its real selector and keeps copied Tooth choices explicit`, () => { + const f = createBasicNativeMirrorFixture({ owner: role, weapon: 'poisonTooth' }); + let game = f.game; + const view = viewGame(game, f.owner), plan = physicalPlan(game, f.owner, f.mirror); + assert.equal(game.advanced, false); + assert.ok(battlePlanCardOptions(view, 'weapon', String(plan.leader), '', '') + .some(card => card.id === f.mirror)); + const options = table(game, f.owner).match(/]*id="battle-weapon"[^>]*>[\s\S]*?<\/select>/)?.[0]; + assert.ok(options); + assert.match(options, /value="richese-mirror-weapon"/); + assert.match(options, /Mirror Weapon/); + + game = act(game, f.owner, plan); + game = act(game, f.opponent, physicalPlan(game, f.opponent, f.original)); + assert.equal(game.battle!.revealed, true); + assert.equal(game.decision?.kind, 'poisonTooth'); + assert.equal(game.decision.player, f.owner); + assert.equal(game.decision.physicalId, f.mirror); + assert.equal(game.decision.copiedFrom, f.original); + const event = game.decision.event, before = structuredClone(game); + for (let reading = 0; reading < 2; reading++) { + const html = table(game, f.owner); + assert.match(html, /Your physical Mirror Weapon copied the opposing Poison Tooth/); + assert.match(html, /A Mirror copy chooses before the original Tooth/); + assert.doesNotMatch(toothButton(html, 'Activate Poison Tooth'), /\sdisabled(?:=|\s|>)/); + assert.doesNotMatch(toothButton(html, 'Leave it unused'), /\sdisabled(?:=|\s|>)/); + } + const busy = table(game, f.owner, true); + assert.match(toothButton(busy, 'Activate Poison Tooth'), /disabled=""/); + assert.match(toothButton(busy, 'Leave it unused'), /disabled=""/); + assert.deepEqual(game, before, 'Repeated rendering leaves the explicit native choice pending'); + + assert.throws(() => act(game, f.owner, + { type: 'decision', event: 'stale-copied-tooth', activate: false })); + assert.deepEqual(game, before); + game = act(game, f.owner, { type: 'decision', event, activate: false }); + assert.equal(game.decision?.kind, 'poisonTooth'); + assert.equal(game.decision.player, f.opponent); + assert.equal(game.decision.physicalId, f.original); + assert.equal(game.decision.copiedFrom, null); + assert.equal(game.battle!.poisonTooth![f.owner], false); + assert.ok(player(game, f.owner).hand.some(card => card.id === f.mirror)); + const original = table(game, f.opponent); + assert.doesNotMatch(original, /Your physical Mirror Weapon copied the opposing Poison Tooth/); + toothButton(original, 'Activate Poison Tooth'); + toothButton(original, 'Leave it unused'); + game = act(game, f.opponent, { type: 'decision', event: game.decision.event, activate: false }); + assert.equal(game.battle!.poisonTooth![f.opponent], false); + }); +} diff --git a/tests/basic-native-mirror-engine.test.ts b/tests/basic-native-mirror-engine.test.ts new file mode 100644 index 00000000..155636fc --- /dev/null +++ b/tests/basic-native-mirror-engine.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mirrorWeaponModeBlock } from '../game/mirror-weapon-mode'; +import { viewGame, type Game, type Action } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { nextBasicEcazNativeStep } from './fixture-basic-ecaz-native'; +import { createBasicNativeMirrorFixture as fixture, actBasicNativeMirror as act, + basicNativeMirrorPlayer as player, basicNativeMirrorInventory as inventory, + basicNativeMirrorClean as clean, type BasicNativeMirrorFixture } from './fixture-basic-native-mirror'; + +void test('bare Basic E3 and mixed native families admit Mirror without adding optional-module permission', () => { + const native = { expansions: ['choam', 'ecaz', 'ix'], advanced: false, + players: (['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu'] as const).map(faction => ({ faction })) } as const; + assert.equal(mirrorWeaponModeBlock(native), null); + assert.equal(mirrorWeaponModeBlock({ ...native, expansions: ['choam', 'ecaz'] }), null); + for (const selectedModule of ['homeworlds', 'nexusCards', 'leaderSkills', 'discoveries', 'sandtrout'] as const) + assert.ok(mirrorWeaponModeBlock({ ...native, [selectedModule]: true })); + assert.ok(mirrorWeaponModeBlock({ ...native, techTokens: {} })); + assert.ok(mirrorWeaponModeBlock({ ...native, expansions: ['choam', 'ecaz'], techTokens: {} })); + assert.equal(mirrorWeaponModeBlock({ ...native, expansions: ['choam', 'ecaz'], advanced: true, techTokens: {} }), null); + assert.ok(mirrorWeaponModeBlock({ ...native, expansions: ['ecaz', 'ix'] })); +}); + +function plan(game: Game, actor: string, weapon: string, variable: number): Action { + const profile = viewGame(game, actor).battle!.ecazOccupy?.profile; + const fixed = profile?.planOwner === actor ? profile.fixedEcazDial : 0; + const leader = player(game, actor).leaders.find(leader => !leader.dead && !leader.usedAt); assert.ok(leader); + return { type: 'battlePlan', dial: fixed + variable, support: 0, leader: leader.id, weapon }; +} +function reveal(f: BasicNativeMirrorFixture, own = 2, other = 0) { + const first = act(f.game, f.owner, plan(f.game, f.owner, f.mirror, own)); + assert.equal(first.battle!.revealed, false); + assert.equal(viewGame(first, f.opponent).battle!.plans[f.owner], undefined); + return act(first, f.opponent, plan(first, f.opponent, f.original, other)); +} +function complete(source: Game, total?: number) { + let game = source; + for (let count = 0; count < 180; count++) { + if (!game.battle && clean(game)) return game; + const d = game.decision; + if (!game.response && !game.phaseOpening && d?.kind === 'stoneAllocation') { + const quote = viewGame(game, d.player).battle!.stoneAllocation!; + const selected = total ?? quote.totals[0]; assert.ok(quote.totals.includes(selected)); + game = act(game, d.player, { type: 'decision', event: quote.event, undialed: selected }); + } else if (!game.response && !game.phaseOpening && d?.kind === 'battleLosses') { + game = act(game, d.player, { type: 'decision', choice: 0 }); + } else { + const step = nextBasicEcazNativeStep(game); assert.ok(step); + assert.notEqual(step.action.type, 'battlePlan'); + game = act(game, step.actor, step.action); + } + } + throw Error('The actual Basic native battle did not finish'); +} +function conserve(game: Game) { + for (const p of game.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'ixians' ? 7 : 5); + } +} +for (const owner of ['ecaz', 'ally', 'moritani'] as const) + void test(`Basic native ${owner} Mirror copies Tooth first, retains physical winning identity and finishes original accounting`, () => { + const f = fixture({ owner }); + assert.equal(f.source.status, 'setup'); assert.equal(f.source.advanced, false); + assert.equal(f.afterSetup.status, 'playing'); assert.equal(f.game.advanced, false); + conserve(f.game); + let game = reveal(f, owner === 'moritani' ? 4 : 2); + assert.equal(game.decision?.kind, 'poisonTooth'); assert.equal(game.decision.player, f.owner); + assert.equal(game.decision.physicalId, f.mirror); assert.equal(game.decision.copiedFrom, f.original); + assert.equal(game.battle!.plans[f.owner].weapon, f.mirror); + assert.equal(game.battle!.plans[f.opponent].weapon, f.original); + const bounty = [f.owner, f.opponent].reduce((sum, actor) => sum + + player(game, actor).leaders.find(leader => leader.id === game.battle!.plans[actor].leader)!.strength, 0); + game = act(game, f.owner, { type: 'decision', event: game.decision.event, activate: true }); + assert.equal(game.decision?.kind, 'poisonTooth'); assert.equal(game.decision.player, f.opponent); + game = act(game, f.opponent, { type: 'decision', event: game.decision.event, activate: false }); + game = complete(game); + assert.equal(game.lastBattleContext!.winner, f.owner); + assert.ok(player(game, f.owner).hand.some(card => card.id === f.mirror)); + assert.ok(game.discard.some(card => card.id === f.original)); + assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice + bounty, + 'Basic charges no support and awards only the actual printed dead-leader bounty'); + assert.deepEqual(inventory(game), f.originalInventory); conserve(game); + }); + +for (const owner of ['ecaz', 'ally'] as const) + void test(`Basic native ${owner} copied Stone keeps fixed Ecaz counters and the Ixian owner's physical alternatives`, () => { + const f = fixture({ owner, weapon: 'stoneBurner' }); let game = reveal(f); + assert.equal(game.decision?.kind, 'stoneBurner'); assert.equal(game.decision.player, f.owner); + game = act(game, f.owner, { type: 'decision', event: game.decision.event, mode: 'ignore' }); + assert.equal(game.decision?.kind, 'stoneBurner'); assert.equal(game.decision.player, f.opponent); + game = act(game, f.opponent, { type: 'decision', event: game.decision.event, mode: 'ignore' }); + for (let count = 0; count < 50 && game.decision?.kind !== 'stoneAllocation'; count++) { + const step = nextBasicEcazNativeStep(game); assert.ok(step); game = act(game, step.actor, step.action); + } + assert.equal(game.decision?.kind, 'stoneAllocation'); assert.equal(game.decision.player, f.ally); + const offer = viewGame(game, f.ally).battle!.stoneAllocation!; + assert.equal(offer.owner, f.ally); assert.equal(offer.player, f.owner); + assert.deepEqual(offer.totals, [6, 9]); + for (const total of offer.totals) { + const after = complete(game, total); + assert.equal(after.lastBattleContext!.winner, total === 9 ? f.owner : f.opponent); + assert.equal(player(after, f.ecaz).forces[f.location] ?? 0, total === 9 ? 2 : 0); + assert.equal(player(after, f.ally).forces[f.location] ?? 0, total === 9 ? 7 : 0); + assert.equal(player(after, f.ally).elites!.forces[f.location] ?? 0, total === 9 ? 1 : 0); + assert.equal(player(after, f.owner).hand.some(card => card.id === f.mirror), total === 9); + assert.deepEqual(inventory(after), f.originalInventory); conserve(after); + } + }); + +void test('Basic Moritani copied Stone compares physical counters against the original typed coalition', () => { + const f = fixture({ owner: 'moritani', weapon: 'stoneBurner' }); let game = reveal(f, 0, 6); + assert.equal(game.decision?.kind, 'stoneBurner'); assert.equal(game.decision.player, f.owner); + game = act(game, f.owner, { type: 'decision', event: game.decision.event, mode: 'ignore' }); + assert.equal(game.decision?.kind, 'stoneBurner'); assert.equal(game.decision.player, f.opponent); + game = act(game, f.opponent, { type: 'decision', event: game.decision.event, mode: 'ignore' }); + game = complete(game); + assert.equal(game.lastBattleContext!.winner, f.owner, 'Five physical counters beat four despite the opposing dial of nine'); + assert.equal(player(game, f.owner).forces[f.location], 5); + assert.equal(player(game, f.ecaz).forces[f.location] ?? 0, 0); + assert.equal(player(game, f.ally).forces[f.location] ?? 0, 0); + assert.ok(player(game, f.owner).hand.some(card => card.id === f.mirror)); + assert.deepEqual(inventory(game), f.originalInventory); conserve(game); +}); + +void test('all four minimal profiles offer legal canonical Mirror commitments for both native coalition leads and Moritani', () => { + for (const owner of ['ecaz', 'ally', 'moritani'] as const) { + const f = fixture({ owner, weapon: 'projectile' }); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(f.game, f.owner); view.players.find(p => p.id === f.owner)!.bot = difficulty; + const before = structuredClone(view); + const options = botActions(view).filter(action => action.type === 'battlePlan' && action.weapon === f.mirror); + assert.ok(options.length, `${owner}/${difficulty}`); + assert.doesNotThrow(() => act(f.game, f.owner, options[0])); + assert.deepEqual(view, before); + } + } +}); diff --git a/tests/fixture-basic-native-mirror.ts b/tests/fixture-basic-native-mirror.ts new file mode 100644 index 00000000..f6acfc16 --- /dev/null +++ b/tests/fixture-basic-native-mirror.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { type Card } from '../game/cards'; +import { basicEcazNativeFixture, nextBasicEcazNativeStep } from './fixture-basic-ecaz-native'; +import { stageEcazOccupyCard } from './fixture-ecaz-occupy'; + +export type BasicNativeMirrorOptions = { + owner?: 'ecaz' | 'ally' | 'moritani'; + weapon?: 'poisonTooth' | 'stoneBurner' | 'projectile' | 'artillery'; +}; +export type BasicNativeMirrorStep = { actor: string; action: Action }; +export const BASIC_NATIVE_MIRROR = 'richese-mirror-weapon'; +export const basicNativeMirrorPlayer = (game: Game, actor: string) => { + const player = game.players.find(player => player.id === actor); + assert.ok(player); return player; +}; +export const basicNativeMirrorClean = (game: Game) => + !game.response && !game.phaseOpening && !game.decision && + !game.truthtrance && !game.pendingTreacheryDiscard; +export const basicNativeMirrorPlansOpen = (game: Game) => + !!game.battle && !game.battle.preparation && + (!game.battle.preLeader || game.battle.preLeader.closed) && basicNativeMirrorClean(game); + +/** Physical inventory, including native cache/retired cards. No duplicated + * original identity may be introduced by the explicitly controlled staging. */ +export function basicNativeMirrorInventory(game: Game): string[] { + const ids = [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), + ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...(game.auction?.cards.slice(game.auction.index) ?? []), + ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); + assert.equal(new Set(ids).size, ids.length, 'Every original physical card has exactly one current location'); + return ids; +} +export function actBasicNativeMirror(game: Game, actor: string, action: Action, + actions: BasicNativeMirrorStep[] = []): Game { + const next = applyAction(game, actor, action); + actions.push({ actor, action: structuredClone(action) }); return next; +} + +/** Only actual native continuations. A plan, copied/original weapon choice, + * typed physical allocation or casualty remains an explicit caller action. */ +export function advanceBasicNativeMirror(game: Game, until: (state: Game) => boolean, + actions: BasicNativeMirrorStep[] = []): Game { + for (let count = 0; count < 250; count++) { + if (until(game)) return game; + const decision = game.decision; + assert.ok(!decision || !['poisonTooth', 'stoneBurner', 'stoneAllocation', 'battleLosses'] + .includes(decision.kind), 'Choose the actual weapon/allocation/loss explicitly'); + const step = nextBasicEcazNativeStep(game); + assert.ok(step, 'The native battle continuation must retain a real control'); + assert.ok(!['battlePlan', 'chooseBattle'].includes(step.action.type), + 'The caller owns real battle admission and sealed plans'); + game = actBasicNativeMirror(game, step.actor, step.action, actions); + } + throw Error('The native Mirror continuation did not reach its finite boundary'); +} + +function stagedPhysicalCard(game: Game, owner: string, predicate: (card: Card) => boolean): string { + const before = basicNativeMirrorInventory(game), player = basicNativeMirrorPlayer(game, owner); + const cache = game.richeseCache?.findIndex(predicate) ?? -1; + let id: string; + if (cache >= 0) { + const card = game.richeseCache!.splice(cache, 1)[0]; + if (player.hand.length >= 4) game.deck.push(player.hand.shift()!); + player.hand.push(card); id = card.id; + } else id = stageEcazOccupyCard(game, owner, predicate); + assert.ok(player.hand.length <= 4, 'Controlled rule hands retain an actual legal capacity'); + assert.deepEqual(basicNativeMirrorInventory(game), before); + return id; +} + +/** Original public Basic native setup/alliance and actual battle admission. + * The shared fixture explicitly stages a conserved board, thirty-spice bank + * budget and original cards. This adds named physical weapon transfers, not + * an unstaged acquisition or complete-game certificate. */ +export function createBasicNativeMirrorFixture(options: BasicNativeMirrorOptions = {}) { + const role = options.owner ?? 'ecaz', weapon = options.weapon ?? 'poisonTooth'; + const fixture = basicEcazNativeFixture({ allyFaction: 'ixians', opponentFaction: 'choam', mixedIxians: true }); + const actions: BasicNativeMirrorStep[] = [], staging = [...fixture.staging]; + const originalInventory = basicNativeMirrorInventory(fixture.source); + let game = structuredClone(role === 'moritani' ? fixture.beforeBattle : fixture.game); + let beforeBattle = structuredClone(fixture.beforeBattle); + if (role === 'moritani') { + const previous = basicNativeMirrorPlayer(game, fixture.opponent); + previous.reserves += previous.forces[fixture.location] ?? 0; + delete previous.forces[fixture.location]; + if (previous.elites) { + previous.elites.reserves += previous.elites.forces[fixture.location] ?? 0; + delete previous.elites.forces[fixture.location]; + } + const moritani = basicNativeMirrorPlayer(game, 'moritani'); + assert.equal(moritani.forces[fixture.location] ?? 0, 0); + assert.ok(moritani.reserves >= 5); + moritani.reserves -= 5; moritani.forces[fixture.location] = 5; + staging.push('Before actual admission, return the original CHOAM Carthag counters to their own reserves and place five conserved Moritani reserves there; preserve the actual Ecaz/Ixian Nexus alliance, native phase and battle order.'); + beforeBattle = structuredClone(game); + const choice = viewGame(game, game.active!).battleChoices.find(choice => + choice.territory === fixture.territory && [choice.attacker, choice.defender].includes('moritani')); + assert.ok(choice, 'The conserved Moritani army creates a real native battle choice'); + game = actBasicNativeMirror(game, choice.chooser, { type: 'chooseBattle', territory: fixture.territory, + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, actions); + } + assert.equal(game.decision?.kind, 'ecazBattleLead'); + const lead = role === 'ecaz' ? fixture.ecaz : fixture.ally; + game = actBasicNativeMirror(game, fixture.ecaz, + { type: 'decision', event: game.decision!.event, lead }, actions); + game = advanceBasicNativeMirror(game, basicNativeMirrorPlansOpen, actions); + assert.ok(game.battle && !game.battle.revealed && Object.keys(game.battle.plans).length === 0); + const owner = role === 'moritani' ? 'moritani' : lead; + const opponent = game.battle.attacker === owner ? game.battle.defender : game.battle.attacker; + assert.ok([game.battle.attacker, game.battle.defender].includes(owner)); + const mirror = stagedPhysicalCard(game, owner, card => card.id === BASIC_NATIVE_MIRROR); + const original = stagedPhysicalCard(game, opponent, + card => weapon === 'stoneBurner' ? card.id === 'richese-stone-burner' : card.kind === weapon); + staging.push(`At the actual clean unsealed-plan boundary, conserve the original physical Mirror into ${owner}'s hand and the original ${weapon} into ${opponent}'s hand, exchanging an original held card with the deck only when capacity requires it. These are labelled rule-case acquisitions, not a played cache purchase or newly manufactured cards.`); + assert.equal(game.advanced, false); + assert.deepEqual(game.players.map(player => player.faction), ['ecaz', 'ixians', 'choam', 'richese', 'moritani', 'tleilaxu']); + assert.deepEqual(basicNativeMirrorInventory(game), originalInventory); + return { source: fixture.source, afterSetup: fixture.afterSetup, beforeBattle, game, + owner, opponent, ecaz: fixture.ecaz, ally: fixture.ally, richese: fixture.richese, + territory: fixture.territory, location: fixture.location, mirror, original, weapon, role, + originalInventory, actions, staging }; +} +export type BasicNativeMirrorFixture = ReturnType; diff --git a/tests/fixture-battle-outcome-window.ts b/tests/fixture-battle-outcome-window.ts new file mode 100644 index 00000000..5dfc97f2 --- /dev/null +++ b/tests/fixture-battle-outcome-window.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game } from '../game/engine'; + +/** Close only the actual final-priority controls. Do not bypass a response, + * pending native decision, Stone count, casualty choice or cleanup. */ +export function finishBattleOutcomeWindow(source: Game): Game { + let game = source; + for (let count = 0; count < 2; count++) { + const window = game.battle?.outcomeWindow; + if (!window || window.closed) return game; + assert.ok(!game.response && !game.decision && !game.phaseOpening, + 'Finish the actual native control before final battle priority'); + const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id)); + assert.ok(actor); + const own = viewGame(game, actor).battle!.outcomeWindow; + assert.ok(own && !own.closed); + game = applyAction(game, actor, { type: 'battleOutcomeReady', event: own.event }); + } + assert.ok(!game.battle?.outcomeWindow || game.battle.outcomeWindow.closed); + return game; +} diff --git a/tests/fixture-ix-revealed-weapons.ts b/tests/fixture-ix-revealed-weapons.ts index 8dc709fe..3038dfb6 100644 --- a/tests/fixture-ix-revealed-weapons.ts +++ b/tests/fixture-ix-revealed-weapons.ts @@ -3,6 +3,7 @@ import {applyAction, viewGame, type Action, type Game} from '../game/engine'; import {harassWithdrawGame} from './fixture-harass-withdraw'; import {stoneHarassCustody, takeStoneHarassCard, STONE_CARD, MIRROR_CARD} from './fixture-stone-harass'; import {finishOccupyCardsAftermath} from './fixture-ecaz-occupy-cards'; +import {finishBattleOutcomeWindow} from './fixture-battle-outcome-window'; export const IX_TOOTH = 'ix-poison-tooth'; export type IxRevealedCase = { @@ -53,6 +54,7 @@ export function finishIxWeaponBattle(state: Game): Game { assert.equal(game.decision, null, 'All native reveal choices must precede traitor calls.'); for (const id of viewGame(game, 'a').battle!.traitorVoters) game = applyAction(game, id, {type: 'traitorCall', call: false}); + game = finishBattleOutcomeWindow(game); game = finishOccupyCardsAftermath(game, {stopAtFaceDance: true}); if (game.decision?.kind === 'faceDance') { game = applyAction(game, game.decision.player, {type: 'decision', reveal: false}); diff --git a/tests/fixture-stone-harass.ts b/tests/fixture-stone-harass.ts index d8042f52..78a89e6a 100644 --- a/tests/fixture-stone-harass.ts +++ b/tests/fixture-stone-harass.ts @@ -13,6 +13,7 @@ import { chooseEcazOccupyLead, ecazOccupyFixture, openEcazOccupyPlans, type EcazOccupyFixture, } from './fixture-ecaz-occupy'; import { finishOccupyCardsAftermath } from './fixture-ecaz-occupy-cards'; +import { finishBattleOutcomeWindow } from './fixture-battle-outcome-window'; export const HARASS_CARD = 'ecaz-harass-withdraw'; export const STONE_CARD = 'richese-stone-burner'; @@ -266,6 +267,7 @@ export function finishStoneHarassBattle(state: Game, options: { call: options.traitors?.includes(voter) ?? false }); if (game.decision) break; } + game = finishBattleOutcomeWindow(game); game = finishOccupyCardsAftermath(game); stoneHarassCustody(game); return game; diff --git a/tests/ix-revealed-weapons.test.ts b/tests/ix-revealed-weapons.test.ts index 6997444d..f0416999 100644 --- a/tests/ix-revealed-weapons.test.ts +++ b/tests/ix-revealed-weapons.test.ts @@ -74,8 +74,11 @@ void test('original Ix-deck Stone precedes uncopied Tooth under the existing pro } }); -void test('Ix public precommit still rejects winner-changing cyborg allocations independently of the hidden opposing plan', () => { +void test('legacy unstamped Ix public precommit rejects winner-changing cyborg allocations independently of the hidden opposing plan', () => { const source = ixRevealedWeaponFixture({advanced: true, kind: 'stoneTooth'}); + // This case specifically preserves the pre-v1 public allocation guard. + // Fresh stamped battles use their actual revealed physical-count controls. + delete source.game.battle!.stoneAllocationVersion; const own = {...source.plans[0].action, dial: 2, support: 0}; const messages: string[] = []; for (const dial of [0, 5]) { diff --git a/tests/mirror-weapon-engine.test.ts b/tests/mirror-weapon-engine.test.ts index 293f0fe5..230dd7b8 100644 --- a/tests/mirror-weapon-engine.test.ts +++ b/tests/mirror-weapon-engine.test.ts @@ -5,6 +5,7 @@ import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { treacheryDeck, type Card } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; +import { finishBattleOutcomeWindow } from './fixture-battle-outcome-window'; const mirror = richeseCards().find((card) => card.effect === 'mirrorWeapon')!; function fixture(advanced = false) { @@ -60,7 +61,8 @@ function reveal(g: Game, source?: Card, ownDial = 2, otherDial = 2) { } function finish(g: Game) { g = applyAction(g, 'q', { type: 'traitorCall', call: false }); - return applyAction(g, 'p', { type: 'traitorCall', call: false }); + g = applyAction(g, 'p', { type: 'traitorCall', call: false }); + return finishBattleOutcomeWindow(g); } void test('copied Tooth chooses before its original despite reverse storm order; restored choices keep physical custody', () => { diff --git a/tests/stone-burner-bots.test.ts b/tests/stone-burner-bots.test.ts index 2c4b6cdd..0a16f601 100644 --- a/tests/stone-burner-bots.test.ts +++ b/tests/stone-burner-bots.test.ts @@ -13,6 +13,7 @@ import { botActions } from '../game/bots'; import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; import { stoneBurnerPlanBlock } from '../game/stone-burner'; import { baseDeck, ixBattleCards } from '../game/cards'; +import { finishBattleOutcomeWindow } from './fixture-battle-outcome-window'; const stone = () => richeseCards().find((c) => c.effect === 'stoneBurner')!; function fixture(advanced = false, reverse = false) { let g = createGame('STONEBOT', newPlayer('p', 'Pilot', 'emperor'), advanced, [ @@ -167,6 +168,7 @@ void test('all profiles choose public leader preservation versus bounty without next = JSON.parse(JSON.stringify(next)); next = applyAction(next, 'p', { type: 'traitorCall', call: false }); next = applyAction(next, 'q', { type: 'traitorCall', call: false }); + next = finishBattleOutcomeWindow(next); assert.equal(next.players[0].forces['pasty_mesa:5'], 8); assert.equal(next.players[1].tanks, 3); assert.equal( diff --git a/tests/stone-burner-engine.test.ts b/tests/stone-burner-engine.test.ts index 4e8ad960..dd1d3cd8 100644 --- a/tests/stone-burner-engine.test.ts +++ b/tests/stone-burner-engine.test.ts @@ -12,6 +12,7 @@ import { import { baseDeck, ixBattleCards, type Card } from '../game/cards'; import { FACTIONS, type FactionId } from '../game/catalog'; import { richeseCards } from '../game/richese-cards'; +import { finishBattleOutcomeWindow } from './fixture-battle-outcome-window'; import { isStoneBurner, isWeaponCard, @@ -95,11 +96,11 @@ function reveal( } const mode = (g: Game, mode: 'kill' | 'ignore') => applyAction(g, 'p', { type: 'decision', event: g.battle!.event, mode }); -const finish = (g: Game) => - applyAction(applyAction(g, 'q', { type: 'traitorCall', call: false }), 'p', { - type: 'traitorCall', - call: false, - }); +function finish(g: Game) { + g = applyAction(g, 'q', { type: 'traitorCall', call: false }); + g = applyAction(g, 'p', { type: 'traitorCall', call: false }); + return finishBattleOutcomeWindow(g); +} void test('canonical Stone is a named special weapon, never a generic special, poison or projectile', () => { const card = richeseCards().find((c) => c.id === stoneId)!; @@ -208,6 +209,7 @@ void test('traitor and explosion precedence remain intact, while Stone never kil g.players[1].traitors = [g.battle!.plans.p.leader!]; g = applyAction(g, 'q', { type: 'traitorCall', call: true }); g = applyAction(g, 'p', { type: 'traitorCall', call: false }); + g = finishBattleOutcomeWindow(g); assert.equal(g.players[1].leaders[0].dead, false); assert.equal(g.players[1].forces['pasty_mesa:5'], 5); g = fixture(); diff --git a/tests/stone-harass-runtime.test.ts b/tests/stone-harass-runtime.test.ts index e8221580..c225df4c 100644 --- a/tests/stone-harass-runtime.test.ts +++ b/tests/stone-harass-runtime.test.ts @@ -148,12 +148,15 @@ void test('a Mirror Weapon copying the opposing Stone keeps the copied compariso } }); -void test('Stone Burner precommit is public: the same commitment is decided before and after hidden opposing plans', () => { +void test('legacy unstamped Stone precommit stays public before and after hidden opposing plans', () => { const messages: string[] = []; for (const hiddenDial of [1, 5]) { const ambiguous = stoneHarassFixture({ advanced: true, harass: 'defender', stone: 'opponent', attackerForces: 2, attackerElite: 1, attackerDial: 2, attackerSupport: 1, defenderForces: 5, defenderDial: hiddenDial }); + // Retain the old unstamped guard here. Fresh v1 battles instead collect + // their actual physical-total commitments after revealed weapon choices. + delete ambiguous.game.battle!.stoneAllocationVersion; const stonePlan = planOf(ambiguous.plans, 'a'); const hiddenPlan = planOf(ambiguous.plans, 'd'); const before = { spice: seat(ambiguous.game, 'a').spice, reserves: seat(ambiguous.game, 'a').reserves,