Admit clean Ix Stone and Mirror with existing revealed priorities
This commit is contained in:
1 parent
842fd72761
commit
f50dda6078
16 files changed
+411
-54
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user