Admit Mirror and Stone in Basic native E3 battles

Reuse original copied-weapon and typed coalition accounting for bare Basic
Ecaz/Moritani and selected Ix. Preserve optional-module guards and physical
card identity, actual force-owner commitments and native continuations.
This commit is contained in:
Quintine committed 2026-10-24 16:34:19 +11:00
1 parent 49992d322f
commit 19e554666e
18 files changed
+566 -23

No files matched your search

+21
View File
@@ -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
+30
View File
@@ -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
+80 -1
View File
@@ -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+
+1 -1
View File
@@ -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) |
+10
View File
@@ -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
+4 -4
View File
@@ -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;
+9 -9
View File
@@ -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: [
+102
View File
@@ -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(`<button\\b[^>]*>${escaped}[\\s\\S]*?</button>`))?.[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(/<select[^>]*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);
});
}
+140
View File
@@ -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);
}
}
});
+124
View File
@@ -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<typeof createBasicNativeMirrorFixture>;
+21
View File
@@ -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;
}
+2
View File
@@ -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});
+2
View File
@@ -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;
+4 -1
View File
@@ -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]) {
+3 -1
View File
@@ -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', () => {
+2
View File
@@ -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(
+7 -5
View File
@@ -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();
+4 -1
View File
@@ -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,