Files
Dune/tests/e3-native-tech-runtime.test.ts
T

333 lines
23 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeFactionExpansionsGameForAudit,
initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { treacheryDeck } from '../game/cards';
import { createTechTokens, ownedTech } from '../game/tech-tokens';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { strongholdControllers } from '../game/stronghold-cards';
import { finishEcazOccupySetup } from './fixture-ecaz-occupy';
import { advanceEcazStronghold, ecazStrongholdSeat as player, finishEcazStronghold } from './fixture-ecaz-strongholds';
import {
advanceMoritaniStrongholds, holdMoritaniStrongholdsTraitor, nextMoritaniStrongholdsStep,
resolveMoritaniStrongholdsBattle, stageMoritaniStrongholdsBattle,
} from './fixture-moritani-strongholds';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import {
createEcazNativeTechFixture, createMoritaniNativeTechFixture,
initializeE3NativeTechSetup, revealEcazNativeTechBattle,
} from './fixture-e3-native-tech';
function lobby(roster: FactionId[], advanced = true, expansions: Game['expansions'] = ['ecaz']): Game {
let game = createGame('E3TECHGUARD', newPlayer(`auth-${roster[0]}`, roster[0], roster[0]), advanced, expansions);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(`auth-${faction}`, faction, faction));
game.techTokens = createTechTokens();
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
return game;
}
function physical(game: Game): void {
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []),
...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
assert.deepEqual(cards, treacheryDeck(['ecaz']).map(c => c.id).sort(), 'Every original selected physical card retains one custodian.');
assert.equal(new Set(cards).size, cards.length);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
p.faction === 'emperor' ? 5 : 3);
}
assert.equal(Object.keys(game.techTokens!).length, 3);
for (const token of Object.values(game.techTokens!))
assert.ok(token.owner === null || game.players.some(p => p.id === token.owner));
const moritani = game.players.find(p => p.faction === 'moritani');
if (moritani) {
assert.equal(moritani.leaders.length, 5, 'The five original Moritani leader discs remain physically present.');
assert.equal(new Set(moritani.leaders.map(l => l.id)).size, 5);
assert.equal(game.moritaniTerror!.tokens.length, 6);
assert.equal(new Set(game.moritaniTerror!.tokens.map(t => t.id)).size, 6);
const traitors = [...game.players.flatMap(p => p.traitors), ...(game.traitorReserve ?? []),
...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : []),
...(game.moritaniAssassinate?.opportunities.flatMap(o => o.stage === 'replaced' && o.card ? [o.card] : []) ?? [])];
assert.equal(new Set(traitors).size, traitors.length, 'Held, replacement-reserve and revealed spent Traitors have distinct physical custody.');
}
}
for (const native of ['ecaz', 'moritani'] as const) for (const advanced of [false, true])
for (const seats of [3, 6]) void test(`${advanced ? 'Advanced' : 'Basic'} standalone ${native} plain Tech: ${seats} original seats complete real setup and receive three first-Storm tokens`, () => {
const roster: FactionId[] = [native, 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit'].slice(0, seats) as FactionId[];
const admitted = lobby(roster, advanced);
const setup = initializeE3NativeTechSetup({ initial: admitted, native, advanced });
let game = finishEcazOccupySetup(setup);
const actor = game.players.find(p => p.faction === native)!;
assert.equal(actor.id, `auth-${native}`);
physical(game);
game = advanceMoritaniStrongholds(game, g => g.phase === 2 && !g.phaseOpening && !g.response && !g.decision);
const owners = Object.values(game.techTokens!).map(token => token.owner);
assert.ok(owners.every(owner => typeof owner === 'string' && game.players.some(p => p.id === owner)));
assert.equal(new Set(owners).size, 3, 'Actual first-Storm order supplies unassigned tokens to three distinct seats.');
physical(game);
});
for (const strongholds of [false, true]) for (const lead of ['ecaz', 'ally'] as const)
void test(`Advanced standalone Ecaz ${strongholds ? 'Stronghold+Tech' : 'plain Tech'}: selected ${lead} earns only the enemy token after original holder reward and typed owner casualties`, () => {
const f = createEcazNativeTechFixture({ strongholds });
const result = revealEcazNativeTechBattle(f, lead, true);
const { before, revealed, pending, actor } = result;
const battle = revealed.battle!, publicBattle = viewGame(revealed, actor).battle!;
const side = (id: string): ResolutionCombatant => {
const p = player(revealed, id), view = viewGame(revealed, id).battle!;
assert.ok(view.ownForces);
return { id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
plan: battle.plans[id], leader: p.leaders.find(l => l.id === battle.plans[id].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[id] };
};
const quote = quoteBattleResolution({ advanced: true, turn: revealed.turn, territory: f.territory,
ecazOccupy: publicBattle.ecazOccupy!.profile!, aggressor: publicBattle.aggressor,
attacker: side(battle.attacker), defender: side(battle.defender),
voters: publicBattle.traitorVoters.map(id => ({ id, called: false, traitors: player(revealed, id).traitors,
beneficiary: [battle.attacker, battle.defender].includes(id) ? id : actor })),
participants: revealed.players, physicalCards: [...revealed.deck, ...revealed.discard, ...revealed.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 3, elite: 0 }]);
assert.equal(quote.casualties!.owner, f.ally, 'The Guild variable army owns its losses even when Ecaz seals the winning plan.');
assert.deepEqual(quote.casualties!.options, [{ normal: 3, elite: 0,
paidNormal: battle.plans[actor].support, paidElite: 0 }]);
const income = strongholds && lead === 'ecaz' ? 2 : 0;
assert.deepEqual(quote.strongholdIncome, income ? [{ player: actor, amount: income }] : []);
const payment = quote.payments.find(p => p.player === actor)!;
assert.equal(player(pending, actor).spice, player(before, actor).spice - payment.ownPayment + income);
assert.equal(pending.decision?.player, actor);
const enemyToken = ownedTech(before.techTokens, f.opponent);
assert.equal(enemyToken.length, 1);
assert.equal(pending.techTokens![enemyToken[0]].owner, f.opponent,
'Stronghold reward is already paid while actual winner-card cleanup still precedes Tech custody.');
const game = finishEcazStronghold(pending), nonLead = lead === 'ecaz' ? f.ally : f.ecaz;
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(game.techTokens![enemyToken[0]].owner, actor);
assert.equal(ownedTech(game.techTokens, actor).length, 2);
assert.equal(ownedTech(game.techTokens, nonLead).length, 1);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
assert.equal(player(game, f.ecaz).tanks - player(before, f.ecaz).tanks, 3);
assert.equal(player(game, f.ally).tanks - player(before, f.ally).tanks, 3);
assert.equal(player(game, f.opponent).tanks - player(before, f.opponent).tanks, 8);
assert.equal(player(game, f.ecaz).forces[f.location], 2);
assert.equal(player(game, f.ally).forces[f.location], 1);
const progress = viewGame(game, actor).victoryProgress.find(p => p.player === actor)!;
assert.equal(progress.strongholds.length, 3);
assert.equal(progress.jointlyOccupied.length, 1);
assert.equal(progress.techStronghold, false, 'Two plus one allied tokens do not produce a joint third-token stronghold.');
assert.equal(progress.qualifies, false);
physical(game);
const collection = quoteSpiceCollection(game).receipts.find(p => p.player === actor)!;
const nextTurn = advanceEcazStronghold(game, g => g.turn === 4 && g.phase === 0);
assert.equal(nextTurn.status, 'playing', 'Original end-Mentat does not infer an allied three-token victory.');
assert.deepEqual(nextTurn.winner, []);
assert.equal(player(nextTurn, actor).spice, player(pending, actor).spice + collection.strongholds + collection.collected,
'Only actual native Collection follows the completed reward; the earlier Stronghold battle income does not replay.');
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic plain Tech Ecaz ${lead}: even original coalition pays no support and attributes the token to its actual selected lead`, () => {
const f = createEcazNativeTechFixture({ advanced: false });
const result = revealEcazNativeTechBattle(f, lead);
const token = ownedTech(result.before.techTokens, f.opponent)[0];
assert.equal(player(result.pending, result.actor).spice, player(result.before, result.actor).spice);
const game = finishEcazStronghold(result.pending);
assert.equal(game.techTokens![token].owner, result.actor);
assert.equal(player(game, f.ecaz).forces[f.location], 2);
assert.equal(player(game, f.ally).forces[f.location], 1);
assert.equal(player(game, f.ecaz).tanks - player(result.before, f.ecaz).tanks, 2);
assert.equal(player(game, f.ally).tanks - player(result.before, f.ally).tanks, 3);
physical(game);
});
void test('Basic native Tech Ecaz admits odd forces with provisional ceiling losses and floor survivors', () => {
const f = createEcazNativeTechFixture({ advanced: false, ecazForces: 3, chooseBattle: false });
const admitted = applyAction(f.game, f.battleAction.actor, f.battleAction.action);
assert.ok(admitted.battle);
assert.equal(admitted.decision?.kind, 'ecazBattleLead');
physical(admitted);
for (const lead of ['ecaz', 'ally'] as const) {
const result = revealEcazNativeTechBattle({ ...f, game: admitted }, lead);
const { before, revealed, pending, actor } = result;
const battle = revealed.battle!, publicBattle = viewGame(revealed, actor).battle!;
const profile = publicBattle.ecazOccupy!.profile!;
assert.equal(profile.ecazForces.normal, 3);
assert.equal(profile.fixedEcazDial, Math.ceil(3 / 2));
assert.equal(profile.planOwner, actor);
assert.equal(profile.payer, actor);
assert.equal(profile.forceOwner, f.ally);
const side = (id: string): ResolutionCombatant => {
const p = player(revealed, id), view = viewGame(revealed, id).battle!;
assert.ok(view.ownForces);
return { id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
plan: battle.plans[id], leader: p.leaders.find(l => l.id === battle.plans[id].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[id] };
};
const quote = quoteBattleResolution({ advanced: false, turn: revealed.turn, territory: f.territory,
ecazOccupy: profile, aggressor: publicBattle.aggressor,
attacker: side(battle.attacker), defender: side(battle.defender),
voters: publicBattle.traitorVoters.map(id => ({ id, called: false, traitors: player(revealed, id).traitors,
beneficiary: [battle.attacker, battle.defender].includes(id) ? id : actor })),
participants: revealed.players, physicalCards: [...revealed.deck, ...revealed.discard, ...revealed.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
assert.equal(quote.winner, actor);
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: Math.ceil(3 / 2), elite: 0 }]);
assert.equal(quote.casualties!.owner, f.ally);
assert.deepEqual(quote.casualties!.options, [{ normal: 3, elite: 0, paidNormal: 0, paidElite: 0 }]);
assert.ok(quote.payments.every(payment => payment.ownPayment === 0));
assert.deepEqual(quote.strongholdIncome, []);
assert.equal(player(pending, actor).spice, player(before, actor).spice);
const token = ownedTech(before.techTokens, f.opponent)[0]; assert.ok(token);
assert.equal(pending.techTokens![token].owner, f.opponent, 'Winner-card cleanup still precedes Tech transfer.');
const game = finishEcazStronghold(pending), nonLead = actor === f.ecaz ? f.ally : f.ecaz;
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(game.techTokens![token].owner, actor);
assert.equal(ownedTech(game.techTokens, actor).length, 2);
assert.equal(ownedTech(game.techTokens, nonLead).length, 1);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
assert.equal(player(game, f.ecaz).tanks - player(before, f.ecaz).tanks, Math.ceil(3 / 2));
assert.equal(player(game, f.ecaz).forces[f.location], Math.floor(3 / 2));
assert.equal(player(game, f.ally).tanks - player(before, f.ally).tanks, 3);
assert.equal(player(game, f.ally).forces[f.location], 1);
assert.equal(player(game, f.opponent).tanks - player(before, f.opponent).tanks, 8);
physical(game);
}
});
for (const strongholds of [false, true])
void test(`Advanced Moritani ${strongholds ? 'Stronghold+Tech' : 'plain Tech'}: loss retains original effects, assassination pays one bounty, actual winner earns Tech, and Mentat replaces one held Traitor`, () => {
const f = createMoritaniNativeTechFixture({ strongholds });
assert.equal(f.afterSetup.players.find(p => p.id === f.moritani)!.leaders.length, 5);
assert.equal(f.afterPlacement.moritaniTerror!.tokens.filter(t => t.status === 'available').length, 5);
assert.equal(f.afterPlacement.moritaniTerror!.tokens.filter(t => t.status === 'placed').length, 1);
const discs = f.afterPlacement.moritaniTerror!.tokens.map(t => ({ ...t }));
const pending = f.pending, quote = quoteStrongholdFactionsBattle(f.revealed);
assert.equal(pending.decision?.kind, 'moritaniAssassinate');
assert.equal(pending.lastBattleContext!.winner, f.opponent);
const income = strongholds ? 4 : 0;
assert.deepEqual(quote.strongholdIncome, income ? [{ player: f.moritani, amount: income }] : []);
assert.equal(player(pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1 + income);
assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
if (strongholds) {
assert.equal(pending.strongholdCards!.owners.tueks_sietch, f.moritani);
assert.equal(strongholdControllers(pending.players, false).tueks_sietch, f.opponent);
}
const token = ownedTech(pending.techTokens, f.moritani)[0];
assert.ok(token);
const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!;
const held = [...player(pending, f.moritani).traitors], reserve = [...pending.traitorReserve!];
const decision = pending.decision;
assert.ok(decision?.kind === 'moritaniAssassinate');
let game = applyAction(pending, f.moritani, { type: 'decision', event: decision.event, card: f.target });
const paid = player(game, f.moritani).spice;
assert.equal(paid, player(pending, f.moritani).spice + target.strength);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1);
assert.deepEqual(player(game, f.moritani).traitors, held, 'Revealed original physical Traitor remains held until Mentat.');
assert.equal(game.techTokens![token].owner, f.moritani);
assert.equal(game.decision?.kind, 'battleCards');
game = applyAction(game, f.opponent, { type: 'decision', discard: f.opponentCards });
game = advanceMoritaniStrongholds(game, g => !g.battle && !g.response && !g.decision && !g.pendingTreacheryDiscard);
assert.equal(game.techTokens![token].owner, f.opponent);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 2);
assert.equal(ownedTech(game.techTokens, f.moritani).length, 0);
assert.equal(player(game, f.moritani).tanks - player(f.revealed, f.moritani).tanks, 6);
assert.equal(player(game, f.opponent).tanks - player(f.revealed, f.opponent).tanks, 3);
assert.equal(player(game, f.moritani).spice, paid);
assert.deepEqual(game.moritaniTerror!.tokens, discs, 'Original assassination does not consume a Terror disc.');
for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1);
physical(game);
game = advanceMoritaniStrongholds(game, g => g.phase === 8);
const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.bounty, target.strength);
assert.equal(receipt.replacement, reserve[0]);
assert.deepEqual(game.traitorReserve, reserve.slice(1));
assert.deepEqual(player(game, f.moritani).traitors, [...held.filter(id => id !== f.target), reserve[0]]);
assert.equal(player(game, f.moritani).spice, paid, 'Neither Stronghold income nor bounty replays at Mentat.');
const rival = viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!;
assert.equal(rival.card, f.target); assert.equal('replacement' in rival, false);
game = advanceMoritaniStrongholds(game, g => g.turn === 3 && g.phase === 0);
assert.equal(game.traitorReserve!.length, reserve.length - 1);
assert.equal(player(game, f.moritani).spice, paid);
assert.equal(game.techTokens![token].owner, f.opponent);
if (strongholds) assert.equal(game.strongholdCards!.owners.tueks_sietch, f.opponent);
physical(game);
});
for (const strongholds of [false, true])
void test(`Advanced Moritani ${strongholds ? 'Stronghold+Tech' : 'plain Tech'}: genuine normal Traitor call forfeits the later original loss assassination across turns`, () => {
const f = createMoritaniNativeTechFixture({ strongholds, normalCall: true });
assert.equal(f.pending.lastBattleContext!.winner, f.moritani);
assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(f.pending.moritaniAssassinate!.opportunities.length, 0);
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true);
assert.equal(ownedTech(f.pending.techTokens, f.moritani).length, 2);
let game = advanceMoritaniStrongholds(f.pending, g => g.turn === 3 && g.phase === 5 && !g.response && !g.phaseOpening && !g.decision);
const emperor = game.players.find(p => p.faction === 'emperor')!.id;
stageMoritaniStrongholdsBattle(game, f.moritani, emperor, 'tueks_sietch');
const loser = player(game, f.moritani).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
const winner = player(game, emperor).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const target = player(game, emperor).leaders.find(l => !l.dead && l.id !== winner.id)!;
holdMoritaniStrongholdsTraitor(game, f.moritani, target.id);
const tokens = ownedTech(game.techTokens, f.moritani), reserve = [...game.traitorReserve!];
game = advanceMoritaniStrongholds(game, g => g.phase === 6 && !g.response && !g.phaseOpening && !g.decision);
const actor = game.active!;
assert.ok([f.moritani, emperor].includes(actor));
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === f.moritani ? emperor : f.moritani });
game = advanceMoritaniStrongholds(game, g => !!g.battle && !g.battle.revealed && !nextMoritaniStrongholdsStep(g));
game = applyAction(game, f.moritani, { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 });
game = applyAction(game, emperor, { type: 'battlePlan', leader: winner.id, dial: 2, support: 2 });
game = resolveMoritaniStrongholdsBattle(game, f.moritani);
assert.equal(game.lastBattleContext!.winner, emperor);
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(game.moritaniAssassinate!.opportunities.length, 0);
assert.equal(player(game, emperor).leaders.find(l => l.id === target.id)!.dead, false);
assert.deepEqual(game.traitorReserve, reserve, 'No assassination means no hidden replacement draw.');
assert.equal(tokens.filter(token => game.techTokens![token].owner === emperor).length, 1,
'A real victory selects one loser token, not both allied or held tokens.');
assert.equal(ownedTech(game.techTokens, emperor).length, 2);
physical(game);
});
for (const strongholds of [false, true]) {
const initialize = strongholds ? initializeStrongholdFactionsGameForAudit : initializeFactionExpansionsGameForAudit;
void test(`${strongholds ? 'Stronghold+Tech' : 'plain Tech'} initializer admits selected mixed families while excluding unrelated overlays and used inventory`, () => {
for (const admitted of [lobby(['ecaz', 'moritani', 'guild']),
lobby(['ecaz', 'ixians', 'guild'], true, ['ecaz', 'ix']),
lobby(['moritani', 'choam', 'guild'], true, ['ecaz', 'choam']),
lobby(['moritani', 'harkonnen', 'guild'])]) {
const setup = initialize(admitted);
assert.equal(setup.status, 'setup');
assert.deepEqual(setup.expansions, admitted.expansions);
assert.equal(!!setup.strongholdCards, strongholds);
assert.equal(!!setup.moritaniAssassinate, admitted.players.some(p => p.faction === 'moritani'));
}
assert.throws(() => initialize(lobby(['ecaz', 'guild'])));
if (strongholds) assert.throws(() => initialize(lobby(['ecaz', 'guild', 'emperor'], false)));
for (const overlay of ['leaderSkills', 'homeworlds', 'nexusCards', 'discoveryEnabled', 'ecazTreachery',
'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', 'guildBetrayalPreview',
'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview']) {
const game = lobby(['ecaz', 'guild', 'emperor']); Object.assign(game, { [overlay]: true });
assert.throws(() => initialize(game), `${overlay} remains excluded from the bounded native Tech family.`);
}
for (const stale of ['owner', 'spice', 'triggeredTurn'] as const) {
const game = lobby(['ecaz', 'guild', 'emperor']);
Object.assign(game.techTokens!.axlotl, { [stale]: stale === 'owner' ? game.host : 1 });
assert.throws(() => initialize(game));
}
});
}
void test('Basic standalone Moritani Tech retains its original Harkonnen-compatible setup boundary', () => {
const game = finishEcazOccupySetup(initializeE3NativeTechSetup({
initial: lobby(['moritani', 'harkonnen', 'guild'], false), native: 'moritani', advanced: false,
}));
assert.equal(player(game, 'auth-moritani').reserves, 14);
assert.equal(player(game, 'auth-harkonnen').traitors.length, 4);
physical(game);
});