Files
Dune/tests/native-world-phase-recruits.test.ts

262 lines
15 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, RuleError, viewGame, type Action, type Game } from '../game/engine';
import { ownedTech } from '../game/tech-tokens';
import { RECRUITS_CARD_ID } from '../game/recruits';
import {
advanceNativeWorldRecruits, assertNativeWorldRecruitsInventory, createNativeWorldRecruitsFixture,
nativeWorldRecruitsClean, nativeWorldRecruitsPlayer as player, nextNativeWorldRecruitsStep,
requestNativeWorldRecruits, type NativeWorldRecruitsStep,
} from './fixture-native-world-recruits';
const play = { type: 'card', card: RECRUITS_CARD_ID } as const;
const wallets = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice]));
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, actor, action), RuleError);
assert.equal(JSON.stringify(game), before);
}
function axlotlProgramme(start: Game, native: string) {
const token = start.techTokens!.axlotl;
const owner = token.owner;
assert.ok(owner, 'The actual first Storm must have assigned Axlotl');
assert.notEqual(owner, native, 'The invader took any original native token in the real battle');
assert.equal(token.spice, 0);
assert.notEqual(token.triggeredTurn, start.turn);
assert.ok(Object.values(start.techTokens!).every(t => t.owner !== null));
const income = ownedTech(start.techTokens, owner).length;
assert.ok(income > 0);
const ownerWallet = player(start, owner).spice;
const accrued = (game: Game) => {
assert.equal(game.techTokens!.axlotl.owner, owner);
assert.equal(game.techTokens!.axlotl.triggeredTurn, start.turn);
assert.equal(game.techTokens!.axlotl.spice, income,
'Further eligible free requests must not accrue another original phase payment');
assert.equal(player(game, owner).spice, ownerWallet,
'Accrued Axlotl income is not spendable personal spice before the phase ends');
assertNativeWorldRecruitsInventory(game);
};
const finish = (game: Game) => {
accrued(game);
const bribes = viewGame(game, owner).bribeOptions;
assert.equal(bribes.blocked, null);
assert.equal(bribes.available, ownerWallet);
rejectsUnchanged(game, owner, { type: 'bribe', target: native, amount: ownerWallet + income });
let beforePhaseEnd: Game | undefined;
let phaseEndStep: NativeWorldRecruitsStep | undefined;
for (let count = 0; count < 100 && game.phase === 4; count++) {
const step = nextNativeWorldRecruitsStep(game);
assert.ok(step);
const before = structuredClone(game);
game = applyAction(game, step.actor, step.action);
if (game.phase !== 4) { beforePhaseEnd = before; phaseEndStep = step; }
else accrued(game);
}
assert.ok(beforePhaseEnd && phaseEndStep, 'The actual final ready must end Revival');
accrued(beforePhaseEnd);
assert.equal(game.turn, 2);
assert.equal(game.phase, 5);
const afterPhaseEnd = structuredClone(game);
const expectedWallets = wallets(beforePhaseEnd);
expectedWallets[owner] += income;
assert.deepEqual(wallets(afterPhaseEnd), expectedWallets);
assert.equal(afterPhaseEnd.techTokens!.axlotl.spice, 0);
assert.equal(afterPhaseEnd.techTokens!.axlotl.triggeredTurn, 2);
assert.equal(player(afterPhaseEnd, native).revived, player(beforePhaseEnd, native).revived);
assert.equal(player(afterPhaseEnd, native).freeForcesRevived,
player(beforePhaseEnd, native).freeForcesRevived);
const shipmentSteps: { actor: string; action: Action }[] = [];
const afterShipment = advanceNativeWorldRecruits(afterPhaseEnd,
g => g.turn === 2 && g.phase === 6 && nativeWorldRecruitsClean(g), shipmentSteps);
assert.ok(shipmentSteps.some(step => step.action.type === 'endMovement'),
'Finish the actual next Shipment, not just its opening boundary');
assert.deepEqual(wallets(afterShipment), expectedWallets,
'Original Shipment continuation cannot pay Axlotl a second time');
assert.equal(afterShipment.techTokens!.axlotl.spice, 0);
assert.equal(afterShipment.techTokens!.axlotl.triggeredTurn, 2);
for (const boundary of [beforePhaseEnd, afterPhaseEnd, afterShipment])
assertNativeWorldRecruitsInventory(boundary);
return { beforePhaseEnd, phaseEndStep, afterPhaseEnd, afterShipment };
};
return { owner, income, accrued, finish };
}
for (const options of [
{ name: 'Basic native Ix + Tech', advanced: false, strongholds: false },
{ name: 'Advanced native Ix + Tech + original Strongholds', advanced: true, strongholds: true },
]) {
void test(`${options.name}: current typed rates, paid ledger and delayed once-only Axlotl survive Recruits`, () => {
const f = createNativeWorldRecruitsFixture({ ...options, tech: true, faction: 'ixians' });
const native = f.native;
const programme = axlotlProgramme(f.game, native);
assert.deepEqual({ free: viewGame(f.game, native).revival.freeRemaining,
bonus: viewGame(f.game, native).revival.homeworldBonus }, { free: 2, bonus: 1 });
const paid = requestNativeWorldRecruits(f.game, native, 3, 1, 0);
assert.equal(paid.price, 3, 'Two free suboids leave the original three-spice paid cyborg');
assert.equal(paid.walletDelta, 3);
assert.equal(paid.free, 2);
assert.deepEqual(paid.nativeAfter, { normal: 2, elite: 1 });
programme.accrued(paid.game);
let game = paid.game;
if (!options.advanced) {
game = applyAction(game, native, { type: 'ready' });
assert.ok(game.ready.includes(native));
} else {
assert.equal(game.revivalOrder?.remaining[0], native);
assert.equal(game.revivalOrder?.started, true, 'Keep the original Advanced opportunity open for later requests');
}
const beforePlayWallets = wallets(game);
game = applyAction(game, f.owner, play);
assert.deepEqual(wallets(game), beforePlayWallets, 'Recruits never refunds the prior paid cyborg');
assert.equal(player(game, native).revived, 3);
assert.equal(player(game, native).freeForcesRevived, 2);
assert.equal(viewGame(game, native).revival.freeRemaining, 2);
const crossing = requestNativeWorldRecruits(game, native, 2, 2, 2);
assert.equal(crossing.price, 0);
assert.equal(crossing.walletDelta, 0);
assert.equal(crossing.free, 2);
assert.deepEqual(crossing.nativeAfter, { normal: 2, elite: 3 });
programme.accrued(crossing.game);
const rate = viewGame(crossing.game, native).revival;
assert.equal(rate.homeworldBonus, 0);
assert.equal(rate.freeRemaining, 0, 'A separate request reads the current population-five World');
assert.equal(rate.eliteRemaining, 1, 'The native multi-cyborg allowance remains in both modes');
rejectsUnchanged(crossing.game, native, { type: 'revive', amount: 2, elite: 2 });
const last = requestNativeWorldRecruits(crossing.game, native, 2, 1, 0);
assert.equal(last.price, 5);
assert.equal(last.walletDelta, 5);
assert.equal(last.free, 0);
assert.equal(last.ixSuboidBonus, 2, 'Current High Ix grants two additional physical Suboids for the paid Cyborg');
assert.deepEqual(last.nativeAfter, { normal: 5, elite: 4 });
assert.equal(player(last.game, native).tanks, 5);
assert.equal(player(last.game, native).spice, player(f.game, native).spice - 8);
assert.equal(player(last.game, native).revived, 7);
assert.equal(player(last.game, native).freeForcesRevived, 4);
assert.equal(last.game.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
rejectsUnchanged(last.game, native, { type: 'revive', amount: 1, elite: 0 });
programme.accrued(last.game);
const completed = programme.finish(last.game);
assert.equal(player(completed.afterPhaseEnd, native).spice, player(f.game, native).spice - 8);
if (options.strongholds) {
assert.equal(f.afterSetup.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(f.afterSetup.strongholdCards!.owners).every(owner => owner === null));
assert.equal(f.game.strongholdCards!.claimedTurn, 1, 'Actual first end-Mentat earns original custody');
assert.deepEqual(completed.afterShipment.strongholdCards, f.game.strongholdCards,
'No held card is fabricated or reassigned by native Revival or the following Shipment');
} else assert.equal(f.game.strongholdCards, undefined);
});
}
void test('Advanced native CHOAM + Tech: unlimited paid/free returns retain their ledgers and one delayed Axlotl payout', () => {
const f = createNativeWorldRecruitsFixture({ advanced: true, faction: 'choam', tech: true });
const native = f.native;
const programme = axlotlProgramme(f.game, native);
const first = requestNativeWorldRecruits(f.game, native, 8);
assert.equal(first.price, 7);
assert.equal(first.walletDelta, 7);
assert.equal(first.free, 1);
programme.accrued(first.game);
const beforePlayWallets = wallets(first.game);
let game = applyAction(first.game, f.owner, play);
assert.deepEqual(wallets(game), beforePlayWallets, 'Seven paid returns stay paid when Recruits doubles free use');
assert.equal(viewGame(game, native).revival.limit, 20, 'Recruits never reduces native unlimited to seven');
assert.equal(viewGame(game, native).revival.forcesRemaining, 12);
assert.equal(viewGame(game, native).revival.freeRemaining, 1);
const free = requestNativeWorldRecruits(game, native, 1);
assert.equal(free.price, 0);
assert.equal(free.walletDelta, 0);
assert.equal(free.free, 1);
programme.accrued(free.game);
const paid = requestNativeWorldRecruits(free.game, native, 3);
assert.equal(paid.price, 3, 'Native CHOAM keeps its original one-spice price after free use');
assert.equal(paid.walletDelta, 3);
assert.equal(paid.free, 0);
game = paid.game;
assert.deepEqual(paid.nativeAfter, { normal: 12, elite: 0 });
assert.equal(player(game, native).revived, 12);
assert.equal(player(game, native).freeForcesRevived, 2);
assert.equal(player(game, native).spice, player(f.game, native).spice - 10);
assert.equal(viewGame(game, native).revival.forcesRemaining, 8);
assert.equal(viewGame(game, native).revival.homeworldBonus, 0);
programme.accrued(game);
const completed = programme.finish(game);
assert.equal(player(completed.afterPhaseEnd, native).spice, player(f.game, native).spice - 10);
});
void test('Basic all14 native Ix: actual paid cyborgs accrue Banker separately from once-only phase-end Axlotl', () => {
const f = createNativeWorldRecruitsFixture({ skills: true, tech: true, faction: 'ixians',
training: { emperor: 'mentat', ixians: 'swordmaster-of-ginaz', guild: 'spice-banker' } });
const native = f.native, banker = 'guild';
const programme = axlotlProgramme(f.game, native);
const deferred = (game: Game) => viewGame(game, banker).spiceBankerIncome!.deferred
.find(row => row.owner === banker)?.amount ?? 0;
const originalBankerWallet = player(f.game, banker).spice;
const paid = requestNativeWorldRecruits(f.game, native, 3, 1, 0);
assert.deepEqual({ cost: paid.price, wallet: paid.walletDelta, free: paid.free },
{ cost: 3, wallet: 3, free: 2 });
assert.equal(deferred(paid.game), 0, 'The actual three-spice bank payment is below Banker threshold');
let game = applyAction(paid.game, native, { type: 'ready' });
game = applyAction(game, f.owner, play);
assert.equal(player(game, native).spice, player(f.game, native).spice - 3);
assert.equal(player(game, native).revived, 3);
assert.equal(player(game, native).freeForcesRevived, 2);
const crossing = requestNativeWorldRecruits(game, native, 2, 2, 2);
assert.deepEqual(crossing.nativeAfter, { normal: 2, elite: 3 });
assert.equal(crossing.price, 0);
assert.equal(deferred(crossing.game), 0, 'Typed free deposits are not qualifying bank payments');
assert.equal(viewGame(crossing.game, native).revival.homeworldBonus, 0);
assert.equal(viewGame(crossing.game, native).revival.freeRemaining, 0);
rejectsUnchanged(crossing.game, native, { type: 'revive', amount: 2, elite: 2 });
const last = requestNativeWorldRecruits(crossing.game, native, 2, 1, 0);
assert.deepEqual({ cost: last.price, wallet: last.walletDelta, free: last.free },
{ cost: 5, wallet: 5, free: 0 });
assert.equal(last.ixSuboidBonus, 2, 'The High bonus is physical stock, not another paid invoice or ordinary allowance');
assert.deepEqual(last.nativeAfter, { normal: 5, elite: 4 });
assert.equal(player(last.game, native).revived, 7);
assert.equal(player(last.game, native).freeForcesRevived, 4);
assert.equal(player(last.game, native).tanks, 5);
assert.equal(player(last.game, native).elites!.tanks, 0);
assert.equal(deferred(last.game), 1);
const receipt = last.game.spiceBankerIncome!.sources.at(-1)!;
assert.equal(receipt.source.kind, 'force-revival');
assert.deepEqual(receipt.source.bankLegs, [{ payer: native, amount: 5 }]);
assert.equal(receipt.grant?.owner, banker);
assert.equal(player(last.game, banker).spice, originalBankerWallet,
'Normal Banker income stays in front of the shield until Mentat');
const completed = programme.finish(last.game);
assert.equal(deferred(completed.afterPhaseEnd), 1, 'Revival end pays Axlotl, not Banker');
assert.equal(deferred(completed.afterShipment), 1);
assert.equal(player(completed.afterPhaseEnd, native).spice, player(f.game, native).spice - 8);
assert.equal(player(completed.afterPhaseEnd, banker).spice,
originalBankerWallet + (programme.owner === banker ? programme.income : 0));
game = completed.afterShipment;
for (let count = 0; count < 300 && !(game.phase === 7 && nativeWorldRecruitsClean(game)); count++) {
if (!game.battle && game.phase === 6 && nativeWorldRecruitsClean(game)) {
const choice = viewGame(game, game.active!).battleChoices[0]; assert.ok(choice);
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
} else if (game.battle && !game.battle.revealed && nativeWorldRecruitsClean(game) &&
!game.battle.preparation && game.battle.preLeader?.closed !== false) {
const actor = [game.battle.attacker, game.battle.defender].find(id => !game.battle!.plans[id]);
assert.ok(actor);
const leader = player(game, actor).leaders.filter(disc => !disc.dead && !disc.usedAt)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader);
game = applyAction(game, actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
} else {
const step = nextNativeWorldRecruitsStep(game); assert.ok(step);
game = applyAction(game, step.actor, step.action);
}
}
assert.equal(game.phase, 7);
assert.equal(deferred(game), 1);
const beforeMentatWallet = player(game, banker).spice;
const mentat = advanceNativeWorldRecruits(game, state => state.phase === 8 && nativeWorldRecruitsClean(state));
assert.equal(deferred(mentat), 0);
assert.equal(player(mentat, banker).spice, beforeMentatWallet + 1,
'The one actual qualifying bank invoice is collected only at original Mentat');
assert.equal(mentat.techTokens!.axlotl.spice, 0);
assert.equal(mentat.techTokens!.axlotl.triggeredTurn, 2);
assertNativeWorldRecruitsInventory(mentat);
});