258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { botActions } from '../game/bots';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeBaseGameForAudit,
|
|
initializeFactionExpansionsGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
viewGame,
|
|
} from '../game/engine';
|
|
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
|
|
import { completedMoritaniSkillsGame } from './moritani-skills-fixture';
|
|
import {
|
|
assassinationChoice,
|
|
assassinationGame,
|
|
assassinationToMentat,
|
|
resolveAssassinationBattle,
|
|
stageAssassinationBattle,
|
|
} from './moritani-assassinate-fixture';
|
|
import { createStrongholdFactionsFixture } from './fixture-stronghold-factions';
|
|
import { createPairedE3NexusSetup } from './fixture-paired-e3-nexus-modules';
|
|
|
|
void test('Moritani Skills sample custody checks the physical skill and Traitor inventories after genuine setup', () => {
|
|
const game = completedMoritaniSkillsGame();
|
|
const inventory = sampleInventory(game);
|
|
assert.ok(inventory.traitors);
|
|
verifySampleCustody(game, inventory);
|
|
const skills = structuredClone(game);
|
|
skills.leaderSkills!.deck.pop();
|
|
assert.throws(() => verifySampleCustody(skills, inventory));
|
|
const missing = structuredClone(game);
|
|
delete missing.leaderSkills;
|
|
assert.throws(() => verifySampleCustody(missing, inventory), /Leader Skills module custody/);
|
|
const traitors = structuredClone(game);
|
|
traitors.players[0].traitors[0] = traitors.players[1].traitors[0];
|
|
assert.throws(() => verifySampleCustody(traitors, inventory), /traitor custody/);
|
|
});
|
|
|
|
for (const skills of [false, true]) void test(`paired native Ecaz loyalty remains one physical Traitor with ${skills ? 'all14 Skills' : 'no Skills'}`, () => {
|
|
const fixture = createPairedE3NexusSetup({ skills, homeworlds: false, discovery: false, tech: false, strongholds: false });
|
|
const inventory = sampleInventory(fixture.offered);
|
|
verifySampleCustody(fixture.afterSetup, inventory);
|
|
const loyaltyCard = fixture.afterSetup.ecazLoyalty?.card;
|
|
assert.ok(loyaltyCard);
|
|
|
|
const missing = structuredClone(fixture.afterSetup);
|
|
delete missing.ecazLoyalty;
|
|
assert.throws(() => verifySampleCustody(missing, inventory));
|
|
|
|
const duplicated = structuredClone(fixture.afterSetup);
|
|
duplicated.traitorReserve!.push(loyaltyCard);
|
|
assert.throws(() => verifySampleCustody(duplicated, inventory));
|
|
});
|
|
|
|
void test('Moritani assassination samples conserve retired Traitors after real battle and Mentat replacement', () => {
|
|
const start = assassinationGame();
|
|
const inventory = sampleInventory(start);
|
|
assert.ok(inventory.traitors);
|
|
verifySampleCustody(start, inventory);
|
|
const staged = stageAssassinationBattle(start);
|
|
verifySampleCustody(staged, inventory);
|
|
const revealed = assassinationChoice(resolveAssassinationBattle(staged), 'guild-1');
|
|
verifySampleCustody(revealed, inventory);
|
|
const settled = assassinationToMentat(revealed);
|
|
assert.equal(settled.moritaniAssassinate?.opportunities[0].stage, 'replaced');
|
|
verifySampleCustody(settled, inventory);
|
|
const duplicated = structuredClone(settled);
|
|
duplicated.traitorReserve!.push('guild-1');
|
|
assert.throws(() => verifySampleCustody(duplicated, inventory), /physical traitor custody/);
|
|
});
|
|
void test('native Stronghold sample custody survives real end-Mentat acquisition and retained next-turn ownership', () => {
|
|
const fixture = createStrongholdFactionsFixture();
|
|
const inventory = sampleInventory(fixture.initial);
|
|
assert.equal(inventory.cards.length, 47);
|
|
assert.equal(inventory.strongholds?.length, 6);
|
|
verifySampleCustody(fixture.beforeFirstMentat, inventory);
|
|
verifySampleCustody(fixture.afterFirstMentat, inventory, fixture.beforeFirstMentat);
|
|
assert.equal(fixture.afterFirstMentat.strongholdCards!.claimedTurn, 1);
|
|
const owner = fixture.afterFirstMentat.strongholdCards!.owners.hidden_mobile_stronghold;
|
|
assert.ok(owner, 'actual end-Mentat claims the mobile card');
|
|
verifySampleCustody(fixture.beforeBattle, inventory, fixture.afterFirstMentat);
|
|
const saved = JSON.parse(JSON.stringify(fixture.game));
|
|
verifySampleCustody(saved, inventory, fixture.beforeBattle);
|
|
const missing = structuredClone(fixture.game);
|
|
delete missing.strongholdCards;
|
|
assert.throws(() => verifySampleCustody(missing, inventory), /Stronghold Cards module custody/);
|
|
const missingCard = structuredClone(fixture.game);
|
|
Reflect.deleteProperty(missingCard.strongholdCards!.owners, 'arrakeen');
|
|
assert.throws(() => verifySampleCustody(missingCard, inventory), /Each Stronghold Card/);
|
|
const foreign = structuredClone(fixture.game);
|
|
foreign.strongholdCards!.owners.arrakeen = 'not-a-seat';
|
|
assert.throws(() => verifySampleCustody(foreign, inventory), /current seat/);
|
|
const future = structuredClone(fixture.game);
|
|
future.strongholdCards!.claimedTurn = future.turn + 1;
|
|
assert.throws(() => verifySampleCustody(future, inventory), /future/);
|
|
const transferred = structuredClone(fixture.game);
|
|
const other = transferred.players.find(player => player.id !== owner)!;
|
|
transferred.strongholdCards!.owners.hidden_mobile_stronghold = other.id;
|
|
assert.throws(() => verifySampleCustody(transferred, inventory, fixture.game),
|
|
/holders must persist/);
|
|
const missingCyborg = structuredClone(fixture.game);
|
|
const ixians = missingCyborg.players.find(player => player.faction === 'ixians')!;
|
|
assert.ok(ixians.elites);
|
|
if (ixians.elites.reserves > 0) ixians.elites.reserves--;
|
|
else if (ixians.elites.tanks > 0) ixians.elites.tanks--;
|
|
else {
|
|
const location = Object.keys(ixians.elites.forces).find(key => ixians.elites!.forces[key] > 0);
|
|
assert.ok(location);
|
|
ixians.elites.forces[location]--;
|
|
}
|
|
assert.throws(() => verifySampleCustody(missingCyborg, inventory), /elite custody ixians/);
|
|
});
|
|
|
|
function fixture(advanced = false) {
|
|
let game = createGame(
|
|
'SAMPLEQA',
|
|
newPlayer('emperor', 'Emperor', 'emperor'),
|
|
advanced,
|
|
);
|
|
for (const faction of ['fremen', 'atreides', 'harkonnen'] as const)
|
|
joinGame(game, newPlayer(faction, faction, faction));
|
|
for (const player of game.players) {
|
|
player.ready = true;
|
|
player.bot = 'Medium';
|
|
}
|
|
game = initializeBaseGameForAudit(game);
|
|
const inventory = sampleInventory(game);
|
|
verifySampleCustody(game, inventory);
|
|
for (let steps = 0; game.status === 'setup' && steps < 30; steps++) {
|
|
const choice = game.players.flatMap((player) =>
|
|
botActions(viewGame(game, player.id)).map((action) => ({
|
|
player,
|
|
action,
|
|
})),
|
|
)[0];
|
|
assert.ok(choice, 'genuine setup has a legal AI choice');
|
|
game = applyAction(game, choice.player.id, choice.action);
|
|
verifySampleCustody(game, inventory);
|
|
}
|
|
assert.equal(game.status, 'playing');
|
|
return { game, inventory };
|
|
}
|
|
|
|
void test('sample custody follows genuine setup and distinct Advanced elite inventories', () => {
|
|
for (const advanced of [false, true]) {
|
|
const { game, inventory } = fixture(advanced);
|
|
verifySampleCustody(JSON.parse(JSON.stringify(game)), inventory);
|
|
const corrupted = structuredClone(game);
|
|
const card = corrupted.deck.pop();
|
|
assert.ok(card);
|
|
assert.throws(
|
|
() => verifySampleCustody(corrupted, inventory),
|
|
/physical card custody/,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('sample custody detects balanced negative forces and elite subsets outside their group', () => {
|
|
const { game, inventory } = fixture(true);
|
|
const negative = structuredClone(game);
|
|
negative.players[0].tanks = -1;
|
|
negative.players[0].reserves++;
|
|
assert.throws(
|
|
() => verifySampleCustody(negative, inventory),
|
|
/nonnegative forces/,
|
|
);
|
|
const missing = structuredClone(game);
|
|
delete missing.players.find((seat) => seat.faction === 'fremen')!.elites;
|
|
assert.throws(
|
|
() => verifySampleCustody(missing, inventory),
|
|
/missing elite inventory fremen/,
|
|
);
|
|
const misplaced = structuredClone(game);
|
|
const player = misplaced.players.find((seat) => seat.faction === 'emperor')!;
|
|
assert.ok(player.elites);
|
|
player.elites.reserves--;
|
|
player.elites.forces['arrakeen:10'] = 1;
|
|
assert.throws(
|
|
() => verifySampleCustody(misplaced, inventory),
|
|
/elite board subset/,
|
|
);
|
|
});
|
|
void test('sample custody accounts for foreign Homeworld ordinary and elite forces without counting Salusa twice', () => {
|
|
const { game, inventory } = fixture(true);
|
|
const emperor = game.players.find((seat) => seat.faction === 'emperor')!;
|
|
assert.ok(emperor.elites);
|
|
emperor.reserves -= 3;
|
|
emperor.elites.reserves--;
|
|
game.homeworlds = {
|
|
custody: {
|
|
visitors: { 'homeworld:fremen': { [emperor.id]: { normal: 2, elite: 1 } } },
|
|
salusa: { normal: 1, elite: 0 },
|
|
},
|
|
historyVersion: 1,
|
|
};
|
|
verifySampleCustody(game, inventory);
|
|
const missing = structuredClone(game);
|
|
missing.homeworlds!.custody!.visitors['homeworld:fremen'][emperor.id].normal--;
|
|
assert.throws(() => verifySampleCustody(missing, inventory), /force custody emperor/);
|
|
const missingElite = structuredClone(game);
|
|
missingElite.homeworlds!.custody!.visitors['homeworld:fremen'][emperor.id].elite--;
|
|
missingElite.homeworlds!.custody!.visitors['homeworld:fremen'][emperor.id].normal++;
|
|
assert.throws(() => verifySampleCustody(missingElite, inventory), /elite custody emperor/);
|
|
});
|
|
|
|
void test('sample custody catches a duplicated base traitor after setup despite preserved hand size', () => {
|
|
const { game, inventory } = fixture();
|
|
const original = game.players[0].traitors[0];
|
|
assert.ok(original);
|
|
const other = game.players.find((player) => player.id !== game.players[0].id)!
|
|
.traitors[0];
|
|
assert.ok(other && other !== original);
|
|
game.players[0].traitors[0] = other;
|
|
assert.throws(
|
|
() => verifySampleCustody(game, inventory),
|
|
/physical traitor custody/,
|
|
);
|
|
});
|
|
|
|
void test('sample custody retains all seven Ixian cyborgs in genuine expansion setup', () => {
|
|
const lobby = createGame(
|
|
'SAMPLEIX',
|
|
newPlayer('ixians', 'Ixians', 'ixians'),
|
|
false,
|
|
['ix'],
|
|
);
|
|
for (const faction of ['tleilaxu', 'atreides', 'harkonnen'] as const)
|
|
joinGame(lobby, newPlayer(faction, faction, faction));
|
|
for (const player of lobby.players) player.ready = true;
|
|
const game = initializeFactionExpansionsGameForAudit(lobby);
|
|
const inventory = sampleInventory(game);
|
|
verifySampleCustody(game, inventory);
|
|
const player = game.players.find((seat) => seat.faction === 'ixians')!;
|
|
assert.ok(player.elites);
|
|
player.elites.reserves--;
|
|
assert.throws(
|
|
() => verifySampleCustody(game, inventory),
|
|
/elite custody ixians/,
|
|
);
|
|
});
|
|
|
|
void test('resumed sample inventory cannot shrink with a deleted leader and matching Traitor card', () => {
|
|
const { game } = fixture();
|
|
const leader = game.players[0].leaders.pop();
|
|
assert.ok(leader);
|
|
game.traitorReserve = game.traitorReserve?.filter(
|
|
(card) => card !== leader.id,
|
|
);
|
|
for (const player of game.players)
|
|
player.traitors = player.traitors.filter((card) => card !== leader.id);
|
|
assert.throws(
|
|
() => verifySampleCustody(game, sampleInventory(game)),
|
|
/physical traitor custody/,
|
|
);
|
|
});
|