190 lines
14 KiB
TypeScript
190 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
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 { createStrongholdCards } from '../game/stronghold-cards';
|
|
import { ownedTech } from '../game/tech-tokens';
|
|
import type { SukRescueOption } from '../game/suk-graduate';
|
|
import { quoteClassicSkillsStrongholdBattle } from './fixture-classic-skills-stronghold';
|
|
import { advanceClassicDiscoverySkills, classicDiscoverySkillsClean, createClassicDiscoverySkillsFixture,
|
|
settleClassicDiscoverySkillsArrival } from './fixture-discovery-classic-skills';
|
|
import { createDiscoverySkillsStrongholdsFixture, discoverySkillsStrongholdsPlayer as player,
|
|
finishDiscoverySkillsStrongholdsBattle, prepareDiscoverySkillsStrongholdsBattle,
|
|
revealDiscoverySkillsStrongholdsBattle } from './fixture-discovery-skills-strongholds';
|
|
|
|
function legalPolicies(game: Game, actor: string): void {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(game, actor); view.players.find(seat => seat.id === actor)!.bot = difficulty;
|
|
const actions = botActions(view);
|
|
assert.ok(actions.length, `${difficulty} has an original owned continuation.`);
|
|
for (const action of actions) {
|
|
const after = applyAction(structuredClone(game), actor, action);
|
|
assert.equal(ordinaryTotal(after, actor), ordinaryTotal(game, actor),
|
|
`${difficulty}'s actual entry, paid shipment or plan preserves its physical force inventory.`);
|
|
}
|
|
}
|
|
}
|
|
function reject(game: Game, actor: string, action: Action): void {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
|
}
|
|
function ordinaryTotal(game: Game, actor: string): number {
|
|
const seat = player(game, actor);
|
|
return seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, count) => sum + count, 0);
|
|
}
|
|
|
|
void test('authenticated Advanced original lobby and undealt offers retain identities, unused cards, real training and the first end-Mentat claim before free entry', () => {
|
|
const lobby = createGame('HUMANDISCOVERYSKILLS', newPlayer('human-guild', 'Guild', 'guild'), true);
|
|
joinGame(lobby, newPlayer('human-emperor', 'Emperor', 'emperor'));
|
|
joinGame(lobby, newPlayer('human-harkonnen', 'Harkonnen', 'harkonnen'));
|
|
const unchanged = structuredClone(lobby);
|
|
const fixture = createDiscoverySkillsStrongholdsFixture({ initial: lobby, tech: true });
|
|
assert.deepEqual(lobby, unchanged);
|
|
assert.deepEqual(fixture.setup.players.map(seat => seat.id), lobby.players.map(seat => seat.id));
|
|
assert.deepEqual(fixture.setup.strongholdCards, createStrongholdCards());
|
|
assert.equal(fixture.setup.leaderSkills!.deck.length + Object.values(fixture.setup.leaderSkills!.offers)
|
|
.reduce((sum, offer) => sum + offer.cards.length, 0), 14);
|
|
const claimed = applyAction(structuredClone(fixture.beforeFirstMentat), fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
|
|
assert.equal(claimed.turn, 2);
|
|
assert.equal(claimed.strongholdCards!.owners.arrakeen, fixture.owner);
|
|
assert.equal(claimed.strongholdCards!.claimedTurn, 1);
|
|
const before = player(fixture.entryWindow, fixture.owner);
|
|
legalPolicies(fixture.entryWindow, fixture.owner);
|
|
reject(fixture.entryWindow, fixture.opponent, fixture.entryStep.action);
|
|
const entered = applyAction(structuredClone(fixture.entryWindow), fixture.entryStep.actor, fixture.entryStep.action);
|
|
assert.equal(player(entered, fixture.owner).forces['cistern:0'], 3);
|
|
assert.equal(player(entered, fixture.owner).forces[`${fixture.discovery.parent}:${fixture.discovery.parentSector}`] ?? 0, 0);
|
|
assert.equal(player(entered, fixture.owner).spice, before.spice);
|
|
assert.equal(player(entered, fixture.owner).reserves, before.reserves);
|
|
assert.equal(player(entered, fixture.owner).moved, before.moved);
|
|
assert.equal(player(entered, fixture.owner).shipped, before.shipped);
|
|
assert.equal(viewGame(entered, fixture.owner).leaderSkills!.assignments.find(assignment => assignment.owner === fixture.owner)!.leader, fixture.discovery.leader);
|
|
const admitted = createDiscoverySkillsStrongholdsFixture({ initial: fixture.setup, tech: true });
|
|
assert.deepEqual(admitted.setup, fixture.setup, 'An admitted original setup is never redealt.');
|
|
assert.equal(admitted.game.strongholdCards!.owners.arrakeen, fixture.owner);
|
|
});
|
|
|
|
void test('held Arrakeen support pays only the actual subsidy while trained physical Suk rescue precedes original winner cleanup and Tech reward for all four policies', () => {
|
|
const fixture = createDiscoverySkillsStrongholdsFixture({ tech: true, support: 3, ownerDial: 4,
|
|
ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' });
|
|
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture);
|
|
const quote = quoteClassicSkillsStrongholdBattle(revealed);
|
|
const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!;
|
|
assert.equal(payment.bankSupport, 2); assert.equal(payment.ownPayment, 1);
|
|
assert.equal(quote.casualties!.options[0].normal, 5);
|
|
assert.equal(quote.sukGraduate?.mode, 'skilled');
|
|
const original = player(revealed, fixture.owner), enemy = player(revealed, fixture.opponent);
|
|
const token = ownedTech(revealed.techTokens, fixture.opponent); assert.equal(token.length, 1);
|
|
const rescue = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'skill' });
|
|
assert.ok(rescue.decision?.kind === 'sukRescue');
|
|
assert.equal(player(rescue, fixture.owner).spice, original.spice - 1 + 3, 'Only actual own support is debited; the dead printed enemy disc pays its real bounty.');
|
|
assert.equal(player(rescue, fixture.opponent).tanks, enemy.tanks + 8);
|
|
assert.deepEqual(rescue.techTokens, revealed.techTokens);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(rescue, fixture.owner); view.players.find(seat => seat.id === fixture.owner)!.bot = difficulty;
|
|
const action = botActions(view).find(candidate => candidate.type === 'decision');
|
|
assert.ok(action && typeof action.choice === 'number');
|
|
const selected: SukRescueOption = rescue.decision.options[action.choice];
|
|
assert.equal(selected.normal, 3); assert.equal(selected.elite, 0); assert.equal(selected.kept?.key, fixture.key);
|
|
reject(rescue, fixture.opponent, action);
|
|
const cleanup = applyAction(structuredClone(rescue), fixture.owner, action);
|
|
assert.equal(player(cleanup, fixture.owner).forces[fixture.key], 4);
|
|
assert.equal(player(cleanup, fixture.owner).reserves, original.reserves + 2);
|
|
assert.equal(player(cleanup, fixture.owner).tanks, original.tanks + 2);
|
|
assert.equal(player(cleanup, fixture.owner).battleLosses, original.battleLosses + 2);
|
|
assert.equal(ordinaryTotal(cleanup, fixture.owner), ordinaryTotal(revealed, fixture.owner));
|
|
assert.equal(cleanup.decision?.kind, 'battleCards');
|
|
assert.deepEqual(cleanup.techTokens, revealed.techTokens);
|
|
const done = finishDiscoverySkillsStrongholdsBattle(cleanup);
|
|
assert.equal(done.techTokens![token[0]].owner, fixture.owner);
|
|
assert.deepEqual(ownedTech(done.techTokens, fixture.opponent), []);
|
|
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
|
assert.equal(done.lastBattleContext!.sukRescue!.completed, true);
|
|
assert.equal(viewGame(done, fixture.owner).leaderSkills!.assignments.find(assignment => assignment.owner === fixture.owner)!.faceUp, true);
|
|
}
|
|
});
|
|
|
|
void test('unheld Arrakeen supplies no subsidy; a held Arrakeen card never donates its territory-only bank benefit to the actual nested Cistern battle', () => {
|
|
for (const options of [{ holder: 'unclaimed' as const }, { nestedBattle: true }]) {
|
|
const fixture = createDiscoverySkillsStrongholdsFixture({ ...options, support: 2, ownerDial: 4,
|
|
ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' });
|
|
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture);
|
|
assert.equal(viewGame(revealed, fixture.owner).battle!.strongholdEffects[fixture.owner], null);
|
|
const quote = quoteClassicSkillsStrongholdBattle(revealed);
|
|
const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!;
|
|
assert.equal(payment.bankSupport, 0); assert.equal(payment.ownPayment, 2);
|
|
const rescue = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'skill' });
|
|
assert.equal(player(rescue, fixture.owner).spice, player(revealed, fixture.owner).spice - 2 + 3);
|
|
assert.equal(rescue.lastBattleContext!.territory, options.nestedBattle ? 'cistern' : 'arrakeen');
|
|
assert.equal(rescue.strongholdCards!.owners.arrakeen, options.nestedBattle ? fixture.owner : null);
|
|
}
|
|
});
|
|
|
|
void test('held Carthag actual Shield protects the normal Prana Bindu-trained disc and its matching physical defense bonus wins; unheld poison still kills', () => {
|
|
for (const holder of ['owner', 'unclaimed'] as const) {
|
|
const fixture = createDiscoverySkillsStrongholdsFixture({ kind: 'carthag', holder, skill: 'prana-bindu-adept', hide: false,
|
|
ownerDial: 0.5, support: 0, opponentDial: 1, opponentSupport: 1, ownerDefense: 'shield', opponentWeapon: 'poison' });
|
|
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture);
|
|
const quote = quoteClassicSkillsStrongholdBattle(revealed);
|
|
assert.equal(quote.winner, holder === 'owner' ? fixture.owner : fixture.opponent);
|
|
const cleanup = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'cleanup' });
|
|
assert.equal(player(cleanup, fixture.owner).leaders.find(leader => leader.id === fixture.leader)!.dead, holder !== 'owner');
|
|
if (holder === 'owner') {
|
|
const bonus = quote.leaderSkillBonuses[revealed.battle!.attacker === fixture.owner ? 'attacker' : 'defender'];
|
|
assert.deepEqual(bonus.applied, [{ skill: 'prana-bindu-adept', amount: 1, mode: 'normal' }]);
|
|
assert.equal(player(cleanup, fixture.owner).spice, player(revealed, fixture.owner).spice);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('held Sietch Tabr victory income and sole Cistern Collection remain separate; Tabr creates no city Collection payment', () => {
|
|
const fixture = createDiscoverySkillsStrongholdsFixture({ kind: 'sietch_tabr', skill: 'planetologist', ownerDial: 2, support: 2,
|
|
opponentDial: 1, opponentSupport: 1, ownerDefense: 'shield' });
|
|
const revealed = revealDiscoverySkillsStrongholdsBattle(fixture), quote = quoteClassicSkillsStrongholdBattle(revealed);
|
|
assert.equal(quote.winner, fixture.owner);
|
|
assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 1 }]);
|
|
const cleanup = finishDiscoverySkillsStrongholdsBattle(revealed, { stop: 'cleanup' });
|
|
const before = player(revealed, fixture.owner).spice;
|
|
assert.equal(player(cleanup, fixture.owner).spice, before - 2 + 1);
|
|
const collected = advanceClassicDiscoverySkills(finishDiscoverySkillsStrongholdsBattle(cleanup), state => state.phase === 7 && classicDiscoverySkillsClean(state));
|
|
assert.equal(player(collected, fixture.owner).forces['cistern:0'], 3);
|
|
assert.equal(player(collected, fixture.owner).forces[fixture.key], 6);
|
|
assert.equal(player(collected, fixture.owner).spice, before - 2 + 1 + 2,
|
|
'Only actual Tabr battle income and sole Cistern Collection pay; Tabr is not an income-paying city.');
|
|
const nextTurn = advanceClassicDiscoverySkills(collected, state => state.turn === 3);
|
|
assert.equal(nextTurn.strongholdCards!.claimedTurn, 2, 'The next real end-Mentat must accept the actually entered nested force keys.');
|
|
assert.equal(nextTurn.strongholdCards!.owners.sietch_tabr, fixture.owner);
|
|
assert.equal(player(nextTurn, fixture.owner).forces['cistern:0'], 3);
|
|
});
|
|
|
|
void test('real held-card nested shipment charges one spice, does not consume free-entry movement, and has four legal native policies', () => {
|
|
const fixture = createDiscoverySkillsStrongholdsFixture();
|
|
const window = fixture.shipmentWindow, before = player(window, fixture.owner);
|
|
legalPolicies(window, fixture.owner);
|
|
let shipped = applyAction(structuredClone(window), fixture.paidShipmentStep.actor, fixture.paidShipmentStep.action);
|
|
shipped = settleClassicDiscoverySkillsArrival(shipped);
|
|
assert.equal(player(shipped, fixture.owner).spice, before.spice - 1);
|
|
assert.equal(player(shipped, fixture.owner).reserves, before.reserves - 1);
|
|
assert.equal(player(shipped, fixture.owner).forces['cistern:0'], 4);
|
|
assert.equal(player(shipped, fixture.owner).moved, before.moved);
|
|
assert.equal(player(shipped, fixture.owner).shipped, true);
|
|
assert.equal(shipped.strongholdCards!.owners.arrakeen, fixture.owner);
|
|
const prepared = prepareDiscoverySkillsStrongholdsBattle(fixture);
|
|
legalPolicies(prepared, fixture.owner);
|
|
});
|
|
|
|
void test('selected canonical unused cards are retained while Basic admission and injected held receipts remain outside original controls', () => {
|
|
const original = createClassicDiscoverySkillsFixture({ advanced: true, strongholdCards: true });
|
|
assert.deepEqual(original.setup.strongholdCards, createStrongholdCards());
|
|
const actualOffers = structuredClone(original.setup);
|
|
const resumed = createDiscoverySkillsStrongholdsFixture({ initial: actualOffers });
|
|
assert.deepEqual(actualOffers, original.setup, 'The authenticated original offers remain untouched.');
|
|
assert.deepEqual(resumed.setup, original.setup);
|
|
assert.equal(resumed.game.leaderSkills!.assignments.find(assignment => assignment.owner === resumed.owner)!.skill, 'planetologist',
|
|
'Human setup continues its actual available offer, not a demanded Suk replacement.');
|
|
assert.throws(() => createClassicDiscoverySkillsFixture({ strongholdCards: true }));
|
|
const forged = structuredClone(original.setup); forged.strongholdCards!.owners.arrakeen = original.collector;
|
|
assert.throws(() => createClassicDiscoverySkillsFixture({ initial: forged }));
|
|
});
|