Compose classic Nexus with earned phase modules

This commit is contained in:
Quintine committed 2026-10-18 06:21:11 +11:00
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) : [];
+1 -1
View File
@@ -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);
+171
View File
@@ -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 });
}
+123
View File
@@ -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);
});
+151
View File
@@ -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);
});