323 lines
11 KiB
TypeScript
323 lines
11 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,
|
|
nexusAdvisorInventory,
|
|
holdAdvisorKarama,
|
|
beginNexusAdvisorFlip,
|
|
allowNexusAdvisorFlip,
|
|
} from './fixture-nexus-advisors';
|
|
import { nexusReload, nexusReject } from './fixture-nexus-cards';
|
|
import { drawNexusCard } from '../game/nexus-cards';
|
|
|
|
function action(g: Game, territories = ['arrakeen', 'pasty_mesa']) {
|
|
const owner = nexusAdvisorPlayer(g);
|
|
return {
|
|
type: 'nexusAdvisors',
|
|
event: viewGame(g, owner.id).nexusAdvisors!.offer!.event,
|
|
territories,
|
|
};
|
|
}
|
|
function resources(g: Game) {
|
|
return g.players.map((p) => ({
|
|
id: p.id,
|
|
forces: p.forces,
|
|
reserves: p.reserves,
|
|
tanks: p.tanks,
|
|
elites: p.elites,
|
|
spice: p.spice,
|
|
hand: p.hand,
|
|
shipped: p.shipped,
|
|
moved: p.moved,
|
|
}));
|
|
}
|
|
|
|
void test('Cunning converts every counter in selected multi-sector groups without consuming movement, shipment or spice', () => {
|
|
let g = nexusAdvisorFixture();
|
|
holdAdvisorKarama(g);
|
|
const original = nexusReload(g);
|
|
const offer = viewGame(g, 'p').nexusAdvisors!.offer!;
|
|
assert.deepEqual(
|
|
[...offer.territories]
|
|
.sort((a, b) => a.territory.localeCompare(b.territory))
|
|
.map((t) => [t.territory, t.count]),
|
|
[
|
|
['arrakeen', 2],
|
|
['carthag', 1],
|
|
['pasty_mesa', 3],
|
|
],
|
|
);
|
|
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']);
|
|
assert.equal(g.response?.kind, 'nexusAdvisorFlip');
|
|
assert.deepEqual(
|
|
nexusAdvisorPlayer(g).advisors,
|
|
nexusAdvisorPlayer(original).advisors,
|
|
);
|
|
assert.equal(g.nexusCards!.cards!.hands.p, null);
|
|
g = allowNexusAdvisorFlip(g);
|
|
assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} });
|
|
assert.deepEqual(resources(g), resources(original));
|
|
assert.equal(g.phase, 5);
|
|
assert.equal(g.active, 'p');
|
|
assert.equal(g.movementRemaining, original.movementRemaining);
|
|
assert.equal(g.pendingTerrorEntry, original.pendingTerrorEntry);
|
|
assert.equal(g.pendingAmbassador, original.pendingAmbassador);
|
|
assert.equal(
|
|
g.nexusCards!.cards!.discard.filter((c) => c === 'beneGesserit').length,
|
|
1,
|
|
);
|
|
nexusAdvisorInventory(g);
|
|
});
|
|
|
|
void test('one Karama cancels the entire announced advisor batch and spends both physical cards once', () => {
|
|
let g = nexusAdvisorFixture();
|
|
const karama = holdAdvisorKarama(g);
|
|
const advisors = structuredClone(nexusAdvisorPlayer(g).advisors);
|
|
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa', 'carthag']);
|
|
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
|
|
g = allowNexusAdvisorFlip(g);
|
|
assert.deepEqual(nexusAdvisorPlayer(g).advisors, advisors);
|
|
assert.equal(g.discard.filter((c) => c.id === karama).length, 1);
|
|
assert.equal(
|
|
g.nexusCards!.cards!.discard.filter((c) => c === 'beneGesserit').length,
|
|
1,
|
|
);
|
|
assert.equal(viewGame(g, 'p').nexusAdvisors!.pending, null);
|
|
nexusAdvisorInventory(g);
|
|
});
|
|
|
|
void test('saved live response exposes only its public selected groups and resumes exactly once', () => {
|
|
let g = nexusAdvisorFixture({
|
|
seatIds: ['bg-seat', 'guild-seat', 'fremen-seat'],
|
|
});
|
|
holdAdvisorKarama(g, 'guild-seat');
|
|
const request = action(g);
|
|
g = beginNexusAdvisorFlip(g, request.territories);
|
|
const saved = nexusReload(g);
|
|
const expected = viewGame(saved, 'bg-seat').nexusAdvisors!.pending;
|
|
assert.ok(expected);
|
|
for (const id of ['bg-seat', 'guild-seat', 'fremen-seat']) {
|
|
const view = viewGame(saved, id);
|
|
assert.deepEqual(view.nexusAdvisors!.pending, expected);
|
|
for (const p of view.players)
|
|
if (p.id !== id) assert.equal(p.hand, undefined);
|
|
}
|
|
assert.deepEqual(normalizeAutomaticGame(saved), saved);
|
|
g = allowNexusAdvisorFlip(nexusReload(saved));
|
|
assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} });
|
|
const done = nexusReload(g);
|
|
assert.deepEqual(normalizeAutomaticGame(done), done);
|
|
nexusReject(g, 'bg-seat', request);
|
|
nexusAdvisorInventory(g);
|
|
});
|
|
|
|
void test('foreign, stale, empty, duplicate, partial and non-advisor selections reject before any spend', () => {
|
|
const g = nexusAdvisorFixture();
|
|
const request = action(g);
|
|
nexusReject(g, 'q', request);
|
|
nexusReject(g, 'p', { ...request, event: 'old' });
|
|
for (const territories of [
|
|
[],
|
|
['arrakeen', 'arrakeen'],
|
|
['polar_sink'],
|
|
['pasty_mesa:5'],
|
|
['homeworld:beneGesserit'],
|
|
])
|
|
nexusReject(
|
|
g,
|
|
'p',
|
|
{ ...request, territories },
|
|
/advisor|territor|Homeworld/i,
|
|
);
|
|
nexusReject(g, 'p', { ...request, amount: 1 });
|
|
assert.equal(g.nexusCards!.cards!.hands.p, 'beneGesserit');
|
|
});
|
|
|
|
void test('Cunning belongs to the current native Advanced shipment and movement action', () => {
|
|
for (const mutate of [
|
|
(g: Game) => {
|
|
g.active = 'q';
|
|
},
|
|
(g: Game) => {
|
|
g.phase = 6;
|
|
},
|
|
(g: Game) => {
|
|
g.phase = 8;
|
|
},
|
|
]) {
|
|
const g = nexusAdvisorFixture();
|
|
mutate(g);
|
|
assert.ok(viewGame(g, 'p').nexusAdvisors!.offer!.blocked);
|
|
nexusReject(g, 'p', action(g), /own Shipment and Movement/);
|
|
}
|
|
const basic = nexusAdvisorFixture({ advanced: false });
|
|
const offer = viewGame(basic, 'p').nexusAdvisors!.offer;
|
|
assert.ok(!offer || offer.blocked || !offer.territories.length);
|
|
nexusReject(
|
|
basic,
|
|
'p',
|
|
{
|
|
type: 'nexusAdvisors',
|
|
event: offer?.event ?? 'none',
|
|
territories: ['arrakeen'],
|
|
},
|
|
/Advanced/,
|
|
);
|
|
});
|
|
|
|
|
|
void test('a third occupying faction blocks stronghold conversion while leaving other advisor groups available', () => {
|
|
const g = nexusAdvisorFixture();
|
|
const observer = g.players.find((p) => p.id === 'r')!;
|
|
observer.forces['arrakeen:10'] = 1;
|
|
observer.reserves--;
|
|
const offer = viewGame(g, 'p').nexusAdvisors!.offer!;
|
|
assert.ok(offer.territories.find((t) => t.territory === 'arrakeen')!.blocked);
|
|
assert.equal(
|
|
offer.territories.find((t) => t.territory === 'pasty_mesa')!.blocked,
|
|
null,
|
|
);
|
|
nexusReject(g, 'p', action(g), /three occupying factions/);
|
|
nexusAdvisorInventory(g);
|
|
});
|
|
|
|
void test('already completed shipping and moving remain completed after a legal conversion before ending the action', () => {
|
|
let g = nexusAdvisorFixture();
|
|
nexusAdvisorPlayer(g).shipped = true;
|
|
nexusAdvisorPlayer(g).moved = 1;
|
|
const before = nexusReload(g);
|
|
g = allowNexusAdvisorFlip(beginNexusAdvisorFlip(g, ['pasty_mesa']));
|
|
assert.deepEqual(resources(g), resources(before));
|
|
assert.equal(g.active, 'p');
|
|
assert.equal(g.phase, 5);
|
|
assert.deepEqual(nexusAdvisorPlayer(g).advisors, {
|
|
arrakeen: {},
|
|
carthag: {},
|
|
});
|
|
});
|
|
|
|
void test('missing response, edited original groups and altered movement-frame evidence reject reads and actions unchanged', () => {
|
|
let g = nexusAdvisorFixture();
|
|
holdAdvisorKarama(g);
|
|
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']);
|
|
for (const mutate of [
|
|
(bad: Game) => {
|
|
bad.response = null;
|
|
},
|
|
(bad: Game) => {
|
|
bad.response!.owner = 'q';
|
|
},
|
|
(bad: Game) => {
|
|
bad.response!.intent = 'other-event';
|
|
},
|
|
(bad: Game) => {
|
|
delete bad.nexusAdvisorHistory;
|
|
},
|
|
(bad: Game) => {
|
|
delete bad.nexusAdvisorLast;
|
|
},
|
|
(bad: Game) => {
|
|
bad.nexusAdvisorHistory![0].receipt.selections[0].count++;
|
|
},
|
|
(bad: Game) => {
|
|
bad.nexusAdvisorHistory![0].stage = 'completed';
|
|
},
|
|
(bad: Game) => {
|
|
nexusAdvisorPlayer(bad).moved++;
|
|
},
|
|
(bad: Game) => {
|
|
nexusAdvisorPlayer(bad).forces['arrakeen:10']++;
|
|
nexusAdvisorPlayer(bad).reserves--;
|
|
},
|
|
]) {
|
|
const bad = nexusReload(g);
|
|
mutate(bad);
|
|
const saved = JSON.stringify(bad);
|
|
for (const p of bad.players) assert.throws(() => viewGame(bad, p.id));
|
|
assert.throws(() => normalizeAutomaticGame(bad));
|
|
assert.throws(() => applyAction(bad, 'q', { type: 'passResponse' }));
|
|
assert.equal(JSON.stringify(bad), saved);
|
|
}
|
|
});
|
|
|
|
void test('a real Truthtrance interruption preserves the pending advisor batch and its original cancellation response', () => {
|
|
let g = nexusAdvisorFixture();
|
|
holdAdvisorKarama(g);
|
|
const index = g.deck.findIndex((card) => card.effect === 'truthtrance');
|
|
assert.ok(index >= 0);
|
|
const truth = g.deck.splice(index, 1)[0];
|
|
g.players.find((p) => p.id === 'r')!.hand.push(truth);
|
|
g = beginNexusAdvisorFlip(g, ['arrakeen', 'pasty_mesa']);
|
|
const response = structuredClone(g.response);
|
|
const record = structuredClone(g.nexusAdvisorHistory);
|
|
g = applyAction(g, 'r', { type: 'card', card: truth.id });
|
|
while (g.truthtrance?.stage === 'priority')
|
|
g = applyAction(
|
|
g,
|
|
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
|
|
{ type: 'truthPass' },
|
|
);
|
|
g = applyAction(g, 'r', {
|
|
type: 'truthAsk',
|
|
question: {
|
|
kind: 'fact',
|
|
target: 'p',
|
|
fact: { kind: 'hand', name: 'Shield' },
|
|
},
|
|
});
|
|
g = applyAction(nexusReload(g), 'p', { type: 'truthAnswer', answer: 'no' });
|
|
assert.equal(g.truthtrance, null);
|
|
assert.deepEqual(g.response, response);
|
|
assert.deepEqual(g.nexusAdvisorHistory, record);
|
|
g = allowNexusAdvisorFlip(g);
|
|
assert.deepEqual(nexusAdvisorPlayer(g).advisors, { carthag: {} });
|
|
assert.equal(g.discard.filter((card) => card.id === truth.id).length, 1);
|
|
nexusAdvisorInventory(g);
|
|
});
|
|
|
|
void test('Harkonnen private Nexus exchange cannot consume or alter a pending advisor conversion', () => {
|
|
let g = nexusAdvisorFixture({ opponentFaction: 'harkonnen' });
|
|
const cards = g.nexusCards!.cards!;
|
|
cards.deck = [
|
|
'harkonnen',
|
|
...cards.deck.filter((card) => card !== 'harkonnen'),
|
|
];
|
|
g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0);
|
|
holdAdvisorKarama(g, 'r');
|
|
g = beginNexusAdvisorFlip(g, ['pasty_mesa']);
|
|
const response = structuredClone(g.response);
|
|
const record = structuredClone(g.nexusAdvisorHistory);
|
|
const offer = viewGame(g, 'q').nexusTraitors!.offer!;
|
|
assert.equal(offer.blocked, null);
|
|
g = applyAction(g, 'q', {
|
|
type: 'nexusTraitorDraw',
|
|
event: offer.event,
|
|
mode: offer.mode,
|
|
});
|
|
assert.deepEqual(g.nexusAdvisorHistory, record);
|
|
nexusReject(g, 'r', { type: 'passResponse' }, /private Nexus card return/);
|
|
const pending = g.nexusTraitorExchanges!.at(-1)!;
|
|
g = applyAction(normalizeAutomaticGame(nexusReload(g)), 'q', {
|
|
type: 'nexusTraitorReturn',
|
|
event: pending.event,
|
|
cards: [...pending.drawn],
|
|
});
|
|
assert.deepEqual(g.response, response);
|
|
assert.deepEqual(g.nexusAdvisorHistory, record);
|
|
g = allowNexusAdvisorFlip(g);
|
|
assert.deepEqual(nexusAdvisorPlayer(g).advisors, {
|
|
arrakeen: {},
|
|
carthag: {},
|
|
});
|
|
assert.equal(g.nexusTraitorExchanges!.length, 1);
|
|
assert.equal(g.nexusAdvisorHistory!.length, 1);
|
|
nexusAdvisorInventory(g);
|
|
});
|