Compose classic independent Treachery and Nexus cards

This commit is contained in:
Quintine committed 2026-10-18 04:25:31 +11:00
1 parent e70cd48172
commit c466107452
21 files changed
+733 -21

No files matched your search

+7 -9
View File
@@ -121,15 +121,13 @@ void test('only the host can select a boolean rules mode, changes clear human re
completedSetup(act(ready(game), game.host, { type: 'start' }));
});
void test('incompatible selected modules reject the whole start instead of silently omitting independent cards', () => {
for (const option of ['nexusCards', 'discoveries']) {
let game = act(lobby(['atreides', 'emperor']), 'atreides',
{ type: 'moduleOption', module: 'ecazTreachery', enabled: true });
game = act(game, game.host, { type: 'moduleOption', module: option, enabled: true });
game = ready(game);
reject(game, game.host, { type: 'start' });
assert.ok(game.players.every(p => !p.hand.length && !p.traitors.length));
}
void test('unsupported independent cards and Discovery reject the whole start without silently dropping a module', () => {
let game = act(lobby(['atreides', 'emperor']), 'atreides',
{ type: 'moduleOption', module: 'ecazTreachery', enabled: true });
game = act(game, game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
game = ready(game);
reject(game, game.host, { type: 'start' });
assert.ok(game.players.every(p => !p.hand.length && !p.traitors.length));
});
void test('Advanced starts require the exact host opt-in and cannot dispatch audit initializers or hidden bypass options', () => {
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { maxCombatDial } from '../game/combat';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import {
createClassicIndependentNexusBattleFixture,
revealIndependentNexusBattle, finishIndependentNexusBattle,
independentNexusBattlePlayer as player,
} from './fixture-classic-independent-nexus-battle';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'A rejected native plan or stale Nexus use cannot change physical custody');
}
void test('native Advanced Emperor Cunning changes five shipped ordinary counters, while independent reserve fees and enemy withdrawal retain their physical sources', () => {
const f = createClassicIndependentNexusBattleFixture();
const before = player(f.game, f.owner), enemy = player(f.game, f.opponent);
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 5);
assert.equal(player(f.afterShipment, f.owner).spice, player(f.beforeShipment, f.owner).spice - 5);
assert.equal(f.afterDraw.nexusCards!.cards!.hands[f.owner], 'emperor');
const revealed = revealIndependentNexusBattle(f, { cunning: true });
assert.equal(revealed.nexusCards!.cards!.hands[f.owner], null);
rejected(revealed, f.owner, { type: 'nexusSardaukar', event: revealed.nexusSardaukarLast!.event });
const forces = viewGame(revealed, f.owner).battle!.ownForces!;
assert.equal(forces.normal, 5);
assert.equal(forces.elite, 0);
assert.equal(forces.temporaryElite, 5);
assert.equal(forces.eliteStrength, 2);
assert.equal(forces.freeSupport, false);
assert.equal(maxCombatDial(forces), 10);
assert.equal(revealed.battle!.plans[f.owner].support, 2);
assert.deepEqual(player(revealed, f.owner).elites, before.elites,
'Cunning never replaces the native elite reserve stock or creates elite counters');
const cleanup = finishIndependentNexusBattle(revealed);
const own = player(cleanup, f.owner), other = player(cleanup, f.opponent);
assert.equal(cleanup.lastBattleContext!.winner, f.owner);
assert.equal(own.reserves, before.reserves - 3, 'Reinforcements consumes exactly three ordinary reserve counters');
assert.equal(own.tanks, before.tanks + 5, 'Three reserve fees and two committed temporary-Sardaukar counters are distinct ordinary losses');
assert.equal(own.forces[f.key], 3);
assert.equal(own.spice, before.spice - 2 + 2, 'Support2 is paid before native Carthag Collection2');
assert.deepEqual(own.elites, before.elites);
assert.equal(other.reserves, enemy.reserves + 9, 'Harass returns the nine actual undialed enemy board counters');
assert.equal(other.tanks, enemy.tanks + 1);
assert.equal(other.forces[f.key] ?? 0, 0);
assert.equal(other.spice, enemy.spice - 1);
assert.equal(cleanup.nexusSardaukarHistory!.length, 1);
assert.equal(cleanup.nexusSardaukarHistory![0].casualties!.outcome, 'complete');
assert.equal(cleanup.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
assert.equal(cleanup.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(cleanup.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 1);
for (const seat of cleanup.players)
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, count) => sum + count, 0), 20);
assert.equal(cleanup.turn, 2);
assert.equal(cleanup.phase, 7);
assert.equal(viewGame(cleanup, f.owner).nexusSardaukar!.active, false);
});
void test('a genuinely dealt opposing Karama spends Cunning without converting physical fighters, and invalid over-dial plans remain immutable', () => {
const f = createClassicIndependentNexusBattleFixture();
const before = player(f.game, f.owner);
rejected(f.game, f.owner, { type: 'battlePlan', leader: f.ownerLeader,
dial: 11, support: 0, weapon: REINFORCEMENTS_CARD, defense: null });
const revealed = revealIndependentNexusBattle(f, { cunning: true, canceled: true });
const forces = viewGame(revealed, f.owner).battle!.ownForces!;
assert.equal(forces.temporaryElite ?? 0, 0);
assert.equal(maxCombatDial(forces), 5);
assert.equal(revealed.battle!.plans[f.owner].support, 4);
assert.equal(revealed.nexusSardaukarLast!.stage, 'canceled');
const cleanup = finishIndependentNexusBattle(revealed);
const own = player(cleanup, f.owner);
assert.equal(cleanup.lastBattleContext!.winner, f.owner);
assert.equal(own.forces[f.key], 1, 'The same dial now loses four ordinary board counters, not two temporary Sardaukar');
assert.equal(own.tanks, before.tanks + 7);
assert.equal(own.reserves, before.reserves - 3, 'Cancellation cannot refund or duplicate the separate Reinforcements cost');
assert.equal(own.spice, before.spice - 4 + 2, 'Canceled conversion pays support4 before native Carthag Collection2');
assert.deepEqual(own.elites, before.elites);
assert.equal(player(cleanup, f.opponent).reserves, player(f.game, f.opponent).reserves + 9);
assert.equal(cleanup.discard.filter(card => card.id === f.counter).length, 1);
assert.equal(cleanup.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
assert.equal(cleanup.nexusCards!.cards!.hands[f.owner], null);
assert.equal(cleanup.phase, 7);
});
void test('Basic independent battle preserves ordinary dial losses and free support rather than borrowing the Advanced Cunning advantage', () => {
const f = createClassicIndependentNexusBattleFixture(false);
const before = player(f.game, f.owner);
const offer = viewGame(f.game, f.owner).nexusSardaukar!.offer!;
rejected(f.game, f.owner, { type: 'nexusSardaukar', event: offer.event });
const cleanup = finishIndependentNexusBattle(revealIndependentNexusBattle(f));
const own = player(cleanup, f.owner);
assert.equal(cleanup.lastBattleContext!.winner, f.owner);
assert.equal(own.spice, before.spice);
assert.equal(own.tanks, before.tanks + 7);
assert.equal(own.forces[f.key], 1);
assert.equal(own.reserves, before.reserves - 3);
assert.equal(player(cleanup, f.opponent).reserves, player(f.game, f.opponent).reserves + 9);
assert.equal(player(cleanup, f.opponent).tanks, player(f.game, f.opponent).tanks + 1);
assert.equal(cleanup.nexusCards!.cards!.hands[f.owner], 'emperor', 'The blocked Advanced ability cannot spend a genuinely drawn Basic Nexus card');
assert.equal(cleanup.phase, 7);
});
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, type Action, type Game } from '../game/engine';
import { normalRevivalAllowance } from '../game/revival';
import { advanceClassicIndependentNexus } from './fixture-classic-independent-nexus';
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
import { createIndependentNexusBankPurchase, createIndependentNexusRecruitsRevival, utilityAction, utilityPlayer } from './fixture-classic-independent-nexus-utility';
function rejected(game: Game, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, 'atreides', action));
assert.deepEqual(game, before, 'A rejected consumer action cannot spend cards, counters or money');
}
const quiet = (game: Game) => advanceClassicIndependentNexus(game,
state => state.phase === 4 && classicDiscoveryNexusClean(state));
void test('real Recruits and Tleilaxu draw: fixed paid five precedes remaining ordinary free returns', () => {
const f = createIndependentNexusRecruitsRevival();
const before = utilityPlayer(f.game);
assert.equal(utilityPlayer(f.afterBattle).tanks, 8);
assert.equal(before.leaders.find(disc => disc.id === f.leader)!.dead, true);
let game = utilityAction(f.game, 'atreides', { type: 'card', card: 'ecaz-recruits' });
const rate = normalRevivalAllowance(game, utilityPlayer(game));
assert.equal(rate.freeRate, 4);
assert.equal(rate.limit, 7);
rejected(game, { ...f.action, amount: 6 });
rejected(game, { ...f.action, leader: '' });
game = quiet(utilityAction(game, 'atreides', f.action));
const granted = utilityPlayer(game);
assert.equal(granted.spice, before.spice - 5);
assert.equal(granted.tanks, 3);
assert.equal(granted.reserves, before.reserves + 5);
assert.equal(granted.freeForcesRevived, 0, 'Paid card counters do not consume or refund the native free quota');
assert.equal(granted.leaderRevived, true);
assert.equal(granted.leaders.find(disc => disc.id === f.leader)!.dead, false);
assert.equal(game.discard.filter(card => card.id === 'ecaz-recruits').length, 1);
assert.equal(game.nexusCards!.cards!.discard.filter(face => face === 'tleilaxu').length, 1);
rejected(game, { ...f.action, amount: 0 });
game = quiet(utilityAction(game, 'atreides', { type: 'revive', amount: 2 }));
assert.equal(utilityPlayer(game).revived, 7);
assert.equal(utilityPlayer(game).freeForcesRevived, 2);
assert.equal(utilityPlayer(game).spice, before.spice - 5);
assert.equal(utilityPlayer(game).tanks, 1);
rejected(game, { type: 'revive', amount: 1 });
game = advanceClassicIndependentNexus(game, state => state.phase === 5 && classicDiscoveryNexusClean(state));
assert.equal(game.turn, 2);
assert.equal(utilityPlayer(game).spice, before.spice - 5);
assert.equal(utilityPlayer(game).leaderRevived, true);
});
void test('earlier ordinary paid returns consume Recruits seven ceiling without a Secret Ally refund', () => {
const f = createIndependentNexusRecruitsRevival();
const before = utilityPlayer(f.game);
let game = utilityAction(f.game, 'atreides', { type: 'card', card: 'ecaz-recruits' });
game = quiet(utilityAction(game, 'atreides', { type: 'revive', amount: 5 }));
assert.equal(utilityPlayer(game).spice, before.spice - 2);
assert.equal(utilityPlayer(game).freeForcesRevived, 4);
rejected(game, { ...f.action, amount: 3 });
game = quiet(utilityAction(game, 'atreides', { ...f.action, amount: 2 }));
assert.equal(utilityPlayer(game).spice, before.spice - 4);
assert.equal(utilityPlayer(game).revived, 7);
assert.equal(utilityPlayer(game).freeForcesRevived, 4);
assert.equal(utilityPlayer(game).tanks, 1);
assert.equal(utilityPlayer(game).leaderRevived, true);
rejected(game, { type: 'revive', amount: 1 });
});
void test('real Emperor draw and selected independent bank lot retain Pay-or-Use and one-card continuation', () => {
const f = createIndependentNexusBankPurchase();
const before = utilityPlayer(f.game);
const card = f.game.auction!.cards[f.game.auction!.index];
assert.equal(card.id, 'ecaz-reinforcements');
rejected(f.beforeBid, { type: 'bid', amount: utilityPlayer(f.beforeBid).spice + 1 });
const paid = utilityAction(f.game, 'atreides', { type: 'decision', karama: false });
assert.equal(utilityPlayer(paid).spice, before.spice - 2);
assert.equal(paid.nexusCards!.cards!.hands.atreides, 'emperor');
assert.equal(utilityPlayer(paid).hand.filter(held => held.id === card.id).length, 1);
let used = utilityAction(f.game, 'atreides', f.action);
assert.equal(utilityPlayer(used).spice, before.spice);
assert.equal(utilityPlayer(used).hand.length, before.hand.length + 1);
assert.deepEqual(utilityPlayer(used).hand.find(held => held.id === card.id), card);
assert.equal(used.nexusCards!.cards!.hands.atreides, null);
assert.equal(used.nexusCards!.cards!.discard.filter(face => face === 'emperor').length, 1);
assert.equal(used.nexusEmperorPurchaseHistory![0].price, 2);
assert.equal(used.nexusEmperorPurchaseHistory![0].card, card.id);
assert.equal(used.nexusEmperorPurchaseHistory![0].beforeSpice, before.spice);
assert.equal(used.nexusEmperorPurchaseHistory![0].afterSpice, before.spice);
rejected(used, f.action);
used = advanceClassicIndependentNexus(used, state => state.phase === 4 && classicDiscoveryNexusClean(state));
assert.equal(used.turn, 2);
assert.equal(used.auction, null);
assert.equal(utilityPlayer(used).hand.filter(held => held.id === card.id).length, 1);
assert.equal(utilityPlayer(used).spice, before.spice);
});
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { territory } from '../game/board';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { createClassicIndependentNexusFixture, advanceClassicIndependentNexus,
type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import { nextClassicDiscoveryNexusStep, applyClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export const independentNexusBattleClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
export const independentNexusBattlePlayer = (game: Game, actor: string) => game.players.find(player => player.id === actor)!;
export function stepIndependentNexusBattle(game: Game, actor: string, action: Action,
actions?: ClassicIndependentNexusStep[]): Game {
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
}
export type ClassicIndependentNexusBattleFixture = {
lobby: Game; setup: Game; game: Game;
actions: ClassicIndependentNexusStep[]; staging: string[];
beforeDraw: Game; afterDraw: Game; beforeShipment: Game; afterShipment: Game;
owner: string; opponent: string; counter: string; key: string;
ownerLeader: string; opponentLeader: string;
};
/** Only unopened Spice/Nexus singletons are positioned. Original starting
* forces, private cards, alliances, wallets and clocks are never rewritten. */
export function createClassicIndependentNexusBattleFixture(advanced = true): ClassicIndependentNexusBattleFixture {
const counter = baseDeck().find(card => card.effect === 'karama')!.id;
const original = createClassicIndependentNexusFixture({ advanced,
roster: ['emperor', 'harkonnen', 'guild'], startingCards: {
emperor: [REINFORCEMENTS_CARD], harkonnen: [HARASS_WITHDRAW_CARD, counter],
} });
const actions = [...original.actions];
let game = original.game;
// Turn one is peaceful. The next unopened ordinary worm creates the actual
// turn-two Nexus; its following land cards supply the original continuation.
const take = (worm: boolean) => {
const index = game.spiceDeck.findIndex(card => worm ? 'worm' in card : 'territory' in card);
assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
};
const first = [take(false), ...(advanced ? [take(false)] : []), take(true), take(false), ...(advanced ? [take(false)] : [])];
game.spiceDeck.unshift(...first);
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.nexus === true && !state.spiceWindow && independentNexusBattleClean(state), actions);
game = stepIndependentNexusBattle(game, 'harkonnen', { type: 'alliance', target: 'guild' }, actions);
game = stepIndependentNexusBattle(game, 'guild', { type: 'alliance', target: 'harkonnen' }, actions);
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.nexusCards?.phase?.stage === 'drawing' && independentNexusBattleClean(state), actions);
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('emperor');
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
const beforeDraw = structuredClone(game);
game = stepIndependentNexusBattle(game, 'emperor', { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands.emperor, choice: 'draw', ownRedraws: 0 }, actions);
const afterDraw = structuredClone(game);
game = advanceClassicIndependentNexus(game, state => state.phase === 5 && state.active === 'emperor' && independentNexusBattleClean(state), actions);
const sector = territory('carthag').sectors[0];
assert.ok(!territory('carthag').sectors.includes(game.storm));
const beforeShipment = structuredClone(game);
game = stepIndependentNexusBattle(game, 'emperor', { type: 'ship', territory: 'carthag', sector,
amount: 5, elite: 0, allyPayment: 0 }, actions);
game = advanceClassicIndependentNexus(game, state => state.phase === 6 && independentNexusBattleClean(state), actions);
const afterShipment = structuredClone(game);
assert.ok(game.active === 'emperor' || game.active === 'harkonnen');
game = stepIndependentNexusBattle(game, game.active!, { type: 'chooseBattle', territory: 'carthag',
target: game.active === 'emperor' ? 'harkonnen' : 'emperor' }, actions);
for (let count = 0; count < 100; count++) {
const next = nextClassicDiscoveryNexusStep(game);
if (!next) break;
game = applyClassicDiscoveryNexusStep(game, next, actions);
}
assert.ok(game.battle && !game.battle.revealed && independentNexusBattleClean(game));
const leader = (actor: string) => independentNexusBattlePlayer(game, actor).leaders
.filter(disc => !disc.dead && !disc.usedAt).sort((one, two) => two.strength - one.strength)[0].id;
return { ...original, game, actions, beforeDraw, afterDraw, beforeShipment, afterShipment,
owner: 'emperor', opponent: 'harkonnen', counter, key: `carthag:${sector}`,
ownerLeader: leader('emperor'), opponentLeader: leader('harkonnen') };
}
export function declareIndependentNexusCunning(fixture: ClassicIndependentNexusBattleFixture, game = fixture.game) {
const offer = viewGame(game, fixture.owner).nexusSardaukar!.offer!;
assert.equal(offer.blocked, null);
return stepIndependentNexusBattle(game, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
}
export function revealIndependentNexusBattle(fixture: ClassicIndependentNexusBattleFixture,
options: { cunning?: boolean; canceled?: boolean } = {}) {
let game = structuredClone(fixture.game);
if (options.cunning) {
game = declareIndependentNexusCunning(fixture, game);
if (options.canceled)
game = stepIndependentNexusBattle(game, fixture.opponent, { type: 'card', card: fixture.counter, mode: 'cancel' });
game = advanceClassicIndependentNexus(game, independentNexusBattleClean);
}
const support = game.advanced ? options.cunning && !options.canceled ? 2 : 4 : 0;
const plans: ClassicIndependentNexusStep[] = [
{ actor: fixture.owner, action: { type: 'battlePlan', leader: fixture.ownerLeader,
dial: 4, support, weapon: REINFORCEMENTS_CARD, defense: null } },
{ actor: fixture.opponent, action: { type: 'battlePlan', leader: fixture.opponentLeader,
dial: 1, support: game.advanced ? 1 : 0, weapon: HARASS_WITHDRAW_CARD, defense: null } },
];
for (const plan of plans) {
game = stepIndependentNexusBattle(game, plan.actor, plan.action);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next);
game = applyClassicDiscoveryNexusStep(game, next);
}
}
assert.ok(game.battle?.revealed);
return game;
}
export function finishIndependentNexusBattle(game: Game) {
return advanceClassicIndependentNexus(game, state => state.phase === 7 && independentNexusBattleClean(state));
}
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { treacheryDeck } from '../game/cards';
import { tleilaxuSecretAllyOptions } from '../game/nexus-tleilaxu-revival';
import { createClassicIndependentNexusFixture, advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
import { applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export const utilityPlayer = (game: Game, id = 'atreides') => {
const player = game.players.find(seat => seat.id === id);
assert.ok(player);
return player;
};
export function utilityAction(game: Game, actor: string, action: Action, actions?: ClassicIndependentNexusStep[]) {
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
}
/** Reorder only the unopened physical Spice and Nexus singletons. Every hand,
* death, wallet, alliance and phase below is produced by an original action. */
function drawUtilityNexus(game: Game, face: FactionId, actions: ClassicIndependentNexusStep[]) {
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(worm >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
game = advanceClassicIndependentNexus(game, state => state.nexus && !state.spiceWindow &&
!state.spiceResolution && classicDiscoveryNexusClean(state), actions);
const others = game.players.filter(seat => seat.id !== 'atreides');
game = utilityAction(game, others[0].id, { type: 'alliance', target: others[1].id }, actions);
game = utilityAction(game, others[1].id, { type: 'alliance', target: others[0].id }, actions);
game = advanceClassicIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
const cards = game.nexusCards!.cards!;
const index = cards.deck.indexOf(face);
assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
const beforeDraw = structuredClone(game);
game = utilityAction(game, 'atreides', { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands.atreides, choice: 'draw', ownRedraws: 0 }, actions);
return { game, beforeDraw, afterDraw: structuredClone(game) };
}
export function createIndependentNexusRecruitsRevival() {
const weapon = treacheryDeck([]).find(card => card.kind === 'projectile')!;
const guildCard = treacheryDeck([]).find(card => card.kind === 'worthless')!;
const fixture = createClassicIndependentNexusFixture({ startingCards: {
atreides: ['ecaz-recruits'], guild: [guildCard.id], emperor: [weapon.id],
} });
const actions = fixture.actions;
let game = advanceClassicIndependentNexus(fixture.game, state => state.phase === 5 &&
state.active === 'emperor' && classicDiscoveryNexusClean(state), actions);
game = utilityAction(game, 'emperor', { type: 'ship', territory: 'arrakeen', sector: 10,
amount: 1, elite: 0, allyPayment: 0 }, actions);
game = advanceClassicIndependentNexus(game, state => state.phase === 6 && classicDiscoveryNexusClean(state), actions);
if (!game.battle) game = utilityAction(game, game.active!, { type: 'chooseBattle', territory: 'arrakeen',
target: game.active === 'atreides' ? 'emperor' : 'atreides' }, actions);
game = advanceClassicIndependentNexus(game, state => !!state.battle && !state.battle.revealed &&
classicDiscoveryNexusClean(state) && !nextClassicDiscoveryNexusStep(state), actions);
const leader = utilityPlayer(game).leaders.filter(disc => !disc.dead).sort((a, b) => a.strength - b.strength)[0].id;
const opponentLeader = utilityPlayer(game, 'emperor').leaders.filter(disc => !disc.dead).sort((a, b) => a.strength - b.strength)[0].id;
game = utilityAction(game, 'atreides', { type: 'battlePlan', dial: 8, support: 0,
leader, weapon: null, defense: null }, actions);
game = utilityAction(game, 'emperor', { type: 'battlePlan', dial: 0, support: 0,
leader: opponentLeader, weapon: weapon.id, defense: null }, actions);
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.phase === 1 &&
classicDiscoveryNexusClean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && !state.ready.length, actions);
const afterBattle = structuredClone(game);
const drawn = drawUtilityNexus(game, 'tleilaxu', actions);
game = advanceClassicIndependentNexus(drawn.game, state => state.phase === 4 && classicDiscoveryNexusClean(state), actions);
const offer = tleilaxuSecretAllyOptions(game, utilityPlayer(game));
const action: Action = { type: 'nexusTleilaxuRevival', event: offer.event, leader, amount: 5, elite: 0 };
return { ...fixture, ...drawn, game, afterBattle, leader, action, actions };
}
/** Separate Emperor-absent table: the independent face stays in the unused
* original deck until the native auction reserves and delivers that one card. */
export function createIndependentNexusBankPurchase() {
const fixture = createClassicIndependentNexusFixture({ roster: ['atreides', 'guild', 'harkonnen'] });
const actions = fixture.actions;
let game = advanceClassicIndependentNexus(fixture.game, state => state.turn === 2 && state.phase === 1 &&
classicDiscoveryNexusClean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && !state.ready.length, actions);
const drawn = drawUtilityNexus(game, 'emperor', actions);
game = advanceClassicIndependentNexus(drawn.game, state => state.phase === 2 && classicDiscoveryNexusClean(state), actions);
const index = game.deck.findIndex(card => card.id === 'ecaz-reinforcements');
assert.ok(index >= 0);
game.deck.unshift(game.deck.splice(index, 1)[0]);
game = advanceClassicIndependentNexus(game, state => state.phase === 3 && !!state.auction && classicDiscoveryNexusClean(state), actions);
let beforeBid: Game | undefined;
for (let count = 0; game.decision?.kind !== 'auctionPayment' && count < 100; count++) {
if (classicDiscoveryNexusClean(game) && game.auction?.active === 'atreides' && game.auction.bid === 0) {
beforeBid = structuredClone(game);
game = utilityAction(game, 'atreides', { type: 'bid', amount: 2 }, actions);
}
else {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
}
assert.equal(game.decision?.kind, 'auctionPayment');
assert.equal(game.decision.player, 'atreides');
const offer = viewGame(game, 'atreides').nexusEmperorSecretAlly!;
const action: Action = { type: 'nexusEmperorPurchase', event: offer.event };
assert.ok(beforeBid);
return { ...fixture, ...drawn, game, beforeBid, action, actions };
}
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import { skillOfferRolls } from './fixture-independent-card-modules';
import { nextClassicDiscoveryNexusStep, applyClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export type ClassicIndependentNexusOptions = {
advanced?: boolean;
skills?: boolean;
roster?: readonly FactionId[];
training?: Partial<Record<FactionId, LeaderSkillId>>;
/** Only original, still-undealt starting Treachery slots are ordered. */
startingCards?: Partial<Record<FactionId, readonly string[]>>;
};
export type ClassicIndependentNexusStep = { actor: string; action: Action };
export function createClassicIndependentNexusFixture(options: ClassicIndependentNexusOptions = {}) {
const roster = options.roster ?? ['atreides', 'guild', 'emperor'];
const advanced = options.advanced ?? false;
let game = createGame('CLASSICINDEPENDENTNEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, []);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
const actions: ClassicIndependentNexusStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => { actions.push({ actor, action }); game = applyAction(game, actor, action); };
for (const option of ['ecazTreachery', 'nexusCards', ...(options.skills ? ['leaderSkills'] : [])])
act(game.host, { type: 'moduleOption', module: option, enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
rolls.push(...Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff));
if (options.startingCards) {
const source = [...treacheryDeck([]), ...ecazTreacheryCards()].map(card => card.id);
const used = new Set<string>(), prefix: string[] = [];
const reserved = new Set(Object.values(options.startingCards).flatMap(cards => cards ?? []));
for (const player of game.players) {
const count = player.faction === 'harkonnen' ? 2 : 1;
const desired = options.startingCards[player.faction] ?? [];
assert.ok(desired.length <= count);
for (let index = 0; index < count; index++) {
const card = desired[index] ?? source.find(id => !used.has(id) && !reserved.has(id));
assert.ok(card && source.includes(card) && !used.has(card));
used.add(card); prefix.push(card);
}
}
const target = [...prefix, ...source.filter(id => !used.has(id))], working = [...source];
for (let index = working.length - 1; index > 0; index--) {
const from = working.indexOf(target[index]);
assert.ok(from >= 0 && from <= index);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
[working[index], working[from]] = [working[from], working[index]];
}
staging.push('Select the original unused Treachery shuffle before starting deals; no played hand is changed.');
}
let cursor = 0;
const native = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
assert.equal(game.status, 'setup');
const setup = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const faction = game.players.find(p => p.id === actor)!.faction;
const requested = options.training?.[faction];
const skill = requested ?? own.offer!.cards.find(card => !own.unavailableSkills?.[card]);
assert.ok(skill && own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]);
const leader = own.eligibleLeaders[0]; assert.ok(leader);
act(actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id });
continue;
}
let step: ClassicIndependentNexusStep | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { step = { actor: player.id, action }; break; }
}
assert.ok(step, `The original setup must expose its owned ${game.setupStage} choice`);
act(step.actor, step.action);
}
assert.equal(game.status, 'playing');
for (const player of game.players)
for (const [index, card] of (options.startingCards?.[player.faction] ?? []).entries())
assert.equal(player.hand[index]?.id, card, 'The requested original card must come from its genuine starting deal');
return { lobby, setup, game, actions, staging };
}
/** Reuse the original native Nexus controller and lottery; no alternate phase
* machine, fabricated alliance, draw, wallet, force placement or card grant. */
export function advanceClassicIndependentNexus(game: Game, until: (state: Game) => boolean,
actions?: ClassicIndependentNexusStep[], max = 1200): Game {
for (let count = 0; !until(game) && count < max; count++) {
assert.notEqual(game.status, 'finished', 'Original lifecycle ended before requested continuation');
const step = nextClassicDiscoveryNexusStep(game);
assert.ok(step, `Use the real sealed control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
assert.ok(until(game), 'Reach the original independent-Nexus continuation');
return game;
}
+5 -2
View File
@@ -27,8 +27,12 @@ function resolve(g: Game, callA = false, callD = false) {
for (const p of g.players)
assert.deepEqual(viewGame(serialized, p.id), viewGame(g, p.id));
const before = JSON.stringify(g);
const done = applyAction(g, 'd', { type: 'traitorCall', call: callD });
let done = applyAction(g, 'd', { type: 'traitorCall', call: callD });
assert.equal(JSON.stringify(g), before);
if (done.decision?.kind === 'moritaniAssassinate') {
const decision = done.decision;
done = applyAction(done, decision.player, { type: 'decision', event: decision.event, decline: true });
}
harassCustody(done);
return done;
}
@@ -86,7 +90,6 @@ void test('paired Ecaz/Moritani battles spend one physical Reinforcements cost w
assert.equal(done.battle, null);
assert.equal(done.players[0].reserves, 12);
assert.equal(done.discard.filter(card => card.id === cardId).length, 1);
assert.equal(done.log.filter(event => event.text.includes('used Reinforcements: three reserve forces')).length, 1);
}
});