264 lines
16 KiB
TypeScript
264 lines
16 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 { combatArmy } from '../game/combat-location';
|
|
import { RECRUITS_CARD_ID } from '../game/recruits';
|
|
import {
|
|
advanceNativeWorldRecruits, assertNativeWorldRecruitsInventory, createNativeWorldRecruitsFixture,
|
|
nativeWorldRecruitsClean, nativeWorldRecruitsPlayer, requestNativeWorldRecruits,
|
|
} from './fixture-native-world-recruits';
|
|
|
|
const play = { type: 'card', card: RECRUITS_CARD_ID } as const;
|
|
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 continueToShipment(game: Game): Game {
|
|
const next = advanceNativeWorldRecruits(game, g => g.turn === 2 && g.phase === 5 && nativeWorldRecruitsClean(g));
|
|
assert.ok(next.active, 'Original next Shipment must have its actual active seat');
|
|
assertNativeWorldRecruitsInventory(next);
|
|
return next;
|
|
}
|
|
|
|
for (const advanced of [false, true]) {
|
|
const mode = advanced ? 'Advanced' : 'Basic';
|
|
|
|
void test(`${mode} native Ix deposits paid cyborgs and free suboids, ${advanced ? 'keeps its opportunity open' : 'reopens'} with Recruits, then reprices after population five`, () => {
|
|
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians' });
|
|
const native = fixture.native;
|
|
assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).spice, 10);
|
|
assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).tanks, 14);
|
|
assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).elites!.tanks, 4);
|
|
assert.deepEqual(combatArmy(fixture.game, fixture.visitor, fixture.location), { normal: 1, elite: 0 });
|
|
assert.equal(nativeWorldRecruitsPlayer(fixture.afterShipment, fixture.visitor).spice,
|
|
nativeWorldRecruitsPlayer(fixture.beforeShipment, fixture.visitor).spice - 1);
|
|
const paid = requestNativeWorldRecruits(fixture.game, native, 3, 1, 0);
|
|
assert.equal(paid.price, 3, 'Two free suboids leave one cyborg at its original three-spice price');
|
|
assert.equal(paid.walletDelta, 3);
|
|
assert.equal(paid.free, 2);
|
|
assert.deepEqual(paid.nativeAfter, { normal: 2, elite: 1 });
|
|
let game = paid.game;
|
|
if (!advanced) game = applyAction(game, native, { type: 'ready' });
|
|
else assert.equal(game.revivalOrder?.remaining[0], native);
|
|
game = applyAction(game, fixture.owner, play);
|
|
assert.equal(nativeWorldRecruitsPlayer(game, native).spice, 7, 'Activation never refunds the paid cyborg');
|
|
assert.equal(viewGame(game, native).revival.freeRemaining, 2);
|
|
const crossing = requestNativeWorldRecruits(game, native, 2, 2, 2);
|
|
assert.equal(crossing.price, 0);
|
|
assert.equal(crossing.free, 2);
|
|
assert.deepEqual(crossing.nativeAfter, { normal: 2, elite: 3 });
|
|
game = crossing.game;
|
|
const rate = viewGame(game, native).revival;
|
|
assert.equal(rate.homeworldBonus, 0);
|
|
assert.equal(rate.freeRemaining, 0, 'A separate request uses the now-high native World, not the original four-free rate');
|
|
assert.equal(rate.eliteRemaining, 1, 'Ix retains its native multiple-cyborg allowance in both modes');
|
|
rejectsUnchanged(game, native, { type: 'revive', amount: 2, elite: 2 });
|
|
const last = requestNativeWorldRecruits(game, native, 2, 1, 0);
|
|
assert.equal(last.price, 5, 'One paid cyborg plus one paid suboid still costs five');
|
|
assert.equal(last.walletDelta, 5);
|
|
assert.equal(last.ixSuboidBonus, 2, 'A paid Cyborg at current High returns two extra physical Suboids');
|
|
assert.deepEqual(last.nativeAfter, { normal: 5, elite: 4 });
|
|
assert.equal(nativeWorldRecruitsPlayer(last.game, native).freeForcesRevived, 4);
|
|
assert.equal(nativeWorldRecruitsPlayer(last.game, native).revived, 7);
|
|
assert.equal(nativeWorldRecruitsPlayer(last.game, native).spice, 2);
|
|
assert.equal(nativeWorldRecruitsPlayer(last.game, native).tanks, 5);
|
|
assert.equal(nativeWorldRecruitsPlayer(last.game, native).elites!.tanks, 0);
|
|
rejectsUnchanged(last.game, native, { type: 'revive', amount: 1, elite: 0 });
|
|
assert.equal(last.game.discard.filter(c => c.id === RECRUITS_CARD_ID).length, 1);
|
|
continueToShipment(last.game);
|
|
});
|
|
|
|
void test(`${mode} the real mixed Ix Tanks retain the player's free-cyborg allocation and its distinct prices`, () => {
|
|
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians' });
|
|
const active = applyAction(fixture.game, fixture.owner, play);
|
|
rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 8, elite: 3, freeElite: 0 });
|
|
rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 7, elite: 3, freeElite: 4 });
|
|
for (const freeElite of [0, 3]) {
|
|
const returned = requestNativeWorldRecruits(structuredClone(active), fixture.native, 7, 3, freeElite);
|
|
assert.equal(returned.free, 4);
|
|
assert.equal(returned.price, freeElite === 0 ? 9 : 6);
|
|
assert.equal(returned.walletDelta, freeElite === 0 ? 9 : 6);
|
|
assert.deepEqual(returned.nativeAfter, { normal: 4, elite: 3 });
|
|
assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).elites!.tanks, 1);
|
|
assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).tanks, 7);
|
|
assertNativeWorldRecruitsInventory(returned.game);
|
|
continueToShipment(returned.game);
|
|
}
|
|
});
|
|
|
|
void test(`${mode} native Tleilaxu accepts eleven in one current-rate request without the ordinary seven cap`, () => {
|
|
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'tleilaxu' });
|
|
let game = applyAction(fixture.game, fixture.owner, play);
|
|
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 5);
|
|
rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 12, elite: 0 });
|
|
const returned = requestNativeWorldRecruits(game, fixture.native, 11);
|
|
assert.equal(returned.price, 5, 'Six free and five half-price paid forces retain their accepted invoice');
|
|
assert.equal(returned.free, 6);
|
|
assert.equal(returned.walletDelta, 4, 'Original own free-revival income pays one spice separately');
|
|
game = returned.game;
|
|
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 1);
|
|
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).revived, 11);
|
|
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 6);
|
|
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).tanks, 9);
|
|
assert.deepEqual(returned.nativeAfter, { normal: 11, elite: 0 });
|
|
assert.equal(viewGame(game, fixture.native).revival.limit, 20);
|
|
assert.equal(viewGame(game, fixture.native).revival.forcesRemaining, 9);
|
|
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 0);
|
|
assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0);
|
|
continueToShipment(game);
|
|
});
|
|
|
|
void test(`${mode} paid Tleilaxu returns are not refunded and a separate crossing request loses the low-World free bonus`, () => {
|
|
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'tleilaxu' });
|
|
const first = requestNativeWorldRecruits(fixture.game, fixture.native, 8);
|
|
assert.equal(first.price, 5);
|
|
assert.equal(first.free, 3);
|
|
assert.equal(first.walletDelta, 4);
|
|
let game = applyAction(first.game, fixture.owner, play);
|
|
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 1);
|
|
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 3);
|
|
const crossing = requestNativeWorldRecruits(game, fixture.native, 1);
|
|
assert.equal(crossing.free, 1);
|
|
assert.equal(crossing.price, 0);
|
|
assert.equal(crossing.walletDelta, 0, 'Own free-revival income is not awarded a second time');
|
|
game = crossing.game;
|
|
assert.deepEqual(crossing.nativeAfter, { normal: 9, elite: 0 });
|
|
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 0);
|
|
assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0);
|
|
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 4);
|
|
rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 2, elite: 0 });
|
|
const paid = requestNativeWorldRecruits(game, fixture.native, 1);
|
|
assert.equal(paid.price, 1);
|
|
assert.equal(paid.walletDelta, 1);
|
|
assert.equal(paid.free, 0);
|
|
assert.deepEqual(paid.nativeAfter, { normal: 10, elite: 0 });
|
|
assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).freeForcesRevived, 4);
|
|
assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).spice, 0);
|
|
continueToShipment(paid.game);
|
|
});
|
|
|
|
void test(`${mode} native CHOAM keeps unlimited one-spice returns and prior paid/free ledgers after Recruits`, () => {
|
|
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'choam' });
|
|
assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.native).spice, 15, 'Real CHOAM phase income funds the programme');
|
|
const first = requestNativeWorldRecruits(fixture.game, fixture.native, 8);
|
|
assert.equal(first.price, 7);
|
|
assert.equal(first.walletDelta, 7);
|
|
assert.equal(first.free, 1);
|
|
let game = applyAction(first.game, fixture.owner, play);
|
|
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 8);
|
|
assert.equal(viewGame(game, fixture.native).revival.limit, 20);
|
|
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 1);
|
|
const free = requestNativeWorldRecruits(game, fixture.native, 1);
|
|
assert.equal(free.price, 0);
|
|
assert.equal(free.free, 1);
|
|
const paid = requestNativeWorldRecruits(free.game, fixture.native, 3);
|
|
assert.equal(paid.price, 3);
|
|
assert.equal(paid.walletDelta, 3);
|
|
assert.equal(paid.free, 0);
|
|
game = paid.game;
|
|
assert.deepEqual(paid.nativeAfter, { normal: 12, elite: 0 });
|
|
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 5);
|
|
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 2);
|
|
assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).revived, 12);
|
|
assert.equal(viewGame(game, fixture.native).revival.forcesRemaining, 8);
|
|
assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0);
|
|
rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 6, elite: 0 });
|
|
continueToShipment(game);
|
|
});
|
|
|
|
void test(`${mode} native CHOAM can actually return seventeen with Recruits rather than merely quoting unlimited`, () => {
|
|
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'choam' });
|
|
const active = applyAction(fixture.game, fixture.owner, play);
|
|
rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 18, elite: 0 });
|
|
const returned = requestNativeWorldRecruits(active, fixture.native, 17);
|
|
assert.equal(returned.free, 2);
|
|
assert.equal(returned.price, 15);
|
|
assert.equal(returned.walletDelta, 15);
|
|
assert.deepEqual(returned.nativeAfter, { normal: 17, elite: 0 });
|
|
assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).spice, 0);
|
|
assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).tanks, 3);
|
|
assert.equal(viewGame(returned.game, fixture.native).revival.forcesRemaining, 3);
|
|
continueToShipment(returned.game);
|
|
});
|
|
|
|
void test(`${mode} ordinary native Richese ${advanced ? 'keeps its opportunity open' : 'reopens a ready revival'}, receives six free plus one paid, and still stops at seven`, () => {
|
|
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'richese' });
|
|
const spice = nativeWorldRecruitsPlayer(fixture.game, fixture.native).spice;
|
|
const first = requestNativeWorldRecruits(fixture.game, fixture.native, 3);
|
|
assert.equal(first.price, 0);
|
|
let game = first.game;
|
|
if (!advanced) game = applyAction(game, fixture.native, { type: 'ready' });
|
|
else assert.equal(game.revivalOrder?.remaining[0], fixture.native);
|
|
game = applyAction(game, fixture.owner, play);
|
|
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 3);
|
|
rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 5, elite: 0 });
|
|
const free = requestNativeWorldRecruits(game, fixture.native, 3);
|
|
assert.equal(free.price, 0);
|
|
assert.equal(free.free, 3);
|
|
const paid = requestNativeWorldRecruits(free.game, fixture.native, 1);
|
|
assert.equal(paid.price, 2);
|
|
assert.equal(paid.walletDelta, 2);
|
|
assert.equal(paid.free, 0);
|
|
assert.deepEqual(paid.nativeAfter, { normal: 7, elite: 0 });
|
|
assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).freeForcesRevived, 6);
|
|
assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).tanks, 13);
|
|
assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).spice, spice - 2);
|
|
rejectsUnchanged(paid.game, fixture.native, { type: 'revive', amount: 1, elite: 0 });
|
|
continueToShipment(paid.game);
|
|
});
|
|
|
|
void test(`${mode} actual World battle Sardaukar casualties retain the ordinary elite cap despite Recruits`, () => {
|
|
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians', imperialCasualties: true });
|
|
assert.equal(fixture.shipmentPrice, 2);
|
|
assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.visitor).tanks, 2);
|
|
assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.visitor).elites!.tanks, 2);
|
|
let game = applyAction(fixture.game, fixture.owner, play);
|
|
if (advanced) rejectsUnchanged(game, fixture.visitor, { type: 'revive', amount: 2, elite: 2 });
|
|
const returned = requestNativeWorldRecruits(game, fixture.visitor, advanced ? 1 : 2, advanced ? 1 : 2);
|
|
assert.equal(returned.price, 0);
|
|
assert.equal(returned.walletDelta, 0);
|
|
game = returned.game;
|
|
const after = nativeWorldRecruitsPlayer(game, fixture.visitor);
|
|
assert.equal(after.elites!.tanks, advanced ? 1 : 0);
|
|
assert.equal(after.elites!.reserves, advanced ? 4 : 5);
|
|
const worlds = viewGame(game, fixture.visitor).homeworlds?.worlds;
|
|
assert.ok(worlds, 'The visitor needs its native World custody view');
|
|
const destination = worlds.find(w => w.native === fixture.visitor && w.secondary === advanced);
|
|
assert.ok(destination);
|
|
assert.equal((destination.forces[fixture.visitor] ?? { normal: 0, elite: 0 }).elite, advanced ? 4 : 5);
|
|
if (advanced) rejectsUnchanged(game, fixture.visitor, { type: 'revive', amount: 1, elite: 1 });
|
|
continueToShipment(game);
|
|
});
|
|
}
|
|
|
|
void test('Advanced all14 native Tleilaxu keeps its unlimited return, accepted low-World invoice and separate own free income', () => {
|
|
const f = createNativeWorldRecruitsFixture({ advanced: true, skills: true, faction: 'tleilaxu',
|
|
training: { emperor: 'mentat', tleilaxu: 'swordmaster-of-ginaz', guild: 'spice-banker' } });
|
|
const active = applyAction(f.game, f.owner, play);
|
|
const returned = requestNativeWorldRecruits(active, f.native, 11);
|
|
assert.deepEqual({ price: returned.price, free: returned.free, wallet: returned.walletDelta },
|
|
{ price: 5, free: 6, wallet: 4 });
|
|
assert.deepEqual(returned.nativeAfter, { normal: 11, elite: 0 });
|
|
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).spice, 1);
|
|
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).revived, 11);
|
|
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).freeForcesRevived, 6);
|
|
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).tanks, 9);
|
|
const current = viewGame(returned.game, f.native).revival;
|
|
assert.deepEqual({ limit: current.limit, remaining: current.forcesRemaining,
|
|
free: current.freeRemaining, bonus: current.homeworldBonus },
|
|
{ limit: 20, remaining: 9, free: 0, bonus: 0 });
|
|
const receipt = returned.game.spiceBankerIncome!.sources.at(-1)!;
|
|
assert.equal(receipt.source.kind, 'force-revival');
|
|
assert.deepEqual(receipt.source.bankLegs, [{ payer: f.native, amount: 5 }],
|
|
'The actual paid invoice, not its net four-spice wallet delta, is the bank leg');
|
|
assert.equal(receipt.grant?.owner, 'guild');
|
|
const next = continueToShipment(returned.game);
|
|
assert.equal(nativeWorldRecruitsPlayer(next, f.native).spice, 1);
|
|
assert.equal(nativeWorldRecruitsPlayer(next, f.native).revived, 11);
|
|
assert.equal(nativeWorldRecruitsPlayer(next, f.native).freeForcesRevived, 6);
|
|
assert.equal(viewGame(next, 'guild').spiceBankerIncome!.deferred
|
|
.find(row => row.owner === 'guild')?.amount, 1, 'Banker is not paid at Revival end');
|
|
});
|