Admit clean Ix Stone and Mirror with existing revealed priorities

This commit is contained in:
Quintine committed 2026-10-16 08:00:45 +11:00
1 parent 842fd72761
commit f50dda6078
16 files changed
+411 -54

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
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';
export const IX_TOOTH = 'ix-poison-tooth';
export type IxRevealedCase = {
game: Game;
native: 'ixians' | 'tleilaxu';
holder: 'a' | 'd';
other: 'a' | 'd';
kind: 'mirrorStone' | 'mirrorTooth' | 'stoneTooth';
plans: {actor: string; action: Action}[];
};
/** Original native Ix/Richese setup and exact deck, then the upstream fixture's
* explicit phase/board staging. Actual chooseBattle owns every battle control;
* this is a conserved rule fixture, not an unstaged complete-game claim. */
export function ixRevealedWeaponFixture({advanced = false, native = 'ixians', holder = 'a',
kind = 'mirrorStone'}: {advanced?: boolean; native?: 'ixians' | 'tleilaxu';
holder?: 'a' | 'd'; kind?: IxRevealedCase['kind']} = {}): IxRevealedCase {
const game = harassWithdrawGame({advanced, factions: [native, 'atreides', 'richese'],
normal: native === 'ixians' ? 2 : 4, elite: native === 'ixians' ? 2 : 0,
territory: 'shield_wall', sector: 8});
for (const player of game.players) {
game.deck.push(...player.hand);
player.hand = [];
}
const other = holder === 'a' ? 'd' : 'a';
const holderCard = kind === 'stoneTooth' ? STONE_CARD : MIRROR_CARD;
const sourceCard = kind === 'mirrorStone' ? STONE_CARD : IX_TOOTH;
takeStoneHarassCard(game, holder, holderCard);
takeStoneHarassCard(game, other, sourceCard);
const plans = (['a', 'd'] as const).map(actor => ({actor, action: {
// Full ordinary commitment remains preflight-safe against every opposing
// mixed Ix allocation; a hidden already-sealed zero dial is not a shortcut.
type: 'battlePlan', dial: actor === 'a' ? 0 : 5,
support: advanced && actor === 'd' ? 5 : 0,
leader: game.players.find(player => player.id === actor)!.leaders[0].id,
weapon: actor === holder ? holderCard : sourceCard,
} satisfies Action}));
stoneHarassCustody(game);
return {game, native, holder, other, kind, plans};
}
export function revealIxWeapons(source: IxRevealedCase): Game {
let game = source.game;
for (const {actor, action} of source.plans) game = applyAction(game, actor, action);
assert.equal(game.battle?.revealed, true);
return game;
}
export function finishIxWeaponBattle(state: Game): Game {
let game = state;
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 = finishOccupyCardsAftermath(game, {stopAtFaceDance: true});
if (game.decision?.kind === 'faceDance') {
game = applyAction(game, game.decision.player, {type: 'decision', reveal: false});
game = finishOccupyCardsAftermath(game);
}
assert.equal(game.battle, null);
assert.equal(game.decision, null);
stoneHarassCustody(game);
return game;
}
+10 -5
View File
@@ -78,7 +78,7 @@ export function stoneHarassCustody(game: Game): void {
...(player.faceDancers ?? []).map(card => card.leader)]),
].sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
for (const player of game.players) {
// Both native fixture rosters use four-card hands.
// All admitted native fixture rosters use four-card hands.
assert.ok(player.hand.length <= 4, 'Conserved card staging must also respect native hand capacity.');
assert.equal(player.reserves + player.tanks +
Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
@@ -273,6 +273,7 @@ export function finishStoneHarassBattle(state: Game, options: {
export type StoneHarassOccupyOptions = {
lead?: 'ecaz' | 'ally';
allyFaction?: 'guild' | 'ixians' | 'tleilaxu';
/** Stage Harass in the selected lead's plan; false keeps the same card in hand. */
harass?: boolean;
stone?: 'opponent' | 'harass';
@@ -281,6 +282,7 @@ export type StoneHarassOccupyOptions = {
support?: number;
ecazForces?: number;
allyForces?: number;
allyElite?: number;
opponentForces?: number;
opponentElite?: number;
opponentDial?: number;
@@ -301,17 +303,20 @@ export function stoneHarassOccupyFixture(
options: StoneHarassOccupyOptions = {},
): StoneHarassOccupyCase {
const lead: 'ecaz' | 'ally' = options.lead ?? 'ecaz';
const allyFaction = options.allyFaction ?? 'guild';
const expansions: Game['expansions'] = ['ecaz', 'choam', ...(allyFaction === 'guild' ? [] : ['ix'] as const)];
// The original four-seat advanced lobby supplies Ecaz, its real ally, a
// classic opponent and Richese's own card cache; every later step is native.
let initial = createGame('STONEOCCUPY', newPlayer('ecaz', 'ecaz', 'ecaz'), true, ['ecaz', 'choam']);
for (const faction of ['guild', 'emperor', 'richese'] as const)
let initial = createGame('STONEOCCUPY', newPlayer('ecaz', 'ecaz', 'ecaz'), true, expansions);
for (const faction of [allyFaction, 'emperor', 'richese'] as const)
joinGame(initial, newPlayer(faction, faction, faction));
for (const player of initial.players) initial = applyAction(initial, player.id, { type: 'ready' });
initial.ecazTreachery = true;
initial = initializeEcazOccupyGameForAudit(initial);
const occupy = ecazOccupyFixture({ initial, expansions: ['ecaz', 'choam'], ecazTreachery: true,
allyFaction: 'guild', opponentFaction: 'emperor',
const occupy = ecazOccupyFixture({ initial, expansions, ecazTreachery: true,
allyFaction, opponentFaction: 'emperor',
ecazForces: options.ecazForces ?? 5, allyForces: options.allyForces ?? 4,
...(options.allyElite === undefined ? {} : { allyElite: options.allyElite }),
...(options.opponentForces === undefined ? {} : { opponentForces: options.opponentForces }),
...(options.opponentElite === undefined ? {} : { opponentElite: options.opponentElite }) });
// Isolate selected battle cards after genuine setup/auctions, before the
+131
View File
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction, viewGame} from '../game/engine';
import {mirrorWeaponModeBlock} from '../game/mirror-weapon-mode';
import {STONE_CARD, MIRROR_CARD, stoneHarassOccupyFixture, takeStoneHarassCard,
sealStoneHarassPlans, finishStoneHarassBattle} from './fixture-stone-harass';
import {IX_TOOTH, ixRevealedWeaponFixture, revealIxWeapons,
finishIxWeaponBattle} from './fixture-ix-revealed-weapons';
void test('native Basic and Advanced Ix or Tleilaxu copied Stone keeps copy-first choices and physical custody in either slot', () => {
for (const advanced of [false, true]) for (const native of ['ixians', 'tleilaxu'] as const)
for (const holder of ['a', 'd'] as const) {
const source = ixRevealedWeaponFixture({advanced, native, holder, kind: 'mirrorStone'});
let game = revealIxWeapons(source);
assert.equal(game.decision?.kind, 'stoneBurner');
assert.equal(game.decision?.player, holder);
assert.equal(game.decision?.physicalId, MIRROR_CARD);
assert.equal(game.decision?.copiedFrom, STONE_CARD);
game = applyAction(game, holder, {type: 'decision', event: game.decision!.event, mode: 'ignore'});
assert.equal(game.decision?.kind, 'stoneBurner');
assert.equal(game.decision?.player, source.other);
game = applyAction(game, source.other, {type: 'decision', event: game.decision!.event, mode: 'kill'});
game = finishIxWeaponBattle(game);
assert.equal(game.lastBattleContext?.winner, 'a', 'four native undialed counters beat zero ordinary counters');
assert.equal(game.players.find(player => player.id === holder)!.hand.some(card => card.id === MIRROR_CARD), holder === 'a');
assert.equal(game.discard.filter(card => card.id === MIRROR_CARD).length, holder === 'a' ? 0 : 1);
for (const actor of ['a', 'd']) {
const selected = source.plans.find(step => step.actor === actor)!.action.leader;
assert.equal(game.players.find(player => player.id === actor)!.leaders.find(leader => leader.id === selected)!.dead, true);
}
}
});
void test('native Ix-deck Mirror activates copied Tooth first without converting physical Mirror into a mandatory discard', () => {
for (const advanced of [false, true]) for (const native of ['ixians', 'tleilaxu'] as const)
for (const holder of ['a', 'd'] as const) {
const source = ixRevealedWeaponFixture({advanced, native, holder, kind: 'mirrorTooth'});
let game = revealIxWeapons(source);
assert.equal(game.decision?.kind, 'poisonTooth');
assert.equal(game.decision?.player, holder);
assert.equal(game.decision?.physicalId, MIRROR_CARD);
game = applyAction(game, holder, {type: 'decision', event: game.decision!.event, activate: true});
assert.ok(game.decision?.kind === 'poisonTooth');
assert.equal(game.decision?.player, source.other);
assert.equal(game.decision?.physicalId, IX_TOOTH);
game = applyAction(game, source.other, {type: 'decision', event: game.decision!.event, activate: false});
assert.equal(game.battle!.poisonTooth![holder], true);
assert.equal(game.battle!.poisonTooth![source.other], false);
game = finishIxWeaponBattle(game);
assert.equal(game.lastBattleContext?.winner, 'd', 'after both leaders die the original ordinary dial5 beats native zero');
assert.equal(game.players.find(player => player.id === holder)!.hand.some(card => card.id === MIRROR_CARD), holder === 'd');
assert.equal(game.players.find(player => player.id === source.other)!.hand.some(card => card.id === IX_TOOTH), source.other === 'd');
for (const actor of ['a', 'd']) {
const selected = source.plans.find(step => step.actor === actor)!.action.leader;
assert.equal(game.players.find(player => player.id === actor)!.leaders.find(leader => leader.id === selected)!.dead, true);
}
}
});
void test('original Ix-deck Stone precedes uncopied Tooth under the existing provisional policy, without forcing Tooth activation', () => {
for (const advanced of [false, true]) for (const native of ['ixians', 'tleilaxu'] as const)
for (const holder of ['a', 'd'] as const) {
const source = ixRevealedWeaponFixture({advanced, native, holder, kind: 'stoneTooth'});
let game = revealIxWeapons(source);
assert.equal(game.decision?.kind, 'stoneBurner');
assert.equal(game.decision?.player, holder);
game = applyAction(game, holder, {type: 'decision', event: game.decision!.event, mode: 'kill'});
assert.equal(game.decision?.kind, 'poisonTooth');
assert.equal(game.decision?.player, source.other);
game = applyAction(game, source.other, {type: 'decision', event: game.decision!.event, activate: false});
game = finishIxWeaponBattle(game);
assert.equal(game.lastBattleContext?.winner, 'a');
assert.equal(game.players.find(player => player.id === source.other)!.hand.some(card => card.id === IX_TOOTH), source.other === 'a');
assert.equal(game.discard.filter(card => card.id === IX_TOOTH).length, source.other === 'a' ? 0 : 1);
}
});
void test('Ix public precommit still rejects winner-changing cyborg allocations independently of the hidden opposing plan', () => {
const source = ixRevealedWeaponFixture({advanced: true, kind: 'stoneTooth'});
const own = {...source.plans[0].action, dial: 2, support: 0};
const messages: string[] = [];
for (const dial of [0, 5]) {
const game = applyAction(source.game, 'd', {...source.plans[1].action, dial, support: dial});
const before = structuredClone(game);
assert.throws(() => applyAction(game, 'a', own), error => {
assert.match(String(error), /change the winner/);
messages.push(String(error));
return true;
});
assert.deepEqual(game, before);
}
assert.equal(messages[0], messages[1]);
});
void test('new Ix admission leaves optional-module and Basic Ecaz limits closed while retaining the existing non-Ix band', () => {
const source = ixRevealedWeaponFixture().game;
assert.equal(mirrorWeaponModeBlock(source), null);
for (const moduleName of ['homeworlds', 'nexusCards', 'leaderSkills', 'discoveries', 'techTokens', 'strongholdCards'] as const)
assert.notEqual(mirrorWeaponModeBlock({...source, [moduleName]: {}}), null);
assert.notEqual(mirrorWeaponModeBlock({...source, sandtrout: true}), null);
assert.notEqual(mirrorWeaponModeBlock({...source, expansions: ['ix']}), null);
assert.notEqual(mirrorWeaponModeBlock({...source, expansions: ['ix', 'choam', 'ecaz'], advanced: false}), null);
assert.equal(mirrorWeaponModeBlock({expansions: ['choam'], advanced: true,
players: [{faction: 'emperor'}, {faction: 'richese'}, {faction: 'guild'}], techTokens: {}, strongholdCards: {}}), null);
});
void test('native Ixian or Tleilaxu Occupy keeps fixed remainder, copied Stone queue and actual card-user reserve cost separate', () => {
for (const allyFaction of ['ixians', 'tleilaxu'] as const) for (const lead of ['ecaz', 'ally'] as const) {
const source = stoneHarassOccupyFixture({allyFaction, lead, harass: false, stone: 'opponent', mirror: true,
variableDial: 0, support: 0, allyForces: 4, ...(allyFaction === 'ixians' ? {allyElite: 2} : {}), opponentForces: 5});
takeStoneHarassCard(source.game, source.lead, 'ecaz-reinforcements');
source.plans.find(step => step.actor === source.lead)!.action.defense = 'ecaz-reinforcements';
const originalReserves = source.game.players.find(player => player.id === source.lead)!.reserves;
// Natural Storm changes public tie priority. Make the original Stone
// finishable for every mixed allocation, never just the sealed zero dial.
const opponentPlan = source.plans.find(step => step.actor === source.opponent)!.action;
const fullCommitment = viewGame(source.game, source.opponent).battle!.tieWinner !== source.opponent;
opponentPlan.dial = fullCommitment ? 5 : 0;
opponentPlan.support = fullCommitment ? 5 : 0;
let game = sealStoneHarassPlans(source);
assert.equal(game.decision?.player, source.lead, 'the selected lead physically owns Mirror and chooses the copy first');
game = finishStoneHarassBattle(game, {modes: {[source.lead]: 'ignore', [source.opponent]: 'ignore'}});
assert.equal(game.lastBattleContext?.winner, source.lead, 'the public-safe original plan leaves fewer undialed counters than the coalition');
assert.equal(game.players.find(player => player.id === source.lead)!.hand.some(card => card.id === MIRROR_CARD), true);
assert.equal(game.players.find(player => player.id === source.ecaz)!.tanks, lead === 'ecaz' ? 6 : 3);
assert.equal(game.players.find(player => player.id === source.ally)!.tanks, lead === 'ally' ? 3 : 0);
assert.equal(game.players.find(player => player.id === source.lead)!.reserves, originalReserves - 3);
assert.equal(game.discard.filter(card => card.id === 'ecaz-reinforcements').length, 1);
assert.equal(viewGame(game, source.lead).battle, null);
}
});
+1 -6
View File
@@ -128,7 +128,7 @@ const inventory = (g: Game) =>
.map((c) => c.id)
.sort();
void test('forged Stone role and public Ix timing reject before any hidden plan commitment', () => {
void test('forged Stone roles reject before any hidden plan commitment', () => {
for (const patch of [
{ id: 'forged-stone' },
{ name: 'Other Stone' },
@@ -145,11 +145,6 @@ void test('forged Stone role and public Ix timing reject before any hidden plan
);
assert.deepEqual(g, before);
}
let ix = fixture();
ix.expansions.push('ix');
ix = prepare(start(ix));
assert.throws(() => plan(ix, 'r'), /Ix expansion/);
assert.deepEqual(ix.battle!.plans, {});
});
void test('Advanced Emperor outcome-changing allocation is rejected identically before or after an unrelated hidden opposing plan', () => {