Compose classic Nexus with earned phase modules
This commit is contained in:
1 parent
a7cc25ae29
commit
ce716f3438
21 files changed
+875
-31
No files matched your search
@@ -49,7 +49,7 @@ export function drawClassicNexusReaction(face: FactionId, owner: string,
|
||||
return { ...fixture, game, owner, actions, beforeDraw, afterDraw: structuredClone(game) };
|
||||
}
|
||||
|
||||
export function createIndependentHarkonnenCall(options: { advanced?: boolean; skills?: boolean; remote?: boolean; independent?: boolean } = {}) {
|
||||
export function createIndependentHarkonnenCall(options: { advanced?: boolean; skills?: boolean; remote?: boolean; independent?: boolean; tech?: boolean; strongholds?: boolean } = {}) {
|
||||
const counter = baseDeck().find(card => card.effect === 'karama')!.id;
|
||||
const starting = options.independent === false ? baseDeck().find(card => card.kind === 'worthless')!.id : HARASS_WITHDRAW_CARD;
|
||||
const fixture = drawClassicNexusReaction('harkonnen', 'atreides', { ...options,
|
||||
@@ -98,7 +98,7 @@ export function createIndependentHarkonnenCall(options: { advanced?: boolean; sk
|
||||
return { ...fixture, game, beforeCall, beforeShipment, afterShipment, identity, opponent, key, counter };
|
||||
}
|
||||
|
||||
export function createIndependentIxianPurchase(options: { advanced?: boolean; skills?: boolean; owner?: 'atreides' | 'harkonnen'; independent?: boolean } = {}) {
|
||||
export function createIndependentIxianPurchase(options: { advanced?: boolean; skills?: boolean; owner?: 'atreides' | 'harkonnen'; independent?: boolean; tech?: boolean; strongholds?: boolean } = {}) {
|
||||
const owner = options.owner ?? 'atreides';
|
||||
const starting = options.independent === false ? baseDeck().find(card => card.kind === 'worthless')!.id : 'ecaz-recruits';
|
||||
const fixture = drawClassicNexusReaction('ixians', owner, { ...options,
|
||||
|
||||
@@ -14,6 +14,8 @@ export type ClassicIndependentNexusOptions = {
|
||||
skills?: boolean;
|
||||
/** False exercises the original base33 without selecting the independent variant. */
|
||||
independent?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
roster?: readonly FactionId[];
|
||||
training?: Partial<Record<FactionId, LeaderSkillId>>;
|
||||
/** Only original, still-undealt starting Treachery slots are ordered. */
|
||||
@@ -36,6 +38,8 @@ export function createClassicIndependentNexusFixture(options: ClassicIndependent
|
||||
};
|
||||
for (const option of [...(independent ? ['ecazTreachery'] : []), 'nexusCards', ...(options.skills ? ['leaderSkills'] : [])])
|
||||
act(game.host, { type: 'moduleOption', module: option, enabled: true });
|
||||
if (options.tech) act(game.host, { type: 'techTokens', enabled: true });
|
||||
if (options.strongholds) act(game.host, { type: 'strongholdCards', 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) : [];
|
||||
|
||||
@@ -7,7 +7,7 @@ import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixt
|
||||
import { advanceClassicIndependentNexus, type ClassicIndependentNexusOptions, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
|
||||
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type ClassicNexusCoreOptions = Pick<ClassicIndependentNexusOptions, 'advanced' | 'skills' | 'independent' | 'training'>;
|
||||
export type ClassicNexusCoreOptions = Pick<ClassicIndependentNexusOptions, 'advanced' | 'skills' | 'independent' | 'training' | 'tech' | 'strongholds'>;
|
||||
function drawSecondFace(game: Game, owner: string, face: FactionId, actions: ClassicIndependentNexusStep[]) {
|
||||
assert.equal(game.nexusCards!.phase!.stage, 'drawing');
|
||||
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(face);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { territory } from '../game/board';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { createClassicIndependentNexusFixture, advanceClassicIndependentNexus,
|
||||
type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
|
||||
import { drawClassicNexusReaction, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
|
||||
import { classicDiscoveryNexusClean as clean, nextClassicDiscoveryNexusStep,
|
||||
applyClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export { reactionPlayer as nexusCoreStrongholdPlayer };
|
||||
export type NexusCoreStrongholdOptions = { independent?: boolean; skills?: boolean };
|
||||
export type NexusCoreStrongholdFixture = {
|
||||
game: Game; setup: Game; actions: ClassicIndependentNexusStep[]; staging: string[];
|
||||
claimed: Game; beforeShipment: Game; afterShipment: Game;
|
||||
owner: string; opponent: string; key: string; ownerLeader: string; opponentLeader: string;
|
||||
weapon: string | null; defense: string | null;
|
||||
};
|
||||
|
||||
function prepare(game: Game, actions: ClassicIndependentNexusStep[]): Game {
|
||||
for (let count = 0; count < 120; count++) {
|
||||
const next = nextClassicDiscoveryNexusStep(game);
|
||||
if (!next) {
|
||||
assert.ok(game.battle && !game.battle.revealed && clean(game));
|
||||
return game;
|
||||
}
|
||||
// The original decision must put the trained disc behind the shield
|
||||
// before it can be selected in a sealed plan.
|
||||
if (next.action.type === 'leaderSkillVisibility') next.action.hide = true;
|
||||
game = applyClassicDiscoveryNexusStep(game, next, actions);
|
||||
}
|
||||
throw Error('Original Stronghold battle did not reach its sealed-plan boundary');
|
||||
}
|
||||
function bestLeader(game: Game, actor: string): string {
|
||||
const leader = reactionPlayer(game, actor).leaders.filter(disc => !disc.dead && !disc.usedAt)
|
||||
.sort((one, two) => two.strength - one.strength)[0];
|
||||
assert.ok(leader);
|
||||
return leader.id;
|
||||
}
|
||||
function chooseBattle(game: Game, location: string, actions: ClassicIndependentNexusStep[]): Game {
|
||||
return prepare(reactionAction(game, game.active!, { type: 'chooseBattle', territory: location,
|
||||
target: game.active === 'emperor' ? 'harkonnen' : 'emperor' }, actions), actions);
|
||||
}
|
||||
|
||||
/** Emperor genuinely ships five original reserves into empty Arrakeen on turn
|
||||
* one. Only unopened Spice/Nexus decks are ordered; every counter, payment,
|
||||
* alliance and first end-Mentat card claim comes from original actions. */
|
||||
export function createNexusCoreArrakeenFixture(options: NexusCoreStrongholdOptions = {}): NexusCoreStrongholdFixture {
|
||||
const independent = options.independent ?? true;
|
||||
const original = createClassicIndependentNexusFixture({ advanced: true, independent,
|
||||
skills: options.skills, strongholds: true, roster: ['emperor', 'harkonnen', 'guild'],
|
||||
startingCards: independent ? { emperor: [REINFORCEMENTS_CARD] } : undefined });
|
||||
const actions = original.actions;
|
||||
let game = original.game;
|
||||
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];
|
||||
};
|
||||
game.spiceDeck.unshift(take(false), take(false), take(true), take(false), take(false));
|
||||
game = advanceClassicIndependentNexus(game, state => state.turn === 1 && state.phase === 5 &&
|
||||
state.active === 'emperor' && clean(state), actions);
|
||||
const sector = territory('arrakeen').sectors[0], key = `arrakeen:${sector}`;
|
||||
assert.ok(!territory('arrakeen').sectors.includes(game.storm));
|
||||
game = reactionAction(game, 'emperor', { type: 'ship', territory: 'arrakeen', sector,
|
||||
amount: 5, elite: 0, allyPayment: 0 }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.strongholdCards?.claimedTurn === 1, actions);
|
||||
const claimed = structuredClone(game);
|
||||
assert.equal(claimed.strongholdCards!.owners.arrakeen, 'emperor');
|
||||
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.nexus &&
|
||||
!state.spiceWindow && !state.spiceResolution && clean(state), actions);
|
||||
game = reactionAction(game, 'harkonnen', { type: 'alliance', target: 'guild' }, actions);
|
||||
game = reactionAction(game, 'guild', { type: 'alliance', target: 'harkonnen' }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(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]);
|
||||
game = reactionAction(game, 'emperor', { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: cards.hands.emperor, choice: 'draw', ownRedraws: 0 }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.phase === 5 && state.active === 'harkonnen' && clean(state), actions);
|
||||
assert.ok(!territory('arrakeen').sectors.includes(game.storm));
|
||||
const beforeShipment = structuredClone(game);
|
||||
game = reactionAction(game, 'harkonnen', { type: 'ship', territory: 'arrakeen', sector,
|
||||
amount: 5, elite: 0, allyPayment: 0 }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.phase === 6 && clean(state), actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
game = chooseBattle(game, 'arrakeen', actions);
|
||||
return { ...original, game, actions, claimed, beforeShipment, afterShipment,
|
||||
owner: 'emperor', opponent: 'harkonnen', key, ownerLeader: bestLeader(game, 'emperor'),
|
||||
opponentLeader: bestLeader(game, 'harkonnen'), weapon: independent ? REINFORCEMENTS_CARD : null,
|
||||
defense: null };
|
||||
}
|
||||
|
||||
/** Harkonnen retains its genuinely earned Carthag card. Atreides draws the
|
||||
* physical Harkonnen Nexus, ships one counter and plays an actually offered
|
||||
* Harkonnen Traitor. Optional all14 uses the original trained-disc choice. */
|
||||
export function createNexusCoreCarthagFixture(options: NexusCoreStrongholdOptions = {}): NexusCoreStrongholdFixture {
|
||||
const poison = baseDeck().find(card => card.kind === 'poison')!.id;
|
||||
const shield = baseDeck().find(card => card.kind === 'shield')!.id;
|
||||
const original = drawClassicNexusReaction('harkonnen', 'atreides', { advanced: true,
|
||||
independent: options.independent ?? true, skills: options.skills, strongholds: true,
|
||||
training: options.skills ? { harkonnen: 'prana-bindu-adept' } : undefined,
|
||||
startingCards: { atreides: [poison], harkonnen: [shield] } });
|
||||
const actions = original.actions;
|
||||
const claimed = structuredClone(original.beforeDraw);
|
||||
assert.equal(claimed.strongholdCards!.owners.carthag, 'harkonnen');
|
||||
let game = advanceClassicIndependentNexus(original.game, state => state.phase === 5 &&
|
||||
state.active === 'atreides' && clean(state), actions);
|
||||
const opponentLeader = reactionPlayer(game, 'atreides').leaders.find(leader =>
|
||||
reactionPlayer(game, 'harkonnen').traitors.includes(leader.id))?.id;
|
||||
assert.ok(opponentLeader, 'Use the actual offered Traitor, never assign a leader identity');
|
||||
const sector = territory('carthag').sectors[0], key = `carthag:${sector}`;
|
||||
assert.ok(!territory('carthag').sectors.includes(game.storm));
|
||||
const beforeShipment = structuredClone(game);
|
||||
game = reactionAction(game, 'atreides', { type: 'ship', territory: 'carthag', sector,
|
||||
amount: 1, elite: 0, allyPayment: 0 }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.phase === 6 && clean(state), actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
game = reactionAction(game, game.active!, { type: 'chooseBattle', territory: 'carthag',
|
||||
target: game.active === 'atreides' ? 'harkonnen' : 'atreides' }, actions);
|
||||
game = prepare(game, actions);
|
||||
const trained = game.leaderSkills?.assignments.find(assignment => assignment.owner === 'harkonnen');
|
||||
return { ...original, game, claimed, beforeShipment, afterShipment, owner: 'harkonnen',
|
||||
opponent: 'atreides', key, ownerLeader: trained?.leader ?? bestLeader(game, 'harkonnen'),
|
||||
opponentLeader, weapon: poison, defense: shield };
|
||||
}
|
||||
|
||||
/** Return the revealed original battle, before any Traitor vote or cleanup. */
|
||||
export function revealNexusCoreStrongholdBattle(fixture: NexusCoreStrongholdFixture,
|
||||
options: { cunning?: boolean; shield?: boolean; ownerDial?: number; opponentDial?: number } = {}): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
const arrakeen = fixture.key.startsWith('arrakeen:');
|
||||
if (arrakeen && options.cunning !== false) {
|
||||
const offer = viewGame(game, fixture.owner).nexusSardaukar!.offer!;
|
||||
assert.equal(offer.blocked, null);
|
||||
game = reactionAction(game, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
|
||||
game = advanceClassicIndependentNexus(game, clean);
|
||||
}
|
||||
const ownerDial = options.ownerDial ?? (arrakeen ? 6 : 0.5);
|
||||
const opponentDial = options.opponentDial ?? (arrakeen ? 1 : 0);
|
||||
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
|
||||
const owner = actor === fixture.owner;
|
||||
game = reactionAction(game, actor, { type: 'battlePlan',
|
||||
dial: owner ? ownerDial : opponentDial,
|
||||
support: owner ? arrakeen ? options.cunning === false ? ownerDial : ownerDial / 2 : 0 : opponentDial,
|
||||
leader: owner ? fixture.ownerLeader : fixture.opponentLeader,
|
||||
weapon: arrakeen ? owner ? fixture.weapon : null : owner ? null : fixture.weapon,
|
||||
defense: !arrakeen && owner && options.shield !== false ? fixture.defense : null });
|
||||
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;
|
||||
}
|
||||
|
||||
/** Original votes and native cleanup; no plan or strategy is invented. */
|
||||
export function finishNexusCoreStrongholdBattle(game: Game, stopAtCleanup = false): Game {
|
||||
return advanceClassicIndependentNexus(game, state => (!state.battle && clean(state)) ||
|
||||
(stopAtCleanup && state.decision?.kind === 'battleCards'));
|
||||
}
|
||||
|
||||
/** Preserve the original native Harkonnen call and pending cancellation window. */
|
||||
export function callNexusCoreCarthagTraitor(game: Game): Game {
|
||||
for (const voter of viewGame(game, 'atreides').battle!.traitorVoters)
|
||||
if (voter !== 'harkonnen' && game.battle!.traitorCalls[voter] === undefined)
|
||||
game = reactionAction(game, voter, { type: 'traitorCall', call: false });
|
||||
return reactionAction(game, 'harkonnen', { type: 'traitorCall', call: true });
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ownedTech, type TechId } from '../game/tech-tokens';
|
||||
import { createClassicNexusGuildPayment, type ClassicNexusCoreOptions } from './fixture-classic-nexus-core';
|
||||
import { createIndependentHarkonnenCall, reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
|
||||
import { advanceClassicIndependentNexus, createClassicIndependentNexusFixture,
|
||||
type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
|
||||
import { classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
|
||||
|
||||
/** Read the actual first-Storm custody; never select or retrofit a token owner. */
|
||||
export function nexusCoreTechHolding(game: Game, token: TechId) {
|
||||
const owner = game.techTokens?.[token].owner;
|
||||
assert.ok(owner, 'The original first Storm must assign this physical token');
|
||||
return { owner, count: ownedTech(game.techTokens, owner).length };
|
||||
}
|
||||
|
||||
/** A real five-counter Richese order, stopped before its funded Guild invoice. */
|
||||
export function createNexusCoreTechShipment(options: ClassicNexusCoreOptions = {}) {
|
||||
const fixture = createClassicNexusGuildPayment({ independent: true, ...options, tech: true });
|
||||
return { ...fixture, token: nexusCoreTechHolding(fixture.game, 'heighliners') };
|
||||
}
|
||||
|
||||
/** Original Harass plan and native Harkonnen declaration, before Nexus Use/Pass.
|
||||
* The reused producer forwards these original module options into its lobby. */
|
||||
export function createNexusCoreTechHarass() {
|
||||
const options = { tech: true, independent: true };
|
||||
return createIndependentHarkonnenCall(options);
|
||||
}
|
||||
|
||||
/** Actual battle losses supply eight Tanks and one own dead leader. Recruits
|
||||
* stays in its original starting hand, and a real closing draw supplies Tleilaxu.
|
||||
* Only unopened original Spice/Nexus singletons are positioned. With `drain`,
|
||||
* a genuine turn-one four-counter desert shipment spends eight of Atreides'
|
||||
* own spice so later Revival prices face a genuinely thin wallet. */
|
||||
export function createNexusCoreTechRevival(options: Pick<ClassicNexusCoreOptions,
|
||||
'advanced' | 'skills' | 'training'> & { drain?: boolean } = {}) {
|
||||
const weapon = treacheryDeck([]).find(card => card.kind === 'projectile')!;
|
||||
const harmless = treacheryDeck([]).find(card => card.kind === 'worthless')!;
|
||||
const { drain, ...modules } = options;
|
||||
const fixture = createClassicIndependentNexusFixture({ advanced: true, skills: true, ...modules,
|
||||
independent: true, tech: true,
|
||||
training: options.training ?? { atreides: 'diplomat', guild: 'mentat', emperor: 'sandmaster' },
|
||||
startingCards: { atreides: ['ecaz-recruits'], guild: [harmless.id], emperor: [weapon.id] } });
|
||||
const actions = fixture.actions;
|
||||
let game = fixture.game;
|
||||
const take = (worm: boolean) => {
|
||||
const index = game.spiceDeck.findIndex(card => worm
|
||||
? 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper
|
||||
: 'territory' in card);
|
||||
assert.ok(index >= 0);
|
||||
return game.spiceDeck.splice(index, 1)[0];
|
||||
};
|
||||
game.spiceDeck.unshift(take(false), ...(game.advanced ? [take(false)] : []),
|
||||
take(true), take(false), ...(game.advanced ? [take(false)] : []));
|
||||
if (drain) {
|
||||
game = advanceClassicIndependentNexus(game, state => state.turn === 1 &&
|
||||
state.phase === 5 && state.active === 'atreides' && classicDiscoveryNexusClean(state), actions);
|
||||
const desert = TERRITORIES.find(place => place.type !== 'stronghold' &&
|
||||
!place.sectors.includes(game.storm) && game.players.every(player =>
|
||||
Object.keys(player.forces).every(key => !key.startsWith(`${place.id}:`))))!;
|
||||
assert.ok(desert);
|
||||
const wallet = reactionPlayer(game, 'atreides').spice;
|
||||
assert.ok(wallet >= 8, 'The genuine drain shipment needs its real turn-one wallet');
|
||||
game = reactionAction(game, 'atreides', { type: 'ship', territory: desert.id,
|
||||
sector: desert.sectors[0], amount: 4, elite: 0, allyPayment: 0 }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.turn === 1 && state.phase === 5 &&
|
||||
state.active !== 'atreides' && classicDiscoveryNexusClean(state), actions);
|
||||
assert.equal(reactionPlayer(game, 'atreides').spice, wallet - 8);
|
||||
}
|
||||
game = advanceClassicIndependentNexus(game, state => state.phase === 5 &&
|
||||
state.active === 'emperor' && classicDiscoveryNexusClean(state), actions);
|
||||
const beforeShipment = structuredClone(game);
|
||||
game = reactionAction(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 = reactionAction(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 = reactionPlayer(game, 'atreides').leaders.filter(disc => !disc.dead)
|
||||
.sort((a, b) => a.strength - b.strength)[0].id;
|
||||
const opponentLeader = reactionPlayer(game, 'emperor').leaders.filter(disc => !disc.dead)
|
||||
.sort((a, b) => a.strength - b.strength)[0].id;
|
||||
game = reactionAction(game, 'atreides', { type: 'battlePlan', dial: game.advanced ? 4 : 8, support: 0,
|
||||
leader, weapon: null, defense: null }, actions);
|
||||
game = reactionAction(game, 'emperor', { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: opponentLeader, weapon: weapon.id, defense: null }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.nexus &&
|
||||
!state.spiceWindow && !state.spiceResolution && classicDiscoveryNexusClean(state), actions);
|
||||
const afterBattle = structuredClone(game);
|
||||
game = reactionAction(game, 'guild', { type: 'alliance', target: 'emperor' }, actions);
|
||||
game = reactionAction(game, 'emperor', { type: 'alliance', target: 'guild' }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
|
||||
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('tleilaxu');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
const beforeDraw = structuredClone(game);
|
||||
game = reactionAction(game, 'atreides', { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: cards.hands.atreides, choice: 'draw', ownRedraws: 0 }, actions);
|
||||
const afterDraw = structuredClone(game);
|
||||
game = advanceClassicIndependentNexus(game, state => state.phase === 4 && classicDiscoveryNexusClean(state), actions);
|
||||
const offer = viewGame(game, 'atreides').nexusTleilaxu!.secretAlly!;
|
||||
assert.equal(offer.blocked, null);
|
||||
const action: Action = { type: 'nexusTleilaxuRevival', event: offer.event, leader, amount: 5, elite: 0 };
|
||||
return { ...fixture, game, actions, beforeShipment, afterBattle, beforeDraw, afterDraw,
|
||||
leader, action,
|
||||
token: nexusCoreTechHolding(game, 'axlotl') };
|
||||
}
|
||||
|
||||
/** Retain the exact native phase-ending action and its pre-payout wallets. */
|
||||
export function finishNexusCoreTechPhase(state: Game, actions: ClassicIndependentNexusStep[] = []) {
|
||||
let game = state;
|
||||
const phase = game.phase;
|
||||
for (let count = 0; count < 300; count++) {
|
||||
const before = structuredClone(game), step = nextClassicDiscoveryNexusStep(game);
|
||||
assert.ok(step, 'Finish the real pending control before leaving this phase');
|
||||
game = reactionAction(game, step.actor, step.action, actions);
|
||||
if (game.phase !== phase) return { before, after: game, step, actions };
|
||||
}
|
||||
throw new Error('The native phase did not finish');
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { maxCombatDial } from '../game/combat';
|
||||
import { strongholdControllers } from '../game/stronghold-cards';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { advanceClassicIndependentNexus } from './fixture-classic-independent-nexus';
|
||||
import { reactionAction } from './fixture-classic-independent-nexus-reactions';
|
||||
import { classicDiscoveryNexusClean as clean } from './fixture-discovery-classic-nexus';
|
||||
import { createNexusCoreArrakeenFixture, createNexusCoreCarthagFixture,
|
||||
revealNexusCoreStrongholdBattle, finishNexusCoreStrongholdBattle,
|
||||
callNexusCoreCarthagTraitor, nexusCoreStrongholdPlayer as player } from './fixture-nexus-core-strongholds';
|
||||
|
||||
function physicalCounters(game: Game): void {
|
||||
for (const seat of game.players)
|
||||
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, count) => sum + count, 0), 20);
|
||||
}
|
||||
function nextMentat(game: Game): Game {
|
||||
return advanceClassicIndependentNexus(game, state => state.turn === 3 &&
|
||||
state.strongholdCards?.claimedTurn === 2 && clean(state));
|
||||
}
|
||||
|
||||
for (const independent of [false, true]) {
|
||||
void test(`earned Arrakeen pays only actual holder support beside native Emperor Cunning${independent ? ' and three real Reinforcements reserves' : ' on base33'}`, () => {
|
||||
const f = createNexusCoreArrakeenFixture({ independent });
|
||||
assert.equal(f.setup.strongholdCards!.claimedTurn, 0);
|
||||
assert.ok(Object.values(f.setup.strongholdCards!.owners).every(owner => owner === null));
|
||||
assert.equal(f.claimed.strongholdCards!.owners.arrakeen, f.owner);
|
||||
assert.equal(player(f.claimed, f.owner).forces[f.key], 5);
|
||||
assert.equal(player(f.claimed, f.owner).reserves, player(f.setup, f.owner).reserves - 5,
|
||||
'First end-Mentat ownership follows an original five-counter shipment');
|
||||
const before = player(f.game, f.owner), enemy = player(f.game, f.opponent);
|
||||
assert.equal(player(f.afterShipment, f.opponent).reserves, player(f.beforeShipment, f.opponent).reserves - 5);
|
||||
assert.equal(player(f.afterShipment, f.opponent).spice, player(f.beforeShipment, f.opponent).spice - 3,
|
||||
'The real reciprocal Guild alliance retains the rounded half-price tariff for five counters');
|
||||
const revealed = revealNexusCoreStrongholdBattle(f);
|
||||
const forces = viewGame(revealed, f.owner).battle!.ownForces!;
|
||||
assert.equal(forces.normal, 5);
|
||||
assert.equal(forces.temporaryElite, 5);
|
||||
assert.equal(maxCombatDial(forces), 10);
|
||||
assert.equal(revealed.battle!.plans[f.owner].dial, 6);
|
||||
assert.equal(revealed.battle!.plans[f.owner].support, 3);
|
||||
assert.equal(player(revealed, f.owner).spice, before.spice,
|
||||
'Sealing the plan neither pays support early nor credits bank money to the holder');
|
||||
const done = finishNexusCoreStrongholdBattle(revealed);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(done, f.owner).spice, before.spice - 1 + 2,
|
||||
'Actual support3 uses bank2/personal1 before separate native Arrakeen Collection2');
|
||||
assert.equal(player(done, f.opponent).spice, enemy.spice - 1 + 2,
|
||||
'The opposing Carthag holder pays personal support1 in Arrakeen before its separate native city Collection2');
|
||||
assert.equal(player(done, f.owner).reserves, before.reserves - (independent ? 3 : 0));
|
||||
assert.equal(player(done, f.owner).tanks, before.tanks + 3 + (independent ? 3 : 0),
|
||||
'Three dialed temporary-Sardaukar casualties and three reserve fees retain distinct sources');
|
||||
assert.equal(player(done, f.owner).forces[f.key], 2,
|
||||
'Reinforcements never adds supported board counters');
|
||||
assert.deepEqual(player(done, f.owner).elites, before.elites);
|
||||
assert.equal(player(done, f.opponent).tanks, enemy.tanks + 5);
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'emperor').length, 1);
|
||||
assert.equal(done.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, independent ? 1 : 0);
|
||||
assert.equal(done.nexusSardaukarHistory!.length, 1);
|
||||
assert.equal(done.nexusSardaukarHistory![0].casualties!.outcome, 'complete');
|
||||
assert.equal(done.strongholdCards!.owners.arrakeen, f.owner);
|
||||
assert.equal(done.phase, 7);
|
||||
physicalCounters(done);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Arrakeen bank support cannot replace the Reinforcements reserve fee or ordinary five-counter dial losses when Cunning is not used', () => {
|
||||
const f = createNexusCoreArrakeenFixture();
|
||||
const before = player(f.game, f.owner);
|
||||
const done = finishNexusCoreStrongholdBattle(revealNexusCoreStrongholdBattle(f, { cunning: false, ownerDial: 5 }));
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(done, f.owner).spice, before.spice - 3,
|
||||
'Support5 costs personal3 after bank2; no surviving city counter means no Collection2');
|
||||
assert.equal(player(done, f.owner).reserves, before.reserves - 3);
|
||||
assert.equal(player(done, f.owner).tanks, before.tanks + 8);
|
||||
assert.equal(player(done, f.owner).forces[f.key] ?? 0, 0);
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.owner], 'emperor');
|
||||
assert.equal(done.strongholdCards!.owners.arrakeen, f.owner,
|
||||
'Vacating the city through actual dial casualties does not transfer held custody early');
|
||||
const settled = nextMentat(done);
|
||||
assert.equal(settled.strongholdCards!.owners.arrakeen, null);
|
||||
physicalCounters(settled);
|
||||
});
|
||||
|
||||
for (const skills of [false, true]) {
|
||||
void test(`earned Carthag physical Shield survives poison after an original Harkonnen call is canceled${skills ? ' with its actually trained all14 disc behind the shield' : ' on base33'}`, () => {
|
||||
const f = createNexusCoreCarthagFixture({ skills, independent: skills });
|
||||
const before = player(f.game, f.owner);
|
||||
assert.equal(f.claimed.strongholdCards!.owners.carthag, f.owner);
|
||||
assert.equal(f.claimed.strongholdCards!.claimedTurn, 1);
|
||||
assert.equal(player(f.afterShipment, f.opponent).reserves, player(f.beforeShipment, f.opponent).reserves - 1);
|
||||
assert.equal(player(f.afterShipment, f.opponent).spice, player(f.beforeShipment, f.opponent).spice - 1);
|
||||
const revealed = revealNexusCoreStrongholdBattle(f);
|
||||
const called = callNexusCoreCarthagTraitor(revealed);
|
||||
assert.equal(player(called, f.opponent).leaders.find(disc => disc.id === f.opponentLeader)!.dead, false,
|
||||
'The original call is pending, not already a paid traitor outcome');
|
||||
const event = called.pendingNexusHarkonnenBetrayal!.source.event;
|
||||
const canceled = reactionAction(called, f.opponent, { type: 'nexusHarkonnenBetrayalUse', event });
|
||||
assert.equal(canceled.nexusCards!.cards!.hands[f.opponent], null);
|
||||
assert.equal(player(canceled, f.owner).traitors.includes(f.opponentLeader), false);
|
||||
assert.equal(canceled.traitorReserve!.includes(f.opponentLeader), true);
|
||||
const done = finishNexusCoreStrongholdBattle(canceled);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(done, f.owner).leaders.find(disc => disc.id === f.ownerLeader)!.dead, false);
|
||||
assert.equal(player(done, f.owner).forces[f.key], before.forces[f.key] - 1);
|
||||
assert.equal(player(done, f.owner).tanks, before.tanks + 1);
|
||||
assert.equal(player(done, f.owner).reserves, before.reserves);
|
||||
assert.equal(player(done, f.owner).hand.some(card => card.id === f.defense), true,
|
||||
'Snooper protection never replaces the physical Shield or moves it to a new hand');
|
||||
assert.equal(player(done, f.opponent).tanks, player(f.game, f.opponent).tanks + 1);
|
||||
const top = done.traitorReserve![0];
|
||||
const mentat = nextMentat(done);
|
||||
assert.equal(mentat.nexusHarkonnenReplacementHistory!.length, 1);
|
||||
assert.equal(mentat.nexusHarkonnenReplacementHistory![0].drawn, top);
|
||||
assert.equal(player(mentat, f.owner).traitors.length, before.traitors.length);
|
||||
assert.equal(mentat.strongholdCards!.owners.carthag, f.owner);
|
||||
physicalCounters(mentat);
|
||||
});
|
||||
}
|
||||
|
||||
void test('a retained Carthag card cannot protect an empty defense; actual loss changes control now but held custody only at Mentat', () => {
|
||||
const f = createNexusCoreCarthagFixture({ skills: true });
|
||||
let game = callNexusCoreCarthagTraitor(revealNexusCoreStrongholdBattle(f, { shield: false }));
|
||||
game = reactionAction(game, f.opponent, { type: 'nexusHarkonnenBetrayalUse',
|
||||
event: game.pendingNexusHarkonnenBetrayal!.source.event });
|
||||
const done = finishNexusCoreStrongholdBattle(game);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(player(done, f.owner).leaders.find(disc => disc.id === f.ownerLeader)!.dead, true);
|
||||
assert.equal(player(done, f.owner).forces[f.key] ?? 0, 0);
|
||||
assert.equal(player(done, f.owner).tanks, player(f.game, f.owner).tanks + 10);
|
||||
assert.equal(player(done, f.owner).hand.some(card => card.id === f.defense), true,
|
||||
'An unplayed Shield stays physically held and grants no Carthag defense');
|
||||
assert.equal(strongholdControllers(done.players, false).carthag, f.opponent);
|
||||
assert.equal(done.strongholdCards!.owners.carthag, f.owner);
|
||||
assert.equal(done.strongholdCards!.claimedTurn, 1);
|
||||
const mentat = nextMentat(done);
|
||||
assert.equal(mentat.strongholdCards!.owners.carthag, f.opponent);
|
||||
assert.equal(mentat.strongholdCards!.claimedTurn, 2);
|
||||
assert.equal(player(mentat, f.owner).hand.some(card => card.id === f.defense), true);
|
||||
physicalCounters(mentat);
|
||||
});
|
||||
|
||||
void test('passing the real Harkonnen Nexus acknowledgment retains the original single-traitor outcome and does not spend or replace the held card', () => {
|
||||
const f = createNexusCoreCarthagFixture();
|
||||
let game = callNexusCoreCarthagTraitor(revealNexusCoreStrongholdBattle(f));
|
||||
game = reactionAction(game, f.opponent, { type: 'nexusHarkonnenBetrayalPass',
|
||||
event: game.pendingNexusHarkonnenBetrayal!.source.event });
|
||||
const done = finishNexusCoreStrongholdBattle(game);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(done, f.opponent).leaders.find(disc => disc.id === f.opponentLeader)!.dead, true);
|
||||
assert.equal(player(done, f.owner).traitors.includes(f.opponentLeader), true);
|
||||
assert.equal(player(done, f.owner).tanks, player(f.game, f.owner).tanks,
|
||||
'A successful single-traitor call does not pay the ordinary half-dial casualty');
|
||||
assert.equal(done.nexusCards!.cards!.hands[f.opponent], 'harkonnen');
|
||||
assert.deepEqual(done.nexusCards!.cards!.discard, []);
|
||||
assert.equal(done.pendingNexusHarkonnenReplacement, null);
|
||||
assert.equal(done.strongholdCards!.owners.carthag, f.owner);
|
||||
physicalCounters(done);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import { TECH_TOKENS, ownedTech } from '../game/tech-tokens';
|
||||
import { forceRevivalQuote, normalRevivalAllowance } from '../game/revival';
|
||||
import { reactionAction, reactionPlayer } from './fixture-classic-independent-nexus-reactions';
|
||||
import { advanceClassicIndependentNexus } from './fixture-classic-independent-nexus';
|
||||
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
|
||||
import { createNexusCoreTechHarass, createNexusCoreTechRevival,
|
||||
createNexusCoreTechShipment, finishNexusCoreTechPhase } from './fixture-nexus-core-tech';
|
||||
|
||||
const wallets = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice]));
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected consumer action cannot move cards, counters, wallets or tokens');
|
||||
}
|
||||
const quietPhaseFour = (game: Game) => advanceClassicIndependentNexus(game,
|
||||
state => state.phase === 4 && classicDiscoveryNexusClean(state));
|
||||
function firstStormCustody(game: Game) {
|
||||
assert.ok(game.techTokens);
|
||||
for (const token of TECH_TOKENS) {
|
||||
const owner = game.techTokens[token.id].owner;
|
||||
assert.ok(owner, `The original first Storm must have assigned ${token.name}`);
|
||||
assert.equal(game.techTokens[token.id].spice, 0, 'No token income exists before its phase');
|
||||
}
|
||||
}
|
||||
|
||||
void test('Basic independent36 Tech keeps first-Storm custody and pays the funded delivery Heighliners once', () => {
|
||||
const f = createNexusCoreTechShipment();
|
||||
firstStormCustody(f.game);
|
||||
assert.equal(f.game.turn, 2);
|
||||
assert.equal(f.game.pendingGuildBetrayal!.invoice.price, 1);
|
||||
const owner = f.token.owner, pending = f.token.count;
|
||||
const before = reactionPlayer(f.beforeShipment, 'atreides');
|
||||
const game = reactionAction(f.game, 'emperor', { type: 'guildBetrayalUse',
|
||||
event: f.game.pendingGuildBetrayal!.invoice.event });
|
||||
// The delivered five counters, not the fee itself, are the real shipment.
|
||||
assert.equal(reactionPlayer(game, 'atreides').forces[f.key], 5);
|
||||
assert.equal(reactionPlayer(game, 'atreides').reserves, before.reserves - 5);
|
||||
assert.equal(reactionPlayer(game, 'atreides').spice, before.spice - 1);
|
||||
assert.equal(reactionPlayer(game, 'emperor').spice, reactionPlayer(f.beforeShipment, 'emperor').spice + 1);
|
||||
assert.equal(game.techTokens!.heighliners.owner, owner);
|
||||
assert.equal(game.techTokens!.heighliners.spice, pending, 'Delivery accrues the owner-count income as pending spice');
|
||||
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn);
|
||||
const closed = finishNexusCoreTechPhase(game);
|
||||
assert.equal(closed.after.techTokens!.heighliners.spice, 0, 'Phase end pays the pending pile exactly once');
|
||||
assert.equal(closed.after.techTokens!.heighliners.triggeredTurn, game.turn);
|
||||
const beforeClose = wallets(closed.before), afterClose = wallets(closed.after);
|
||||
assert.equal(afterClose[owner], beforeClose[owner] + pending);
|
||||
for (const [id, spice] of Object.entries(beforeClose))
|
||||
if (id !== owner) assert.equal(afterClose[id], spice, 'No other seat collects another token owner\'s pile');
|
||||
});
|
||||
|
||||
void test('Advanced all14 Tech fixed Tleilaxu group pays real bank spice and only a free return accrues Axlotl', () => {
|
||||
const f = createNexusCoreTechRevival();
|
||||
firstStormCustody(f.game);
|
||||
const owner = f.token.owner, pending = f.token.count;
|
||||
const start = reactionPlayer(f.game, 'atreides');
|
||||
assert.equal(start.tanks, 8, 'Actual battle losses supply the physical Tanks');
|
||||
assert.equal(start.leaders.find(disc => disc.id === f.leader)!.dead, true);
|
||||
let game = reactionAction(f.game, 'atreides', { type: 'card', card: 'ecaz-recruits' });
|
||||
const rate = normalRevivalAllowance(game, reactionPlayer(game, 'atreides'));
|
||||
assert.equal(rate.freeRate, 4);
|
||||
assert.equal(rate.limit, 7);
|
||||
rejected(game, 'atreides', { ...f.action, elite: 1 });
|
||||
rejected(game, 'atreides', { ...f.action, leader: '' });
|
||||
rejected(game, 'atreides', { ...f.action, amount: 6 });
|
||||
game = quietPhaseFour(reactionAction(game, 'atreides', f.action));
|
||||
const fixed = reactionPlayer(game, 'atreides');
|
||||
assert.equal(fixed.spice, start.spice - 5, 'Five exact counters cost one bank spice each');
|
||||
assert.equal(fixed.tanks, 3);
|
||||
assert.equal(fixed.reserves, start.reserves + 5);
|
||||
assert.equal(fixed.revived, 5);
|
||||
assert.equal(fixed.freeForcesRevived, 0, 'The paid card group never consumes the free quota');
|
||||
assert.equal(fixed.leaderRevived, true);
|
||||
assert.equal(fixed.leaders.find(disc => disc.id === f.leader)!.dead, false);
|
||||
assert.equal(game.nexusCards!.cards!.hands.atreides, null);
|
||||
assert.equal(game.discard.filter(card => card.id === 'ecaz-recruits').length, 1);
|
||||
assert.equal(game.techTokens!.axlotl.owner, owner);
|
||||
assert.equal(game.techTokens!.axlotl.spice, 0, 'A paid fixed group is not a free revival and accrues nothing');
|
||||
assert.equal(game.techTokens!.axlotl.triggeredTurn, undefined);
|
||||
game = quietPhaseFour(reactionAction(game, 'atreides', { type: 'revive', amount: 1 }));
|
||||
const freeSeat = reactionPlayer(game, 'atreides');
|
||||
assert.equal(freeSeat.spice, fixed.spice, 'The ordinary free return itself costs nothing');
|
||||
assert.equal(freeSeat.freeForcesRevived, 1);
|
||||
assert.equal(freeSeat.tanks, 2);
|
||||
assert.equal(game.techTokens!.axlotl.spice, pending, 'One genuine free return accrues the owner-count pile');
|
||||
assert.equal(game.techTokens!.axlotl.triggeredTurn, game.turn);
|
||||
game = quietPhaseFour(reactionAction(game, 'atreides', { type: 'revive', amount: 1 }));
|
||||
assert.equal(reactionPlayer(game, 'atreides').spice, fixed.spice);
|
||||
assert.equal(reactionPlayer(game, 'atreides').reserves, start.reserves + 7);
|
||||
assert.equal(reactionPlayer(game, 'atreides').tanks, 1);
|
||||
assert.equal(game.techTokens!.axlotl.spice, pending,
|
||||
'A second genuine free return in this Revival does not mint another pile');
|
||||
const closed = finishNexusCoreTechPhase(game);
|
||||
assert.equal(closed.after.techTokens!.axlotl.spice, 0, 'Revival phase end pays the Axlotl pile once');
|
||||
const beforeClose = wallets(closed.before), afterClose = wallets(closed.after);
|
||||
assert.equal(afterClose[owner], beforeClose[owner] + pending);
|
||||
});
|
||||
|
||||
void test('pending Axlotl spice cannot fund an unaffordable further paid revival', () => {
|
||||
const f = createNexusCoreTechRevival({ drain: true });
|
||||
const owner = f.token.owner, pending = f.token.count;
|
||||
let game = reactionAction(f.game, 'atreides', { type: 'card', card: 'ecaz-recruits' });
|
||||
const rate = normalRevivalAllowance(game, reactionPlayer(game, 'atreides'));
|
||||
assert.ok(reactionPlayer(game, 'atreides').spice < 6,
|
||||
'The genuine turn-one shipment leaves a thin wallet for this boundary');
|
||||
game = quietPhaseFour(reactionAction(game, 'atreides', { type: 'revive', amount: rate.freeRate }));
|
||||
const seat = reactionPlayer(game, 'atreides');
|
||||
assert.equal(game.techTokens!.axlotl.spice, pending, 'The owner\'s own free return created the pending pile');
|
||||
const quote = forceRevivalQuote(game, seat, 3);
|
||||
assert.ok(quote.cost > seat.spice && quote.cost - seat.spice <= pending,
|
||||
'Choose the smallest legal overage the pending pile would cover if it were spendable');
|
||||
rejected(game, 'atreides', { type: 'revive', amount: 3 });
|
||||
assert.equal(game.techTokens!.axlotl.spice, pending, 'The refusal cannot mint or clear token income');
|
||||
game = quietPhaseFour(reactionAction(game, 'atreides', { type: 'revive', amount: 2 }));
|
||||
const paid = reactionPlayer(game, 'atreides');
|
||||
assert.equal(paid.spice, seat.spice - 4, 'Two genuinely affordable paid counters cost their printed four bank spice');
|
||||
assert.equal(paid.tanks, 2);
|
||||
assert.equal(game.techTokens!.axlotl.spice, pending, 'Axlotl accrues once per turn, not once per return');
|
||||
const closed = finishNexusCoreTechPhase(game);
|
||||
assert.equal(closed.after.techTokens!.axlotl.spice, 0);
|
||||
assert.equal(wallets(closed.after)[owner], wallets(closed.before)[owner] + pending);
|
||||
});
|
||||
|
||||
void test('Basic independent36 Harass return and native winner transfer mint no token income', () => {
|
||||
const f = createNexusCoreTechHarass();
|
||||
const before = f.beforeCall;
|
||||
const game = reactionAction(f.game, 'atreides', { type: 'nexusHarkonnenBetrayalUse',
|
||||
event: f.game.pendingNexusHarkonnenBetrayal!.source.event });
|
||||
const lost = ownedTech(before.techTokens, 'atreides');
|
||||
assert.ok(lost.length, 'The defeated seat genuinely held tokens');
|
||||
const moved = TECH_TOKENS.filter(token =>
|
||||
before.techTokens![token.id].owner !== game.techTokens![token.id].owner);
|
||||
assert.equal(moved.length, 1, 'The battle winner takes exactly one token from the loser');
|
||||
const token = moved[0];
|
||||
assert.equal(before.techTokens![token.id].owner, 'atreides');
|
||||
assert.equal(game.techTokens![token.id].owner, 'harkonnen');
|
||||
assert.ok(lost.includes(token.id), 'The native choice belongs to the loser\'s actual holdings');
|
||||
assert.equal(game.pendingTech ?? null, null);
|
||||
for (const rule of TECH_TOKENS) {
|
||||
assert.equal(game.techTokens![rule.id].spice, 0, `${rule.name} mints no income from a traitor battle`);
|
||||
}
|
||||
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn,
|
||||
'The earlier genuine shipment accrual was already paid at its own phase end');
|
||||
assert.equal(reactionPlayer(game, 'atreides').reserves,
|
||||
reactionPlayer(f.beforeShipment, 'atreides').reserves, 'The Harass return is not a Revival grant');
|
||||
assert.equal(reactionPlayer(game, 'atreides').tanks, 0);
|
||||
assert.equal(reactionPlayer(game, 'atreides').leaders.find(disc => disc.id === f.identity)?.dead, false);
|
||||
});
|
||||
Reference in new issue
Block a user