Compose classic independent Treachery and Nexus cards
This commit is contained in:
1 parent
e70cd48172
commit
c466107452
21 files changed
+733
-21
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user