297 lines
20 KiB
TypeScript
297 lines
20 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 { gameDistance, splitLocation, TERRITORIES, territory } from '../game/board';
|
|
import { presenceAt } from '../game/force-presence';
|
|
import { ownedTech } from '../game/tech-tokens';
|
|
import { quotePhaseResources } from '../game/phase-resource-quote';
|
|
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
|
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
|
import { orderNexusSpice } from './fixture-nexus-cards';
|
|
import {
|
|
advancePairedChoam, allowPairedChoam, createPairedChoamNexusModulesFixture,
|
|
holdPairedChoamCard, initializePairedChoamModules, pairedChoamClean,
|
|
pairedChoamPlayer as player, pairedRicheseCunningRequest, placePairedChoam,
|
|
revealPairedRichese, type PairedChoamNexusModulesFixture,
|
|
} from './fixture-paired-choam-nexus-modules';
|
|
|
|
function reject(game: Game, actor: string, action: Action): void {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before, 'An illegal native choice cannot consume original money, tokens, cards or counters');
|
|
}
|
|
function physicalCards(game: Game): string[] {
|
|
return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
|
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
|
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
|
|
}
|
|
function conserved(game: Game, original: Game): void {
|
|
assert.deepEqual(physicalCards(game), physicalCards(original), 'Original E2 deck and native cache have one physical custody location per card');
|
|
for (const p of game.players) {
|
|
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction);
|
|
assert.deepEqual(p.noField?.tokens, player(original, p.id).noField?.tokens, 'Physical marker identities and printed values never change');
|
|
}
|
|
}
|
|
function assertPair(f: PairedChoamNexusModulesFixture, after: Game, faceup: number): void {
|
|
const before = player(f.game, f.richese), p = player(after, f.richese);
|
|
const request = pairedRicheseCunningRequest(f);
|
|
assert.equal(p.spice, before.spice - 1, 'The original one-marker Stronghold tariff is paid, not the eight-force tariff');
|
|
assert.equal(player(after, f.guild).spice, player(f.game, f.guild).spice + 1, 'The actual native Guild receives the one-spice invoice');
|
|
assert.equal(p.reserves, before.reserves - faceup);
|
|
assert.equal(p.forces[f.location] ?? 0, faceup);
|
|
assert.equal(p.noField!.deployed!.tokenId, request.noField);
|
|
assert.equal(p.noField!.lastShipped, request.noField);
|
|
assert.equal(presenceAt(p, 'habbanya_ridge_sietch'), faceup + 1);
|
|
assert.equal(after.nexusCards!.cards!.hands[f.richese], null);
|
|
assert.equal(after.nexusRicheseCunningLast!.stage, 'shipped');
|
|
conserved(after, f.afterSetup);
|
|
}
|
|
|
|
for (const modules of [
|
|
{ advanced: false, tech: true, strongholds: false },
|
|
{ advanced: true, tech: true, strongholds: false },
|
|
{ advanced: true, tech: false, strongholds: true },
|
|
{ advanced: true, tech: true, strongholds: true },
|
|
]) void test(`paired E2 ${modules.advanced ? 'Advanced' : 'Basic'} Tech=${modules.tech} SH=${modules.strongholds}: real two-token shipment, one tariff and once-only phase-end Heighliners`, () => {
|
|
const f = createPairedChoamNexusModulesFixture(modules), request = pairedRicheseCunningRequest(f);
|
|
assert.equal(player(f.firstShipment.after, f.richese).spice, player(f.firstShipment.before, f.richese).spice - 1);
|
|
assert.equal(player(f.firstShipment.after, f.richese).reserves, player(f.firstShipment.before, f.richese).reserves);
|
|
const previous = player(f.game, f.richese).noField!.tokens.find(t => t.value === 0)!;
|
|
assert.equal(player(f.game, f.richese).noField!.lastShipped, previous.id, 'Genuine first-turn shipment, not a staged last-used stamp');
|
|
reject(f.game, f.richese, { ...request, noField: previous.id });
|
|
reject(f.game, f.richese, { ...request, revealedToken: previous.id });
|
|
reject(f.game, f.richese, { ...request, revealedToken: request.noField });
|
|
const declared = applyAction(f.game, f.richese, request);
|
|
const shipped = allowPairedChoam(declared);
|
|
assertPair(f, shipped, 5);
|
|
if (modules.strongholds) {
|
|
assert.equal(f.afterFirstMentat.strongholdCards!.owners.habbanya_ridge_sietch, f.richese);
|
|
assert.equal(shipped.strongholdCards!.owners.habbanya_ridge_sietch, f.richese, 'The card is retained even after controlled board relocation');
|
|
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.choam);
|
|
}
|
|
if (modules.tech) {
|
|
const token = shipped.techTokens!.heighliners;
|
|
assert.ok(token.owner);
|
|
const amount = ownedTech(f.game.techTokens, token.owner).length;
|
|
assert.equal(token.triggeredTurn, shipped.turn);
|
|
assert.equal(token.spice, amount);
|
|
assert.equal(player(shipped, token.owner).spice, player(f.game, token.owner).spice + (token.owner === f.richese ? -1 : token.owner === f.guild ? 1 : 0), 'Industry accrues without immediate wallet credit');
|
|
reject(shipped, f.richese, { type: 'ship', amount: 1, territory: 'arrakeen', sector: territory('arrakeen').sectors[0] });
|
|
let another = shipped;
|
|
if (shipped.movementRemaining?.includes(f.choam)) {
|
|
const atChoam = advancePairedChoam(shipped, g => g.phase === 5 && g.active === f.choam && pairedChoamClean(g));
|
|
another = allowPairedChoam(applyAction(atChoam, f.choam, { type: 'ship', amount: 1, territory: 'arrakeen', sector: territory('arrakeen').sectors[0] }));
|
|
assert.equal(player(another, f.choam).spice, player(atChoam, f.choam).spice - 1);
|
|
assert.equal(player(another, f.guild).spice, player(atChoam, f.guild).spice + 1);
|
|
}
|
|
assert.equal(another.techTokens!.heighliners.spice, amount, 'Neither a second paid shipment nor a rejected repeated shipment accrues industry again');
|
|
const credit = quotePhaseResources(another).credits.find(c => c.kind === 'tech' && c.token === 'heighliners');
|
|
assert.ok(credit);
|
|
assert.equal(credit.player, token.owner);
|
|
assert.equal(credit.amount, amount);
|
|
const collection = quoteSpiceCollection(another).receipts.find(r => r.player === token.owner);
|
|
const ended = advancePairedChoam(another, g => g.phase >= 6 && pairedChoamClean(g));
|
|
assert.equal(player(ended, token.owner).spice, credit.balance +
|
|
(ended.phase === 7 ? (collection?.collected ?? 0) + (collection?.strongholds ?? 0) : 0));
|
|
assert.equal(ended.techTokens!.heighliners.spice, 0);
|
|
assert.equal(ended.techTokens!.heighliners.triggeredTurn, ended.turn);
|
|
assertPair(f, shipped, 5);
|
|
conserved(ended, f.afterSetup);
|
|
}
|
|
});
|
|
|
|
void test('paired E2 materializes only available physical reserves, and voluntary revelation retains previous-marker restriction on the next native shipment', () => {
|
|
const f = createPairedChoamNexusModulesFixture({ advanced: false, strongholds: false, reserveCap: 2 });
|
|
const shipped = allowPairedChoam(applyAction(f.game, f.richese, pairedRicheseCunningRequest(f)));
|
|
assertPair(f, shipped, 2);
|
|
const revealed = revealPairedRichese(shipped, f.richese);
|
|
assert.equal(player(revealed, f.richese).noField!.deployed, null);
|
|
assert.equal(player(revealed, f.richese).forces[f.location], 2, 'Concealed three cannot manufacture counters when reserves are empty');
|
|
assert.equal(player(revealed, f.richese).reserves, 0);
|
|
const previous = player(revealed, f.richese).noField!.lastShipped!;
|
|
let next = advancePairedChoam(revealed, g => g.turn === 3 && g.phase === 1 && pairedChoamClean(g));
|
|
// Deck ordering remains physical; prevent a fresh worm from changing this native lifecycle program.
|
|
orderNexusSpice(next, ['land', 'land']);
|
|
next = advancePairedChoam(next, g => g.turn === 3 && g.phase === 5 && g.active === f.richese && pairedChoamClean(g));
|
|
reject(next, f.richese, { type: 'ship', noField: previous, event: player(next, f.richese).noFieldEvent, territory: 'polar_sink', sector: 0 });
|
|
const zero = player(next, f.richese).noField!.tokens.find(t => t.value === 0)!;
|
|
const accepted = allowPairedChoam(applyAction(next, f.richese, { type: 'ship', noField: zero.id, event: player(next, f.richese).noFieldEvent, territory: 'polar_sink', sector: 0 }));
|
|
assert.equal(player(accepted, f.richese).noField!.lastShipped, zero.id);
|
|
assert.equal(player(accepted, f.richese).noField!.deployed!.tokenId, zero.id);
|
|
assert.equal(player(accepted, f.richese).spice, player(next, f.richese).spice - 2, 'Polar Sink uses the actual ordinary one-marker non-Stronghold price');
|
|
conserved(accepted, f.afterSetup);
|
|
});
|
|
|
|
void test('paired E2 Karama-stopped pair spends Nexus but neither invoice, counters, markers nor Heighliners accrual', () => {
|
|
const f = createPairedChoamNexusModulesFixture();
|
|
const karama = holdPairedChoamCard(f.game, f.guild, c => c.kind === 'special' && c.effect === 'karama');
|
|
const before = structuredClone(f.game), request = pairedRicheseCunningRequest(f);
|
|
const pending = applyAction(f.game, f.richese, request);
|
|
assert.equal(pending.response?.kind, 'richeseNoField');
|
|
assert.equal(pending.pendingShipment?.cost, 1);
|
|
assert.equal(pending.pendingShipment?.amount, 1);
|
|
assert.equal(pending.nexusCards!.cards!.hands[f.richese], null);
|
|
const stopped = allowPairedChoam(applyAction(pending, f.guild, { type: 'card', mode: 'cancel', card: karama.id }));
|
|
assert.equal(stopped.nexusRicheseCunningLast!.stage, 'stopped');
|
|
for (const p of before.players) assert.equal(player(stopped, p.id).spice, p.spice, 'No paid leg exists for the stopped shipment');
|
|
assert.deepEqual(player(stopped, f.richese).forces, player(before, f.richese).forces);
|
|
assert.equal(player(stopped, f.richese).reserves, player(before, f.richese).reserves);
|
|
assert.deepEqual(player(stopped, f.richese).noField, player(before, f.richese).noField);
|
|
assert.deepEqual(stopped.techTokens, before.techTokens);
|
|
assert.equal(player(stopped, f.richese).shipped, false);
|
|
reject(stopped, f.richese, request);
|
|
const ordinary = allowPairedChoam(applyAction(stopped, f.richese, { type: 'ship', amount: 1, territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0] }));
|
|
assert.equal(player(ordinary, f.richese).spice, player(stopped, f.richese).spice - 1);
|
|
assert.equal(player(ordinary, f.guild).spice, player(stopped, f.guild).spice + 1);
|
|
assert.equal(ordinary.techTokens!.heighliners.triggeredTurn, ordinary.turn);
|
|
conserved(ordinary, f.afterSetup);
|
|
});
|
|
|
|
void test('paired CHOAM own Cunning consumes one original physical fuel card through the real printed Kulon response and enables its extra territory', () => {
|
|
const f = createPairedChoamNexusModulesFixture();
|
|
let game = advancePairedChoam(f.movementStart, g => g.phase === 5 && g.active === f.choam && pairedChoamClean(g));
|
|
const card = holdPairedChoamCard(game, f.choam, c => c.kind === 'projectile');
|
|
holdPairedChoamCard(game, f.guild, c => c.kind === 'special' && c.effect === 'karama');
|
|
const offer = viewGame(game, f.choam).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === card.id && p.effect === 'kulon');
|
|
assert.ok(offer && offer.source === 'nexus' && !offer.blocked);
|
|
const request: Action = { type: 'card', mode: 'choam', card: card.id, effect: 'kulon', nexus: offer.event };
|
|
const before = structuredClone(game);
|
|
game = applyAction(game, f.choam, request);
|
|
assert.equal(game.response?.kind, 'choamWorthless');
|
|
assert.equal(game.nexusCards!.cards!.hands[f.choam], null);
|
|
assert.ok(player(game, f.choam).hand.some(c => c.id === card.id), 'Fuel remains physical until the original response resolves');
|
|
game = allowPairedChoam(game);
|
|
assert.equal(game.nexusChoamLast!.stage, 'complete');
|
|
assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
|
|
assert.equal(game.choamMovement!.bonus, 1);
|
|
assert.equal(viewGame(game, f.choam).choamMovementBonus, 1);
|
|
for (const p of before.players) assert.equal(player(game, p.id).spice, p.spice);
|
|
reject(game, f.choam, request);
|
|
const keys = TERRITORIES.flatMap(t => t.sectors.map(s => `${t.id}:${s}`));
|
|
let destination: string | undefined;
|
|
const source = keys.find(from => {
|
|
const origin = splitLocation(from);
|
|
if (origin.sector === game.storm || territory(origin.territory).type === 'stronghold' || origin.territory === 'polar_sink') return false;
|
|
destination = keys.find(to => splitLocation(to).sector !== game.storm &&
|
|
gameDistance(game, from, to, k => splitLocation(k).sector === game.storm) === 2);
|
|
return !!destination;
|
|
});
|
|
assert.ok(source && destination, 'An actual clear two-territory route is available for Kulon');
|
|
placePairedChoam(game, f.choam, source, 1);
|
|
const move: Action = { type: 'move', from: source, amount: 1, ...splitLocation(destination) };
|
|
placePairedChoam(before, f.choam, source, 1);
|
|
reject(before, f.choam, move);
|
|
const moved = allowPairedChoam(applyAction(game, f.choam, move));
|
|
assert.equal(player(moved, f.choam).forces[destination], 1);
|
|
assert.equal(player(moved, f.choam).forces[source] ?? 0, 0);
|
|
assert.equal(player(moved, f.choam).moved, 1);
|
|
conserved(moved, f.afterSetup);
|
|
conserved(game, f.afterSetup);
|
|
});
|
|
|
|
void test('paired Advanced Habbanya retained effect wins an ordinary tied battle after native voluntary marker reveal, without entering mixed No-Field plans', () => {
|
|
const f = createPairedChoamNexusModulesFixture();
|
|
let game = allowPairedChoam(applyAction(f.game, f.richese, pairedRicheseCunningRequest(f)));
|
|
game = revealPairedRichese(game, f.richese);
|
|
assert.equal(player(game, f.richese).noField!.deployed, null);
|
|
assert.equal(player(game, f.richese).forces[f.location], 8);
|
|
placePairedChoam(game, f.guild, f.location, 6);
|
|
game = advancePairedChoam(game, g => g.phase === 6 && pairedChoamClean(g));
|
|
assert.ok(game.active === f.richese || game.active === f.guild);
|
|
game = applyAction(game, game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch', target: game.active === f.richese ? f.guild : f.richese });
|
|
game = advancePairedChoam(game, g => pairedChoamClean(g) && !!g.battle && (!g.battle.preLeader || g.battle.preLeader.closed) && !g.battle.preparation);
|
|
const richeseLeader = player(game, f.richese).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
|
const guildLeader = player(game, f.guild).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
|
assert.ok(richeseLeader && guildLeader);
|
|
const richeseDial = Math.max(0, guildLeader.strength - richeseLeader.strength) + 1;
|
|
const guildDial = Math.max(0, richeseLeader.strength - guildLeader.strength) + 1;
|
|
assert.ok(richeseDial <= 8 && guildDial <= 6);
|
|
for (const [actor, leader, dial] of [[f.richese, richeseLeader.id, richeseDial], [f.guild, guildLeader.id, guildDial]] as const) {
|
|
game = applyAction(game, actor, { type: 'battlePlan', leader, dial, support: dial, weapon: null, defense: null });
|
|
game = allowPairedChoam(game);
|
|
}
|
|
assert.ok(game.battle?.revealed);
|
|
const revealed = structuredClone(game), quote = quoteStrongholdFactionsBattle(game);
|
|
assert.equal(richeseDial + richeseLeader.strength, guildDial + guildLeader.strength);
|
|
assert.equal(viewGame(revealed, f.richese).battle!.strongholdEffects[f.richese], 'habbanya_ridge_sietch', 'Tie benefit is attributed to the actual retained card holder and committed plan');
|
|
assert.equal(quote.winner, f.richese);
|
|
const payment = quote.payments.find(p => p.player === f.richese)!;
|
|
assert.equal(payment.ownPayment, richeseDial);
|
|
assert.equal(payment.bankSupport, 0, 'Habbanya grants tie priority, not Arrakeen support');
|
|
game = advancePairedChoam(game, g => !g.battle && !g.response && !g.decision && !g.pendingTreacheryDiscard);
|
|
assert.equal(game.lastBattleContext!.winner, f.richese);
|
|
assert.equal(player(game, f.richese).tanks, richeseDial);
|
|
assert.equal(player(game, f.richese).forces[f.location], 8 - richeseDial);
|
|
assert.equal(player(game, f.guild).tanks, 6);
|
|
assert.equal(game.strongholdCards!.owners.habbanya_ridge_sietch, f.richese);
|
|
const collection = quoteSpiceCollection(game).receipts;
|
|
for (const p of revealed.players) {
|
|
const paid = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0;
|
|
const choam = quote.choamIncome?.owner === p.id ? quote.choamIncome.amount : 0;
|
|
const city = collection.find(r => r.player === p.id);
|
|
assert.equal(player(game, p.id).spice, p.spice - paid + choam + (city?.strongholds ?? 0) + (city?.collected ?? 0), 'Plan support and native CHOAM bank legs are separate from city Collection');
|
|
}
|
|
for (const token of ownedTech(revealed.techTokens, f.guild)) assert.equal(game.techTokens![token].owner, f.richese);
|
|
conserved(game, f.afterSetup);
|
|
});
|
|
|
|
void test('paired original undealt CLI setup preserves authenticated actors and first deal while continuing real closing Nexus participation', () => {
|
|
const lobby = createGame('CHOAMCLIAUDIT', newPlayer('auth-richese', 'R', 'richese'), true, ['choam']);
|
|
joinGame(lobby, newPlayer('auth-choam', 'C', 'choam'));
|
|
joinGame(lobby, newPlayer('auth-guild', 'G', 'guild'));
|
|
joinGame(lobby, newPlayer('auth-atreides', 'A', 'atreides'));
|
|
const setup = initializePairedChoamModules({ initial: lobby });
|
|
const original = structuredClone(setup);
|
|
const f = createPairedChoamNexusModulesFixture({ initial: setup });
|
|
assert.deepEqual(setup, original);
|
|
assert.deepEqual(f.initial, setup, 'Supplied undealt initializer is not restarted or redealt');
|
|
assert.equal(f.richese, 'auth-richese');
|
|
assert.equal(f.choam, 'auth-choam');
|
|
assert.equal(f.guild, 'auth-guild');
|
|
assert.equal(f.observer, 'auth-atreides');
|
|
const live = viewGame(f.game, f.richese);
|
|
live.players.find(p => p.id === f.richese)!.bot = 'Hard';
|
|
const request = botActions(live)[0];
|
|
assert.ok(request?.type === 'ship' && request.noField && request.revealedToken);
|
|
const completed = allowPairedChoam(applyAction(f.game, f.richese, request));
|
|
assert.equal(completed.nexusRicheseCunningLast!.stage, 'shipped');
|
|
assert.equal(completed.nexusCards!.cards!.hands[f.richese], null);
|
|
conserved(completed, f.afterSetup);
|
|
});
|
|
|
|
void test('paired Basic Tech ordinary battle uses revealed physical pools and no Advanced support payment or CHOAM bank-support income', () => {
|
|
const f = createPairedChoamNexusModulesFixture({ advanced: false, strongholds: false });
|
|
let game = allowPairedChoam(applyAction(f.game, f.richese, pairedRicheseCunningRequest(f)));
|
|
game = revealPairedRichese(game, f.richese);
|
|
placePairedChoam(game, f.guild, f.location, 6);
|
|
game = advancePairedChoam(game, g => g.phase === 6 && pairedChoamClean(g));
|
|
assert.ok(game.active === f.richese || game.active === f.guild);
|
|
game = applyAction(game, game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch', target: game.active === f.richese ? f.guild : f.richese });
|
|
game = advancePairedChoam(game, g => pairedChoamClean(g) && !!g.battle && (!g.battle.preLeader || g.battle.preLeader.closed) && !g.battle.preparation);
|
|
const winner = player(game, f.richese).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
|
|
const loser = player(game, f.guild).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
|
|
assert.ok(winner && loser);
|
|
assert.ok(winner.strength + 3 > loser.strength);
|
|
for (const [actor, leader, dial] of [[f.richese, winner.id, 3], [f.guild, loser.id, 0]] as const) {
|
|
game = applyAction(game, actor, { type: 'battlePlan', leader, dial, support: 0, weapon: null, defense: null });
|
|
game = allowPairedChoam(game);
|
|
}
|
|
assert.ok(game.battle?.revealed);
|
|
const revealed = structuredClone(game), quote = quoteStrongholdFactionsBattle(game);
|
|
assert.equal(quote.winner, f.richese);
|
|
assert.equal(quote.choamIncome, null);
|
|
for (const payment of quote.payments) {
|
|
assert.equal(payment.ownPayment, 0);
|
|
assert.equal(payment.bankSupport, 0);
|
|
assert.equal(payment.cost, 0, 'Basic dials never become Advanced support invoices');
|
|
}
|
|
game = advancePairedChoam(game, g => !g.battle && !g.response && !g.decision && !g.pendingTreacheryDiscard);
|
|
assert.equal(player(game, f.richese).tanks, 3);
|
|
assert.equal(player(game, f.richese).forces[f.location], 5);
|
|
assert.equal(player(game, f.guild).tanks, 6);
|
|
for (const p of revealed.players) assert.equal(player(game, p.id).spice, p.spice);
|
|
for (const token of ownedTech(revealed.techTokens, f.guild)) assert.equal(game.techTokens![token].owner, f.richese);
|
|
conserved(game, f.afterSetup);
|
|
});
|