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:
1 parent
49992d322f
commit
19e554666e
18 files changed
+566
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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+
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user