175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import {
|
|
nexusAdvisorFixture,
|
|
nexusAdvisorPlayer,
|
|
holdAdvisorKarama,
|
|
beginNexusAdvisorFlip,
|
|
allowNexusAdvisorFlip,
|
|
} from './fixture-nexus-advisors';
|
|
import { nexusReload } from './fixture-nexus-cards';
|
|
import { treacheryDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
|
|
function inventory(g: Game) {
|
|
assert.deepEqual(
|
|
[
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.players.flatMap((p) => p.hand),
|
|
...g.richeseCache!,
|
|
]
|
|
.map((card) => card.id)
|
|
.sort(),
|
|
[...treacheryDeck(g.expansions), ...richeseCards()]
|
|
.map((card) => card.id)
|
|
.sort(),
|
|
);
|
|
for (const p of g.players)
|
|
assert.equal(
|
|
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
|
20,
|
|
);
|
|
}
|
|
|
|
function paidSearch() {
|
|
let g = nexusAdvisorFixture({ opponentFaction: 'richese' });
|
|
const boxIndex = g.richeseCache!.findIndex(
|
|
(card) => card.effect === 'nullentropyBox',
|
|
);
|
|
assert.ok(boxIndex >= 0);
|
|
const box = g.richeseCache!.splice(boxIndex, 1)[0];
|
|
g.players[1].hand.push(box);
|
|
const karama = holdAdvisorKarama(g);
|
|
for (const kind of ['shield', 'projectile']) {
|
|
const index = g.deck.findIndex((card) => card.kind === kind);
|
|
assert.ok(index >= 0);
|
|
g.discard.push(g.deck.splice(index, 1)[0]);
|
|
}
|
|
g.players[1].spice = 10;
|
|
const original = nexusReload(g);
|
|
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']);
|
|
const response = structuredClone(g.response);
|
|
g = applyAction(g, 'q', { type: 'card', card: box.id });
|
|
assert.equal(g.decision?.kind, 'nullentropy');
|
|
assert.equal(g.pendingNullentropy?.resume.response?.kind, 'nexusAdvisorFlip');
|
|
assert.deepEqual(g.pendingNullentropy!.resume.response, response);
|
|
assert.equal(g.players[1].spice, 8);
|
|
inventory(g);
|
|
return { g, original, response, karama, box: box.id };
|
|
}
|
|
|
|
function finishSearch(g: Game) {
|
|
return applyAction(g, 'q', {
|
|
type: 'decision',
|
|
event: g.pendingNullentropy!.event,
|
|
card: g.discard[0].id,
|
|
});
|
|
}
|
|
|
|
void test('paid Nullentropy suspends the advisor response and restores its exact batch once after a private JSON recovery', () => {
|
|
const f = paidSearch();
|
|
let g = nexusReload(f.g);
|
|
const before = nexusReload(g);
|
|
assert.deepEqual(normalizeAutomaticGame(g), before);
|
|
for (const player of g.players) {
|
|
assert.deepEqual(
|
|
viewGame(nexusReload(g), player.id),
|
|
viewGame(g, player.id),
|
|
);
|
|
if (player.id !== 'q')
|
|
assert.equal(viewGame(g, player.id).nullentropy, null);
|
|
}
|
|
assert.deepEqual(
|
|
nexusAdvisorPlayer(g).advisors,
|
|
nexusAdvisorPlayer(f.original).advisors,
|
|
);
|
|
const selected = g.discard[0].id;
|
|
g = finishSearch(g);
|
|
assert.deepEqual(g.response, f.response);
|
|
assert.equal(g.nexusAdvisorHistory!.at(-1)!.stage, 'pending');
|
|
assert.equal(g.players[1].spice, 8);
|
|
assert.ok(g.players[1].hand.some((card) => card.id === selected));
|
|
assert.equal(g.discard.filter((card) => card.id === f.box).length, 1);
|
|
g = allowNexusAdvisorFlip(nexusReload(g));
|
|
assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} });
|
|
assert.equal(g.nexusAdvisorHistory!.at(-1)!.stage, 'completed');
|
|
assert.equal(g.nexusAdvisorHistory!.length, 1);
|
|
assert.deepEqual(
|
|
g.players.map((player) => player.forces),
|
|
f.original.players.map((player) => player.forces),
|
|
);
|
|
assert.equal(g.players[1].spice, 8);
|
|
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
|
inventory(g);
|
|
});
|
|
|
|
void test('the restored advisor response still accepts one Karama after Box recovery without undoing the paid search', () => {
|
|
const f = paidSearch();
|
|
let g = finishSearch(nexusReload(f.g));
|
|
const selected = f.g.discard[0].id;
|
|
g = applyAction(g, 'q', { type: 'card', card: f.karama, mode: 'cancel' });
|
|
g = allowNexusAdvisorFlip(nexusReload(g));
|
|
assert.equal(g.nexusAdvisorHistory!.at(-1)!.stage, 'canceled');
|
|
assert.deepEqual(
|
|
nexusAdvisorPlayer(g).advisors,
|
|
nexusAdvisorPlayer(f.original).advisors,
|
|
);
|
|
assert.equal(g.players[1].spice, 8);
|
|
assert.ok(g.players[1].hand.some((card) => card.id === selected));
|
|
assert.equal(g.discard.filter((card) => card.id === f.box).length, 1);
|
|
assert.equal(g.discard.filter((card) => card.id === f.karama).length, 1);
|
|
assert.equal(
|
|
g.nexusCards!.cards!.discard.filter((card) => card === 'beneGesserit')
|
|
.length,
|
|
1,
|
|
);
|
|
inventory(g);
|
|
});
|
|
|
|
void test('damaged advisor parents inside a paid search reject reads, automatic progress and card selection immutably', () => {
|
|
const { g: paid } = paidSearch();
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.pendingNullentropy!.resume.response = null;
|
|
},
|
|
(g) => {
|
|
g.pendingNullentropy!.resume.response!.intent = 'stale';
|
|
},
|
|
(g) => {
|
|
g.pendingNullentropy!.resume.response!.owner = 'q';
|
|
},
|
|
(g) => {
|
|
delete g.nexusAdvisorLast;
|
|
},
|
|
(g) => {
|
|
delete g.nexusAdvisorHistory;
|
|
},
|
|
(g) => {
|
|
g.nexusAdvisorHistory![0].stage = 'completed';
|
|
},
|
|
(g) => {
|
|
g.players[0].moved++;
|
|
},
|
|
(g) => {
|
|
g.players[0].forces['arrakeen:10']++;
|
|
g.players[0].reserves--;
|
|
},
|
|
];
|
|
for (const mutate of mutations) {
|
|
const g = nexusReload(paid);
|
|
mutate(g);
|
|
const before = nexusReload(g);
|
|
for (const player of g.players) assert.throws(() => viewGame(g, player.id));
|
|
assert.throws(() => normalizeAutomaticGame(g));
|
|
assert.throws(() => finishSearch(g));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|