375 lines
29 KiB
TypeScript
375 lines
29 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
applyAction, createGame, joinGame, newPlayer,
|
|
viewGame, type Action, type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { MOBILE_LOCATION, validGameLocation } from '../game/board';
|
|
import { treacheryDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { validateRicheseNoField } from '../game/richese-no-field';
|
|
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
|
|
import { validateNexusCards } from '../game/nexus-cards';
|
|
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
|
|
import { ownedTech } from '../game/tech-tokens';
|
|
import { traitorDeck } from '../game/traitors';
|
|
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
|
import {
|
|
advancePairedDiscoveryNexus, applyPairedDiscoveryNexusStep, closePairedDiscoveryNexus,
|
|
createPairedDiscoveryNexusFixture, holdPairedDiscoveryNexusCard,
|
|
initializePairedDiscoveryNexusForFixture, nextPairedDiscoveryNexusStep,
|
|
pairedDiscoveryNexusClean, pairedDiscoveryNexusPlayer as player, placePairedDiscoveryNexus,
|
|
pairedDiscoveryRicheseRequest, preparePairedDiscoveryNexusBattle,
|
|
reservePairedDiscoveryNexusBoard, revealPairedDiscoveryNexusBattle, settlePairedDiscoveryNexusShipment,
|
|
type PairedDiscoveryNexusFixture,
|
|
} from './fixture-discovery-paired-nexus';
|
|
|
|
function reject(game: Game, actor: string, action: Action): void {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before, 'Rejected native choices leave original cards, wallets, subtype counters and receipts unchanged.');
|
|
}
|
|
function custody(game: Game): void {
|
|
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
|
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
|
...(game.auction?.cards.slice(game.auction.index) ?? []),
|
|
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
|
|
const expected = [...treacheryDeck(game.expansions), ...(game.expansions[0] === 'choam' ? richeseCards() : [])];
|
|
assert.deepEqual(cards.map(c => c.id).sort(), expected.map(c => c.id).sort());
|
|
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
|
|
const identities = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => [
|
|
...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader),
|
|
])];
|
|
assert.deepEqual(identities.sort(), traitorDeck(game.players, game.expansions[0] === 'ix').sort());
|
|
assert.equal(new Set(identities).size, identities.length);
|
|
for (const p of game.players) {
|
|
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, p.faction);
|
|
if (p.elites) {
|
|
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
|
p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5);
|
|
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
|
|
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count >= 0 && count <= (p.forces[key] ?? 0));
|
|
}
|
|
if (p.noField) validateRicheseNoField(p.noField);
|
|
}
|
|
validateDiscoveryState(game.discoveries!);
|
|
validateNexusCards(game.nexusCards!.cards!, game.players);
|
|
assert.ok(!game.leaderSkills && !game.homeworlds);
|
|
}
|
|
function richeseWindow(f: PairedDiscoveryNexusFixture, state: Game): Game {
|
|
return advancePairedDiscoveryNexus(state, g => g.phase === 5 && g.active === f.owner && pairedDiscoveryNexusClean(g));
|
|
}
|
|
function richeseReveal(game: Game, owner: string): Game {
|
|
const p = player(game, owner); assert.ok(p.noField!.deployed);
|
|
return applyAction(game, owner, { type: 'revealNoField', token: p.noField!.deployed.tokenId, event: p.noFieldEvent });
|
|
}
|
|
|
|
for (const family of ['ix', 'choam'] as const) for (const mode of [
|
|
{ advanced: false, tech: false, strongholds: false },
|
|
{ advanced: false, tech: true, strongholds: false },
|
|
{ advanced: true, tech: false, strongholds: false },
|
|
{ advanced: true, tech: true, strongholds: false },
|
|
{ advanced: true, tech: false, strongholds: true },
|
|
{ advanced: true, tech: true, strongholds: true },
|
|
]) void test(`paired ${family} ${JSON.stringify(mode)}: actual printed reveal, END Mentat, typed entry/Maker ride and alliance-qualified closing native deals`, () => {
|
|
const f = createPairedDiscoveryNexusFixture({ family, ...mode });
|
|
assert.deepEqual(f.setup.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(),
|
|
DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort());
|
|
assert.equal(f.setup.spiceDeck.filter(c => 'worm' in c && c.greatMaker).length, 1);
|
|
assert.equal(f.setup.spiceDeck.filter(c => 'sandtrout' in c && c.sandtrout).length, family === 'ix' ? 1 : 0);
|
|
assert.equal(f.setup.discoveries!.tokens.length, 8);
|
|
assert.equal(f.afterSetup.players.reduce((sum, p) => sum + p.hand.length, 0) + f.afterSetup.deck.length,
|
|
family === 'ix' ? 47 : 35);
|
|
assert.equal(f.game.decision?.kind, 'discoveryEntry'); assert.equal(f.game.phase, 0); assert.equal(f.game.turn, 2);
|
|
assert.ok(f.actions.some(s => s.action.type === 'ship') && f.actions.some(s => s.action.type === 'discovery'));
|
|
assert.ok(Object.values(f.game.nexusCards!.cards!.hands).every(c => c === null), 'END Mentat/free entry do not manufacture a Nexus hand.');
|
|
if (mode.strongholds) assert.equal(f.firstMentat.after.strongholdCards!.owners[f.claim], f.owner);
|
|
const c = closePairedDiscoveryNexus(f), old = player(c.entry.before, f.collector), entered = player(c.entry.after, f.collector);
|
|
const total = family === 'ix' ? 2 : 1, elite = family === 'ix' ? 1 : 0;
|
|
assert.equal(entered.forces['shrine:0'], total);
|
|
assert.equal(entered.elites?.forces['shrine:0'] ?? 0, elite);
|
|
assert.equal(entered.forces[f.source], 3 - total);
|
|
assert.equal(entered.reserves, old.reserves); assert.equal(entered.spice, old.spice);
|
|
assert.equal(entered.moved, old.moved); assert.equal(entered.shipped, old.shipped);
|
|
assert.equal(player(c.vote, f.native).forces['hagga_basin:12'] ?? 0, 0);
|
|
assert.equal(player(c.vote, f.native).tanks, player(f.game, f.native).tanks + 2, 'Actual Great Maker ordinary worm losses precede its vote.');
|
|
assert.ok(Object.values(c.vote.nexusCards!.cards!.hands).every(c => c === null));
|
|
const rider = player(c.ride.before, f.fremen), arrived = player(c.ride.after, f.fremen);
|
|
assert.equal(arrived.forces['polar_sink:0'], 2);
|
|
assert.equal(arrived.elites?.forces['polar_sink:0'] ?? 0, mode.advanced ? 1 : 0);
|
|
assert.equal(arrived.reserves, rider.reserves - 2);
|
|
assert.equal(arrived.elites?.reserves ?? 0, (rider.elites?.reserves ?? 0) - (mode.advanced ? 1 : 0));
|
|
assert.equal(arrived.spice, rider.spice); assert.equal(arrived.shipped, rider.shipped); assert.equal(arrived.moved, rider.moved);
|
|
assert.notEqual(c.ride.after.nexusCards!.phase?.stage, 'drawing', 'Maker reserve ride is not the closing phase draw.');
|
|
assert.equal(c.drawing.greatMaker!.stage, 'complete');
|
|
assert.ok(!c.drawing.spiceWindow && !c.drawing.spiceResolution);
|
|
assert.equal(player(c.drawing, f.guild).ally, f.fremen); assert.equal(player(c.drawing, f.fremen).ally, f.guild);
|
|
assert.deepEqual([...c.drawing.nexusCards!.phase!.eligible].sort(), [f.owner, f.native].sort());
|
|
if (mode.advanced) assert.ok(c.drawing.spiceDiscard[1].some(card => 'territory' in card && card.territory === 'rock_outcroppings'),
|
|
'The actual second Advanced pile closes before any physical Nexus draw.');
|
|
const faces = family === 'ix' ? ['ixians', 'tleilaxu'] : ['richese', 'choam'];
|
|
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.owner], faces[0]);
|
|
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.native], faces[1]);
|
|
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.guild], null); assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.fremen], null);
|
|
if (mode.strongholds) assert.deepEqual(c.afterDraw.strongholdCards, f.firstMentat.after.strongholdCards);
|
|
for (const game of [f.afterSetup, f.game, c.entry.after, c.vote, c.ride.after, c.drawing, c.afterDraw]) custody(game);
|
|
});
|
|
|
|
void test('paired native Great Maker vote and both Spice piles without a settled alliance deal no Nexus cards', () => {
|
|
const f = createPairedDiscoveryNexusFixture({ family: 'ix' }), c = closePairedDiscoveryNexus(f);
|
|
const game = advancePairedDiscoveryNexus(c.ride.after, g => g.phase === 2 && pairedDiscoveryNexusClean(g));
|
|
assert.ok(game.players.every(p => p.ally === null));
|
|
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
|
assert.equal(game.nexusCards!.cards!.deck.length, c.ride.after.nexusCards!.cards!.deck.length);
|
|
assert.notEqual(game.nexusCards!.phase?.stage, 'drawing');
|
|
custody(game);
|
|
});
|
|
|
|
void test('paired E2 immediate and concealed groups are capped by the original reserve pool without a second tariff', () => {
|
|
const f = createPairedDiscoveryNexusFixture({ family: 'choam', advanced: false, tech: true, strongholds: false });
|
|
const c = closePairedDiscoveryNexus(f), window = richeseWindow(f, c.afterDraw);
|
|
const count = player(window, f.owner).reserves - 2;
|
|
placePairedDiscoveryNexus(window, f.owner, 'polar_sink:0', count);
|
|
f.staging.push(`Conserved ${count} original Richese reserves → Polar Sink; exactly two physical reserves remain before declaration.`);
|
|
const request = pairedDiscoveryRicheseRequest(f, window), before = player(window, f.owner);
|
|
const shipped = settlePairedDiscoveryNexusShipment(applyAction(window, f.owner, request));
|
|
assert.equal(player(shipped, f.owner).forces['shrine:0'], 2); assert.equal(player(shipped, f.owner).reserves, 0);
|
|
const materialized = richeseReveal(shipped, f.owner);
|
|
assert.equal(player(materialized, f.owner).forces['shrine:0'], 2);
|
|
assert.equal(player(materialized, f.owner).reserves, 0);
|
|
assert.equal(player(materialized, f.owner).spice, before.spice - 1);
|
|
assert.equal(player(materialized, f.guild).spice, player(window, f.guild).spice + 1);
|
|
assert.equal(player(materialized, f.owner).noField!.lastShipped, request.noField);
|
|
assert.equal(materialized.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
|
custody(materialized);
|
|
});
|
|
|
|
for (const advanced of [false, true]) void test(`paired E1 ${advanced ? 'Advanced' : 'Basic'}: original Cunning typed casualties, original Tech before nested Face Dance and draw-before-retire Tleilaxu Cunning`, () => {
|
|
const f = createPairedDiscoveryNexusFixture({ family: 'ix', advanced, tech: true, strongholds: advanced });
|
|
const closing = closePairedDiscoveryNexus(f), b = preparePairedDiscoveryNexusBattle(f, closing.afterDraw);
|
|
const offer = viewGame(b.game, f.owner).nexusSuboids!.offer!; assert.ok(offer && !offer.blocked);
|
|
const action: Action = { type: 'nexusSuboids', event: offer.event };
|
|
reject(b.game, f.native, action);
|
|
const used = applyAction(b.game, f.owner, action);
|
|
assert.equal(used.nexusCards!.cards!.hands[f.owner], null);
|
|
assert.equal(used.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
|
|
assert.deepEqual(player(used, f.owner).forces, player(b.game, f.owner).forces);
|
|
assert.deepEqual(player(used, f.owner).elites, player(b.game, f.owner).elites);
|
|
reject(used, f.owner, action);
|
|
const revealed = revealPairedDiscoveryNexusBattle(b, used), quote = quoteStrongholdFactionsBattle(revealed);
|
|
assert.equal(quote.winner, f.owner);
|
|
assert.equal(quote.casualties!.forces.normalFixedHalf, false);
|
|
assert.equal(quote.casualties!.forces.normalFreeSupport, true);
|
|
assert.equal(viewGame(revealed, f.owner).battle!.strongholdEffects[f.owner], null, 'Retained Arrakeen does not subsidize a different nested stronghold-price site.');
|
|
if (advanced) assert.equal(quote.payments.find(p => p.player === f.owner)!.bankSupport, 0);
|
|
let losses = advancePairedDiscoveryNexus(revealed, g => g.decision?.kind === 'battleLosses' || g.decision?.kind === 'faceDance');
|
|
if (losses.decision?.kind === 'battleLosses') {
|
|
const choice = losses.decision.options.findIndex(o => o.normal === 2 && o.elite === 0); assert.ok(choice >= 0);
|
|
losses = applyAction(losses, f.owner, { type: 'decision', choice });
|
|
}
|
|
const pending = advancePairedDiscoveryNexus(losses, g => g.decision?.kind === 'faceDance');
|
|
assert.equal(pending.lastBattleContext!.winner, f.owner);
|
|
assert.equal(player(pending, f.owner).forces['shrine:0'], 4);
|
|
assert.equal(player(pending, f.owner).elites!.forces['shrine:0'], 3);
|
|
assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks + 2);
|
|
assert.equal(player(pending, f.owner).elites!.tanks, player(revealed, f.owner).elites!.tanks);
|
|
assert.equal(player(pending, f.native).tanks, player(revealed, f.native).tanks + 6);
|
|
assert.ok(player(pending, f.owner).hand.some(c => c.id === b.weapon) && player(pending, f.owner).hand.some(c => c.id === b.defense));
|
|
const defeatedTech = ownedTech(revealed.techTokens, f.native); assert.equal(defeatedTech.length, 1);
|
|
assert.equal(pending.techTokens![defeatedTech[0]].owner, f.owner, 'Original winner already owns the actual loser Tech before replacement.');
|
|
const winner = player(pending, f.owner), dancer = player(pending, f.native), stock = structuredClone(dancer.faceDancers!);
|
|
const dance: Action = { type: 'decision', reveal: true, sector: 0, sources: { reserves: 2, [f.source]: 2 } };
|
|
reject(pending, f.native, { ...dance, sector: 1 }); reject(pending, f.native, { ...dance, sources: { [MOBILE_LOCATION]: 4 } });
|
|
reject(pending, f.native, { ...dance, sources: { reserves: 5 } });
|
|
const after = applyAction(pending, f.native, dance), returned = player(after, f.owner), replaced = player(after, f.native);
|
|
assert.equal(returned.forces['shrine:0'] ?? 0, 0); assert.equal(returned.elites!.forces['shrine:0'] ?? 0, 0);
|
|
assert.equal(returned.reserves, winner.reserves + 4); assert.equal(returned.elites!.reserves, winner.elites!.reserves + 3);
|
|
assert.equal(returned.tanks, winner.tanks); assert.equal(returned.spice, winner.spice); assert.deepEqual(returned.hand, winner.hand);
|
|
assert.equal(returned.leaders.find(l => l.id === b.leader)!.dead, true);
|
|
assert.equal(replaced.forces['shrine:0'], 4); assert.equal(replaced.forces[f.source], 1); assert.equal(replaced.reserves, dancer.reserves - 2);
|
|
assert.deepEqual(after.techTokens, pending.techTokens); assert.deepEqual(after.strongholdCards, pending.strongholdCards);
|
|
const revealedIdentity = replaced.faceDancers!.find(c => c.revealed)!; assert.ok(revealedIdentity);
|
|
const untouched = stock.filter(c => c.leader !== revealedIdentity.leader), reserve = [...after.traitorReserve!];
|
|
const view = viewGame(after, f.native), replacement = nexusFaceDancersAction(view, view.nexusTleilaxu!.cunning!.event); assert.ok(replacement);
|
|
reject(after, f.native, { ...replacement, leaders: [revealedIdentity.leader] });
|
|
const renewed = applyAction(after, f.native, replacement), nextStock = player(renewed, f.native).faceDancers!;
|
|
assert.deepEqual(nextStock.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
|
|
assert.deepEqual(nextStock.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]);
|
|
assert.deepEqual([...renewed.traitorReserve!].sort(), [...reserve.slice(1), revealedIdentity.leader].sort());
|
|
assert.equal(player(renewed, f.native).faceDancerReplacedTurn, replaced.faceDancerReplacedTurn);
|
|
assert.equal(renewed.nexusCards!.cards!.discard.filter(c => c === 'tleilaxu').length, 1);
|
|
reject(renewed, f.native, replacement); custody(renewed);
|
|
reservePairedDiscoveryNexusBoard(renewed, ['shrine:0', f.source, MOBILE_LOCATION]);
|
|
f.staging.push('After genuine Face Dance/replacement: conserve only the unrelated second-conflict groups back to their original reserves; original native Mentat closes the turn and expires Cunning.');
|
|
const nextTurn = advancePairedDiscoveryNexus(renewed, g => g.turn > renewed.turn);
|
|
assert.equal(viewGame(nextTurn, f.owner).nexusSuboids!.active, false);
|
|
assert.equal(nextTurn.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
|
|
if (advanced) assert.equal(nextTurn.strongholdCards!.owners.arrakeen, null, 'Only the actual next END Mentat updates the held card to the conserved current board.');
|
|
custody(nextTurn);
|
|
});
|
|
|
|
for (const advanced of [false, true]) void test(`paired E2 ${advanced ? 'Advanced' : 'Basic'}: signed nested pair charges one physical tariff, native reveal and CHOAM support invoice`, () => {
|
|
const f = createPairedDiscoveryNexusFixture({ family: 'choam', advanced, tech: true, strongholds: advanced });
|
|
const closing = closePairedDiscoveryNexus(f), window = richeseWindow(f, closing.afterDraw), request = pairedDiscoveryRicheseRequest(f, window);
|
|
const before = player(window, f.owner), guild = player(window, f.guild);
|
|
assert.equal(validGameLocation(window, 'shrine', 0), true); assert.equal(validGameLocation(window, 'cistern', 0), false);
|
|
reject(window, f.owner, { ...request, territory: 'cistern' }); reject(window, f.owner, { ...request, territory: 'invented-nested' });
|
|
reject(window, f.owner, { ...request, sector: 1 }); reject(window, f.owner, { ...request, revealedToken: request.noField });
|
|
holdPairedDiscoveryNexusCard(window, f.guild, c => c.effect === 'karama', f.staging);
|
|
const declared = applyAction(window, f.owner, request);
|
|
assert.equal(declared.response?.kind, 'richeseNoField'); assert.equal(declared.pendingShipment!.cost, 1);
|
|
assert.equal(declared.pendingShipment!.amount, 1); assert.equal(declared.nexusCards!.cards!.hands[f.owner], null);
|
|
assert.equal(player(declared, f.owner).spice, before.spice); assert.equal(player(declared, f.owner).reserves, before.reserves);
|
|
for (const target of ['cistern', 'invented-nested']) {
|
|
const corrupt = structuredClone(declared); corrupt.pendingShipment!.territory = target;
|
|
const next = nextPairedDiscoveryNexusStep(corrupt); assert.ok(next);
|
|
reject(corrupt, next.actor, next.action);
|
|
}
|
|
const shipped = settlePairedDiscoveryNexusShipment(declared), owner = player(shipped, f.owner);
|
|
assert.equal(owner.spice, before.spice - 1); assert.equal(player(shipped, f.guild).spice, guild.spice + 1);
|
|
assert.equal(owner.reserves, before.reserves - 5); assert.equal(owner.forces['shrine:0'], 5);
|
|
assert.equal(owner.noField!.deployed!.tokenId, request.noField); assert.equal(owner.noField!.lastShipped, request.noField);
|
|
assert.deepEqual(owner.noField!.tokens, before.noField!.tokens);
|
|
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped'); assert.equal(shipped.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
|
|
const publicView = viewGame(shipped, f.guild); assert.equal(publicView.richeseNoField!.private, null);
|
|
for (const token of owner.noField!.tokens) assert.equal(JSON.stringify(publicView).includes(token.id), false);
|
|
reject(shipped, f.owner, request);
|
|
for (const target of ['cistern', 'invented-nested']) {
|
|
const corrupt = structuredClone(shipped); corrupt.players.find(p => p.id === f.owner)!.noField!.deployed!.location = { territory: target, sector: 0 };
|
|
reject(corrupt, f.owner, { type: 'revealNoField', token: String(request.noField), event: owner.noFieldEvent });
|
|
}
|
|
const revealedMarker = richeseReveal(shipped, f.owner), materialized = player(revealedMarker, f.owner);
|
|
assert.equal(materialized.forces['shrine:0'], 8); assert.equal(materialized.reserves, before.reserves - 8);
|
|
assert.equal(materialized.spice, before.spice - 1); assert.equal(materialized.noField!.deployed, null);
|
|
assert.equal(materialized.noField!.lastShipped, request.noField); custody(revealedMarker);
|
|
const b = preparePairedDiscoveryNexusBattle(f, revealedMarker), revealed = revealPairedDiscoveryNexusBattle(b);
|
|
const quote = quoteStrongholdFactionsBattle(revealed); assert.equal(quote.winner, f.owner);
|
|
assert.equal(viewGame(revealed, f.owner).battle!.strongholdEffects[f.owner], null, 'Held Carthag is not inherited inside Shrine.');
|
|
if (advanced) {
|
|
const invoice = quote.payments.find(p => p.player === f.owner)!;
|
|
assert.equal(invoice.ownPayment, 3); assert.equal(invoice.bankSupport, 0);
|
|
assert.deepEqual(quote.choamIncome, { owner: f.native, amount: 1 });
|
|
} else { assert.deepEqual(quote.payments, []); assert.equal(quote.choamIncome, null); }
|
|
const done = advancePairedDiscoveryNexus(revealed, g => !g.battle && pairedDiscoveryNexusClean(g));
|
|
assert.equal(done.phase, 6, 'Second physical conflict keeps unrelated Collection out of this invoice.');
|
|
assert.equal(done.lastBattleContext!.winner, f.owner); assert.equal(player(done, f.owner).forces['shrine:0'], 5);
|
|
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 3);
|
|
assert.equal(player(done, f.guild).tanks, player(revealed, f.guild).tanks + 6);
|
|
assert.equal(player(done, f.native).spice, player(revealed, f.native).spice + (advanced ? 1 : 0));
|
|
const bounty = player(revealed, f.guild).leaders.find(l => l.id === b.enemy)!.strength;
|
|
assert.equal(player(done, f.owner).spice, player(revealed, f.owner).spice - (advanced ? 3 : 0) + bounty);
|
|
assert.deepEqual(done.strongholdCards, revealed.strongholdCards); custody(done);
|
|
});
|
|
|
|
void test('paired E2 CHOAM own Cunning uses original physical fuel and native response after the closing deal', () => {
|
|
const f = createPairedDiscoveryNexusFixture({ family: 'choam' }), closing = closePairedDiscoveryNexus(f);
|
|
let game = advancePairedDiscoveryNexus(closing.afterDraw, g => g.phase === 5 && g.active === f.native && pairedDiscoveryNexusClean(g));
|
|
const fuel = holdPairedDiscoveryNexusCard(game, f.native, c => c.kind === 'projectile', f.staging);
|
|
holdPairedDiscoveryNexusCard(game, f.guild, c => c.effect === 'karama', f.staging);
|
|
const offer = viewGame(game, f.native).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === fuel.id && p.effect === 'kulon');
|
|
assert.ok(offer && offer.source === 'nexus' && !offer.blocked);
|
|
const request: Action = { type: 'card', mode: 'choam', card: fuel.id, effect: 'kulon', nexus: offer.event };
|
|
const before = structuredClone(game); game = applyAction(game, f.native, request);
|
|
assert.equal(game.response?.kind, 'choamWorthless'); assert.equal(game.nexusCards!.cards!.hands[f.native], null);
|
|
assert.ok(player(game, f.native).hand.some(c => c.id === fuel.id));
|
|
game = settlePairedDiscoveryNexusShipment(game);
|
|
assert.equal(game.choamMovement!.bonus, 1); assert.equal(game.nexusChoamLast!.stage, 'complete');
|
|
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
|
|
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
|
for (const p of before.players) { assert.equal(player(game, p.id).spice, p.spice); assert.deepEqual(player(game, p.id).forces, p.forces); }
|
|
reject(game, f.native, request); custody(game);
|
|
});
|
|
|
|
void test('all four minimal policies consume actual paired entry/vote/typed ride/closing draw and native Face Dance outcomes', () => {
|
|
const f = createPairedDiscoveryNexusFixture({ family: 'ix' }), c = closePairedDiscoveryNexus(f);
|
|
const b = preparePairedDiscoveryNexusBattle(f, c.afterDraw), revealed = revealPairedDiscoveryNexusBattle(b);
|
|
const dance = advancePairedDiscoveryNexus(revealed, g => g.decision?.kind === 'faceDance');
|
|
for (const difficulty of DIFFICULTIES) for (const current of [
|
|
{ game: f.game, actor: f.collector, type: 'decision' },
|
|
{ game: c.vote, actor: c.vote.decision!.player, type: 'decision' },
|
|
{ game: c.ride.before, actor: f.fremen, type: 'decision' },
|
|
{ game: c.drawing, actor: f.owner, type: 'nexusCardChoice' },
|
|
{ game: dance, actor: f.native, type: 'decision' },
|
|
]) {
|
|
const view = viewGame(current.game, current.actor); view.players.find(p => p.id === current.actor)!.bot = difficulty;
|
|
const action = botActions(view).find(a => a.type === current.type); assert.ok(action);
|
|
const after = applyPairedDiscoveryNexusStep(structuredClone(current.game), { actor: current.actor, action });
|
|
if (current.game.decision?.kind === 'discoveryEntry') {
|
|
assert.equal(after.discoveryEntry, undefined);
|
|
assert.equal(player(after, current.actor).spice, player(current.game, current.actor).spice);
|
|
assert.equal(player(after, current.actor).reserves, player(current.game, current.actor).reserves);
|
|
} else if (current.game.decision?.kind === 'greatMakerVote')
|
|
assert.equal(after.greatMaker!.votes.length, current.game.greatMaker!.votes.length + 1);
|
|
else if (current.game.decision?.kind === 'greatMakerRide') {
|
|
assert.equal(after.greatMaker!.stage, 'complete');
|
|
assert.equal(player(after, f.fremen).spice, player(current.game, f.fremen).spice);
|
|
assert.equal(player(after, f.fremen).shipped, player(current.game, f.fremen).shipped);
|
|
} else if (current.game.decision?.kind === 'faceDance') {
|
|
assert.notEqual(after.decision?.kind, 'faceDance');
|
|
assert.deepEqual(after.techTokens, current.game.techTokens);
|
|
if (action.reveal) {
|
|
assert.equal(player(after, f.owner).forces['shrine:0'] ?? 0, 0);
|
|
assert.equal(player(after, f.owner).leaders.find(l => l.id === b.leader)!.dead, true);
|
|
assert.ok((player(after, f.native).forces['shrine:0'] ?? 0) > 0);
|
|
} else assert.deepEqual(player(after, f.owner).forces, player(current.game, f.owner).forces);
|
|
} else {
|
|
assert.ok(after.nexusCards!.phase!.done.includes(current.actor));
|
|
assert.ok(after.nexusCards!.cards!.hands[current.actor]);
|
|
assert.equal(after.nexusCards!.cards!.deck.length, current.game.nexusCards!.cards!.deck.length - 1);
|
|
}
|
|
custody(after);
|
|
}
|
|
});
|
|
|
|
void test('all four minimal policies settle the original signed E2 pair and materialize only physical reserves', () => {
|
|
const f = createPairedDiscoveryNexusFixture({ family: 'choam' }), c = closePairedDiscoveryNexus(f);
|
|
const window = richeseWindow(f, c.afterDraw), request = pairedDiscoveryRicheseRequest(f, window);
|
|
holdPairedDiscoveryNexusCard(window, f.guild, card => card.effect === 'karama', f.staging);
|
|
const pending = applyAction(window, f.owner, request);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(pending, f.guild);
|
|
view.players.find(p => p.id === f.guild)!.bot = difficulty;
|
|
const action = botActions(view).find(a => a.type === 'passResponse' || a.type === 'card');
|
|
assert.ok(action, 'The actual eligible Karama holder must resolve the signed pair response.');
|
|
let game = applyAction(pending, f.guild, action);
|
|
game = settlePairedDiscoveryNexusShipment(game);
|
|
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
|
|
if (game.nexusRicheseCunningLast!.stage === 'stopped') {
|
|
assert.equal(player(game, f.owner).spice, player(window, f.owner).spice);
|
|
assert.equal(player(game, f.owner).reserves, player(window, f.owner).reserves);
|
|
assert.deepEqual(player(game, f.owner).forces, player(window, f.owner).forces);
|
|
assert.deepEqual(player(game, f.owner).noField, player(window, f.owner).noField);
|
|
assert.equal(player(game, f.owner).shipped, false);
|
|
custody(game);
|
|
} else {
|
|
assert.equal(game.nexusRicheseCunningLast!.stage, 'shipped');
|
|
assert.equal(player(game, f.owner).forces['shrine:0'], 5);
|
|
assert.equal(player(game, f.owner).reserves, player(window, f.owner).reserves - 5);
|
|
assert.equal(player(game, f.owner).spice, player(window, f.owner).spice - 1);
|
|
const materialized = richeseReveal(game, f.owner);
|
|
assert.equal(player(materialized, f.owner).forces['shrine:0'], 8);
|
|
assert.equal(player(materialized, f.owner).reserves, player(window, f.owner).reserves - 8);
|
|
custody(materialized);
|
|
}
|
|
}
|
|
});
|
|
|
|
for (const family of ['ix', 'choam'] as const) void test(`paired ${family} authenticated original setup retains native actors, starting hands/offers and exposes a genuine human entry window`, () => {
|
|
const factions = family === 'ix' ? ['ixians', 'tleilaxu', 'guild', 'fremen'] as const : ['richese', 'choam', 'guild', 'fremen'] as const;
|
|
const lobby = createGame('PAIREDAUTHDISCOVERY', newPlayer(`auth-${factions[0]}`, factions[0], factions[0]), true, [family]);
|
|
for (const faction of factions.slice(1)) joinGame(lobby, newPlayer(`auth-${faction}`, faction, faction));
|
|
const setup = initializePairedDiscoveryNexusForFixture({ initial: lobby, family, tech: true, strongholds: true });
|
|
const original = structuredClone(setup), f = createPairedDiscoveryNexusFixture({ initial: setup });
|
|
assert.deepEqual(setup, original); assert.deepEqual(f.setup, original);
|
|
assert.equal(f.owner, `auth-${factions[0]}`); assert.equal(f.native, `auth-${factions[1]}`);
|
|
for (const p of setup.players) for (const card of p.hand)
|
|
assert.ok(player(f.afterSetup, p.id).hand.some(c => c.id === card.id), 'Continue the native starting hand, not a cleared/redealt approximation.');
|
|
assert.equal(f.game.decision!.player, family === 'ix' ? 'auth-ixians' : 'auth-guild');
|
|
const offer = viewGame(f.game, f.collector).discoveryEntry!;
|
|
const accepted = applyAction(f.game, f.collector, { type: 'decision', event: offer.event, accept: true,
|
|
groups: [{ source: f.source, normal: 1, elite: family === 'ix' ? 1 : 0 }] });
|
|
assert.equal(player(accepted, f.collector).forces['shrine:0'], family === 'ix' ? 2 : 1); custody(accepted);
|
|
});
|