125 lines
7.3 KiB
TypeScript
125 lines
7.3 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
|
import { ownedTech } from '../game/tech-tokens';
|
|
import { RECRUITS_CARD_ID } from '../game/recruits';
|
|
import { assertRecruitsHomeworldInventory } from './fixture-recruits-homeworld';
|
|
import {
|
|
createWorldPhaseRecruitsFixture, worldPhaseRecruitsNativePool as pool,
|
|
worldPhaseRecruitsPlayer as player,
|
|
} from './fixture-world-phase-recruits';
|
|
|
|
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
|
|
const before = JSON.stringify(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.equal(JSON.stringify(game), before, 'Rejected spending/returns must preserve the actual programme');
|
|
}
|
|
|
|
const cases = [
|
|
{ name: 'Basic World + Tech', advanced: false, strongholds: false },
|
|
{ name: 'Advanced World + Tech', advanced: true, strongholds: false },
|
|
{ name: 'Advanced World + Tech + earned Strongholds', advanced: true, strongholds: true },
|
|
];
|
|
|
|
for (const options of cases) {
|
|
void test(`${options.name}: original casualties, useful doubled typed returns and one delayed Axlotl payment`, () => {
|
|
const f = createWorldPhaseRecruitsFixture(options);
|
|
const { emperor, fremen, atreides, guild, owner } = f.actors;
|
|
const start = f.game;
|
|
assert.equal(player(start, fremen).reserves, 2);
|
|
assert.equal(player(start, fremen).tanks, 18);
|
|
assert.equal(player(start, fremen).elites!.tanks, 3);
|
|
assert.equal(player(start, atreides).tanks, 10);
|
|
assert.equal(start.techTokens!.production.owner, emperor, 'The real Fremen loss transfers its original token');
|
|
assert.equal(ownedTech(start.techTokens, owner).length, 2);
|
|
assert.equal(start.techTokens!.axlotl.spice, 0);
|
|
|
|
assert.deepEqual({ free: f.ordinary.fremen.freeRemaining, limit: f.ordinary.fremen.limit,
|
|
bonus: f.ordinary.fremen.homeworldBonus }, { free: 4, limit: 4, bonus: 1 });
|
|
assert.deepEqual({ free: f.ordinary.atreides.freeRemaining, limit: f.ordinary.atreides.limit },
|
|
{ free: 2, limit: 3 });
|
|
const paid = player(f.afterPaidReturn, atreides);
|
|
assert.deepEqual({ revived: paid.revived, free: paid.freeForcesRevived, reserves: paid.reserves,
|
|
tanks: paid.tanks, spice: paid.spice },
|
|
{ revived: 3, free: 2, reserves: 13, tanks: 7, spice: player(start, atreides).spice - 2 });
|
|
assert.ok(f.beforePlay.ready.includes(atreides));
|
|
assert.equal(viewGame(f.afterPlay, fremen).revival.freeRemaining, 7);
|
|
assert.equal(viewGame(f.afterPlay, fremen).revival.limit, 7);
|
|
assert.equal(viewGame(f.afterPlay, atreides).revival.freeRemaining, 2,
|
|
'Doubled free allowance subtracts the recorded two free returns, not all three returns');
|
|
assert.equal(viewGame(f.afterPlay, atreides).revival.forcesRemaining, 4);
|
|
const reopened = player(f.beforeFremen, atreides);
|
|
assert.deepEqual({ revived: reopened.revived, free: reopened.freeForcesRevived,
|
|
reserves: reopened.reserves, tanks: reopened.tanks, spice: reopened.spice },
|
|
{ revived: 5, free: 4, reserves: 15, tanks: 5, spice: paid.spice });
|
|
assert.deepEqual(pool(f.beforeFremen, atreides), { normal: 15, elite: 0 });
|
|
|
|
assert.deepEqual(pool(f.afterTypedReturn, fremen), { normal: 2, elite: 1 });
|
|
const current = viewGame(f.afterTypedReturn, fremen).revival;
|
|
assert.deepEqual({ freeRemaining: current.freeRemaining, forcesRemaining: current.forcesRemaining,
|
|
homeworldBonus: current.homeworldBonus }, { freeRemaining: 5, forcesRemaining: 5, homeworldBonus: 0 });
|
|
assert.equal(current.eliteRemaining, options.advanced ? 0 : 2);
|
|
rejectsUnchanged(f.afterTypedReturn, fremen, { type: 'revive', amount: 6, elite: 0 });
|
|
if (options.advanced)
|
|
rejectsUnchanged(f.afterTypedReturn, fremen, { type: 'revive', amount: 1, elite: 1 });
|
|
const returned = player(f.afterReturns, fremen);
|
|
assert.deepEqual({ revived: returned.revived, free: returned.freeForcesRevived,
|
|
reserves: returned.reserves, tanks: returned.tanks, spice: returned.spice },
|
|
{ revived: 6, free: 6, reserves: 8, tanks: 12, spice: player(start, fremen).spice });
|
|
assert.equal(returned.elites!.reserves, 1); assert.equal(returned.elites!.tanks, 2);
|
|
assert.deepEqual(pool(f.afterReturns, fremen), { normal: 7, elite: 1 });
|
|
assert.equal(viewGame(f.afterReturns, fremen).revival.forcesRemaining, 0);
|
|
assert.equal(f.afterReturns.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
|
|
|
|
for (const boundary of [f.afterPaidReturn, f.afterPlay, f.beforeFremen,
|
|
f.afterTypedReturn, f.afterReturns, f.beforePhaseEnd]) {
|
|
assert.equal(boundary.techTokens!.axlotl.spice, 2);
|
|
assert.equal(boundary.techTokens!.axlotl.triggeredTurn, 2);
|
|
assert.equal(player(boundary, owner).spice, player(start, owner).spice,
|
|
'Accrued Axlotl spice is not a spendable wallet credit');
|
|
}
|
|
const available = player(f.afterReturns, owner).spice;
|
|
const bribes = viewGame(f.afterReturns, owner).bribeOptions;
|
|
assert.equal(bribes.available, available);
|
|
assert.equal(bribes.blocked, null, 'The spending rejection must be a wallet boundary, not a timing gate');
|
|
assert.equal(bribes.targets.find(target => target.id === fremen)?.maximum, available);
|
|
rejectsUnchanged(f.afterReturns, owner,
|
|
{ type: 'bribe', target: fremen, amount: available + f.expected.income });
|
|
const settled = applyAction(f.beforePhaseEnd, f.phaseEndStep.actor, f.phaseEndStep.action);
|
|
assert.equal(settled.phase, 5);
|
|
assert.equal(player(settled, owner).spice, available + 2);
|
|
assert.equal(settled.techTokens!.axlotl.spice, 0);
|
|
assert.equal(settled.techTokens!.axlotl.triggeredTurn, 2);
|
|
assert.equal(player(settled, atreides).spice, paid.spice,
|
|
'Recruits and settlement never refund the earlier paid return');
|
|
assert.equal(player(settled, atreides).revived, 5);
|
|
assert.equal(player(settled, atreides).freeForcesRevived, 4);
|
|
assert.equal(player(settled, fremen).revived, 6);
|
|
assert.equal(player(settled, fremen).freeForcesRevived, 6);
|
|
|
|
if (options.strongholds) {
|
|
assert.equal(start.strongholdCards!.claimedTurn, 1);
|
|
assert.equal(start.strongholdCards!.owners.sietch_tabr, emperor);
|
|
assert.equal(start.strongholdCards!.owners.arrakeen, guild);
|
|
assert.equal(start.strongholdCards!.owners.tueks_sietch, guild);
|
|
}
|
|
for (const boundary of [start, f.afterPaidReturn, f.afterPlay, f.afterTypedReturn,
|
|
f.afterReturns, settled]) assertRecruitsHomeworldInventory(boundary);
|
|
});
|
|
}
|
|
|
|
void test('A bulk native low-population request locks its quoted seven, unlike the six separate typed returns', () => {
|
|
const f = createWorldPhaseRecruitsFixture();
|
|
const { fremen } = f.actors;
|
|
const bulk = applyAction(f.beforeFremen, fremen, { type: 'revive', amount: 7, elite: 0 });
|
|
assert.equal(player(bulk, fremen).revived, 7);
|
|
assert.equal(player(bulk, fremen).freeForcesRevived, 7);
|
|
assert.equal(player(bulk, fremen).spice, player(f.beforeFremen, fremen).spice);
|
|
assert.equal(player(bulk, fremen).tanks, 11);
|
|
assert.equal(player(bulk, fremen).elites!.tanks, 3);
|
|
assert.deepEqual(pool(bulk, fremen), { normal: 9, elite: 0 });
|
|
assert.equal(bulk.techTokens!.axlotl.spice, f.expected.income,
|
|
'Another eligible bulk return cannot accrue a second payment in the same original phase');
|
|
assertRecruitsHomeworldInventory(bulk);
|
|
});
|