315 lines
16 KiB
TypeScript
315 lines
16 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { TERRITORIES } from '../game/board';
|
|
import { presenceAt } from '../game/force-presence';
|
|
import { test } from 'node:test';
|
|
import { captureFremenCunningOccurrence, fremenCunningDestinationBlock,
|
|
quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection,
|
|
type FremenCunningOccurrence, type FremenCunningRideAuthorization,
|
|
type FremenCunningSelection } from '../game/nexus-fremen-cunning';
|
|
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { nexusFixture, nexusTurnTwo, nexusReady, nexusAllow, nexusInventory, nexusReload, orderNexusSpice } from './fixture-nexus-cards';
|
|
import { nexusFremenComponentsAppearance, completeNexusFremenComponentsRide } from './fixture-nexus-fremen-components';
|
|
|
|
function fixture() {
|
|
const g = nexusFixture({ advanced: true });
|
|
g.turn = 2;
|
|
g.phase = 1;
|
|
g.storm = 0;
|
|
for (const p of g.players) {
|
|
p.forces = {};
|
|
if (p.elites) p.elites.forces = {};
|
|
}
|
|
const cards = g.nexusCards!.cards!;
|
|
cards.deck = cards.deck.filter(card => card !== 'fremen');
|
|
cards.discard = cards.discard.filter(card => card !== 'fremen');
|
|
for (const id of Object.keys(cards.hands))
|
|
if (cards.hands[id] === 'fremen') cards.hands[id] = null;
|
|
cards.hands[g.players[0].id] = 'fremen';
|
|
const fremen = g.players[0];
|
|
fremen.forces['wind_pass:14'] = 3;
|
|
fremen.forces['wind_pass:15'] = 2;
|
|
fremen.elites!.forces['wind_pass:14'] = 1;
|
|
fremen.elites!.forces['wind_pass:15'] = 2;
|
|
return { g, fremen, other: g.players[1] };
|
|
}
|
|
|
|
function appearance(g: Game, territory = 'hagga_basin', event = 'worm-pile-0') {
|
|
return captureFremenCunningOccurrence(g, {
|
|
event, territory, origin: 'natural', pile: 0,
|
|
});
|
|
}
|
|
function accepted(g: Game, occurrence: FremenCunningOccurrence): FremenCunningRideAuthorization {
|
|
const owner = g.players[0].id;
|
|
g.nexusCards!.cards!.hands[owner] = null;
|
|
g.nexusCards!.cards!.discard.push('fremen');
|
|
return { event: occurrence.event, turn: occurrence.turn, owner };
|
|
}
|
|
const selection: FremenCunningSelection = {
|
|
source: 'wind_pass',
|
|
forces: { 'wind_pass:14': { normal: 1, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 2 } },
|
|
destination: { territory: 'carthag', sector: 11 },
|
|
};
|
|
|
|
void test('initial public emptiness belongs to an individual appearance, not its post-devour board', () => {
|
|
const { g, other } = fixture();
|
|
const empty = appearance(g);
|
|
assert.equal(empty.initiallyEmpty, true);
|
|
other.forces['hagga_basin:12'] = 2;
|
|
const occupied = appearance(g, 'hagga_basin', 'worm-pile-1');
|
|
assert.equal(occupied.initiallyEmpty, false);
|
|
delete other.forces['hagga_basin:12']; // worm devoured the defenders
|
|
assert.equal(quoteFremenCunning(g, g.players[0].id, occupied), null);
|
|
const quote = quoteFremenCunning(g, g.players[0].id, empty)!;
|
|
assert.equal(quote.event, 'worm-pile-0');
|
|
assert.deepEqual(quote.sources, [{ territory: 'wind_pass', sectors: {
|
|
'wind_pass:14': { normal: 2, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 2 },
|
|
} }]);
|
|
const receipt = accepted(g, empty);
|
|
assert.deepEqual(validateFremenCunningSelection(g, g.players[0].id, empty, selection, receipt),
|
|
{ total: 4, elite: 3 });
|
|
assert.equal(g.players[0].forces['wind_pass:14'], 3); // pure quote, no movement
|
|
assert.equal(quoteFremenCunning(g, g.players[0].id, { ...empty, turn: 1 }), null);
|
|
assert.equal(quoteFremenCunningRide(g, g.players[0].id, occupied, receipt), null);
|
|
assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
|
|
{ ...receipt, event: 'worm-pile-1' }), null);
|
|
assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
|
|
{ ...receipt, owner: other.id }), null);
|
|
assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
|
|
{ ...receipt, turn: empty.turn - 1 }), null);
|
|
assert.equal(quoteFremenCunningRide(g, g.players[0].id, { ...empty, turn: 1 }, receipt), null);
|
|
});
|
|
|
|
void test('advisers and a public No-Field count as initial force presence without reading marker denomination', () => {
|
|
const { g, other } = fixture();
|
|
other.faction = 'beneGesserit';
|
|
other.forces['hagga_basin:13'] = 1;
|
|
other.advisors = { hagga_basin: {} };
|
|
assert.equal(appearance(g).initiallyEmpty, false);
|
|
delete other.forces['hagga_basin:13'];
|
|
other.faction = 'richese';
|
|
const marker = { deployed: { location: { territory: 'hagga_basin', sector: 12 } } };
|
|
Object.defineProperty(marker, 'tokens', { get() { throw new Error('private denomination read'); } });
|
|
other.noField = marker as typeof other.noField;
|
|
assert.equal(appearance(g).initiallyEmpty, false);
|
|
assert.equal(appearance(g, 'carthag').initiallyEmpty, true);
|
|
});
|
|
|
|
void test('typed one-desert-source subset and ordinary destination occupancy/storm restrictions', () => {
|
|
const { g, fremen, other } = fixture();
|
|
const occurrence = appearance(g);
|
|
const receipt = accepted(g, occurrence);
|
|
const valid = (move: FremenCunningSelection) =>
|
|
validateFremenCunningSelection(g, fremen.id, occurrence, move, receipt);
|
|
const reject = (move: FremenCunningSelection, reason: RegExp) => assert.throws(() => valid(move), reason);
|
|
reject({ ...selection, forces: { ...selection.forces, 'hagga_basin:12': { normal: 1, elite: 0 } } }, /physical forces/);
|
|
reject({ ...selection, forces: { 'wind_pass:14': { normal: 3, elite: 0 } } }, /physical forces/);
|
|
reject({ ...selection, forces: { 'wind_pass:15': { normal: 1, elite: 0 } } }, /physical forces/);
|
|
reject({ ...selection, forces: { constructor: { normal: 20, elite: 0 } } },
|
|
/physical forces/);
|
|
reject({ ...selection, forces: {} }, /at least one/);
|
|
reject({ ...selection, source: 'carthag' }, /desert/);
|
|
assert.equal(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source,
|
|
{ territory: 'hagga_basin', sector: 12 }), null);
|
|
reject({ ...selection, destination: { territory: 'wind_pass', sector: 14 } }, /another destination/);
|
|
reject({ ...selection, destination: { territory: 'carthag', sector: 12 } }, /Arrakis board sector/);
|
|
g.storm = 11;
|
|
reject(selection, /storm/);
|
|
g.storm = 14;
|
|
assert.equal(quoteFremenCunningRide(g, fremen.id, occurrence, receipt)!.sources[0].sectors['wind_pass:14'], undefined);
|
|
reject(selection, /physical forces/);
|
|
g.storm = 0;
|
|
fremen.ally = other.id;
|
|
other.ally = fremen.id;
|
|
other.forces['carthag:11'] = 1;
|
|
assert.notEqual(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source, selection.destination), null);
|
|
fremen.ally = null;
|
|
other.ally = null;
|
|
const third = g.players[2];
|
|
third.forces['carthag:11'] = 1;
|
|
assert.notEqual(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source, selection.destination), null);
|
|
});
|
|
|
|
void test('physical Fremen card ownership and native entitlement are required', () => {
|
|
const { g, fremen, other } = fixture();
|
|
const occurrence = appearance(g);
|
|
const cards = g.nexusCards!.cards!;
|
|
cards.hands[fremen.id] = null;
|
|
cards.hands[other.id] = 'fremen';
|
|
assert.equal(quoteFremenCunning(g, fremen.id, occurrence), null);
|
|
assert.equal(quoteFremenCunning(g, other.id, occurrence), null);
|
|
assert.throws(() => validateFremenCunningSelection(g, fremen.id, occurrence, selection,
|
|
{ event: occurrence.event, turn: occurrence.turn, owner: fremen.id }), /not available/);
|
|
cards.hands[other.id] = null;
|
|
cards.hands[fremen.id] = 'fremen';
|
|
cards.deck.push('fremen'); // duplicate physical card cannot establish custody
|
|
assert.throws(() => quoteFremenCunning(g, fremen.id, occurrence), /Nexus/);
|
|
cards.deck.pop();
|
|
fremen.ally = other.id;
|
|
other.ally = fremen.id;
|
|
assert.equal(quoteFremenCunning(g, fremen.id, occurrence), null);
|
|
});
|
|
|
|
for (const advanced of [false, true]) void test(
|
|
`${advanced ? 'Advanced' : 'Basic'} empty worm offers privately, spends once and rides remotely after the Nexus`, () => {
|
|
let g = nexusTurnTwo({ advanced });
|
|
const cards = g.nexusCards!.cards!;
|
|
const index = cards.deck.indexOf('fremen');
|
|
assert.ok(index >= 0);
|
|
cards.deck.splice(index, 1);
|
|
cards.hands.f = 'fremen';
|
|
const sourceSector = g.storm === 14 ? 15 : 14;
|
|
g.players[0].reserves--;
|
|
g.players[0].forces[`wind_pass:${sourceSector}`] = 1;
|
|
orderNexusSpice(g, ['worm', 'land']);
|
|
const empty = g.spiceDeck.find(card => 'territory' in card &&
|
|
g.players.every(player => presenceAt(player, card.territory) === 0));
|
|
assert.ok(empty && 'territory' in empty);
|
|
g.spiceDeck.splice(g.spiceDeck.indexOf(empty), 1);
|
|
if (!('territory' in g.spiceDeck[1])) {
|
|
const nextLand = g.spiceDeck.findIndex((card, index) =>
|
|
index > 1 && 'territory' in card);
|
|
assert.ok(nextLand > 1);
|
|
g.spiceDeck.splice(1, 0, g.spiceDeck.splice(nextLand, 1)[0]);
|
|
}
|
|
g.spiceDiscard[0].push(empty);
|
|
g = nexusReady(g);
|
|
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
|
const event = g.nexusFremenCunningOffer!.occurrence.event;
|
|
assert.equal(viewGame(g, 'a').nexusCards?.card, null);
|
|
assert.equal(viewGame(g, 'f').nexusCards?.card, 'fremen');
|
|
const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
|
|
assert.ok(karamaIndex >= 0);
|
|
g.players[1].hand.push(g.deck.splice(karamaIndex, 1)[0]);
|
|
const noCard = nexusReload(g);
|
|
noCard.nexusCards!.cards!.hands.f = null;
|
|
noCard.nexusCards!.cards!.deck.push('fremen');
|
|
assert.equal(viewGame(noCard, 'a').decision?.kind, 'nexusFremenCunningOffer');
|
|
assert.throws(() => applyAction(noCard, 'f', {
|
|
type: 'decision', event, accept: true,
|
|
}), /Fremen Cunning needs/);
|
|
const passed = applyAction(noCard, 'f', { type: 'decision', event, accept: false });
|
|
assert.equal(passed.nexusFremenCunningOffer, null);
|
|
assert.equal(passed.nexusCards!.cards!.hands.f, null);
|
|
assert.throws(() => applyAction(g, 'f', {
|
|
type: 'decision', event: 'not-this-worm', accept: true,
|
|
}), /empty-worm Nexus opportunity/);
|
|
g = applyAction(nexusReload(g), 'f', { type: 'decision', event, accept: true });
|
|
assert.equal(g.response?.kind, 'nexusFremenCunning');
|
|
assert.equal(g.nexusCards!.cards!.hands.f, null);
|
|
assert.ok(g.nexusCards!.cards!.discard.includes('fremen'));
|
|
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
|
|
const canceled = applyAction(nexusReload(g), 'a', {
|
|
type: 'card', card: g.players[1].hand[0].id, mode: 'cancel',
|
|
});
|
|
assert.equal(canceled.nexusFremenCunningRides?.[0].stage, 'complete');
|
|
assert.ok(canceled.log.some(line => line.text.includes('Shai-Hulud appeared')));
|
|
assert.equal(canceled.players[0].forces[`wind_pass:${sourceSector}`], 1);
|
|
nexusInventory(canceled);
|
|
g = nexusAllow(g);
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
|
|
for (let i = 0; !g.decision && g.phase === 1 && i < 6; i++) {
|
|
if (g.response) g = nexusAllow(g);
|
|
else g = nexusReady(g);
|
|
}
|
|
assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'select');
|
|
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
|
|
const botView = viewGame(g, 'f');
|
|
botView.players.find(player => player.id === 'f')!.bot = 'Easy';
|
|
const botRide = botActions(botView).find(candidate => candidate.accept === true);
|
|
assert.ok(botRide);
|
|
const botResult = applyAction(nexusReload(g), 'f', botRide);
|
|
assert.equal(botResult.nexusFremenCunningRides?.[0].stage, 'complete');
|
|
nexusInventory(botResult);
|
|
const action = {
|
|
type: 'decision' as const, event, accept: true, source: 'wind_pass',
|
|
forces: { [`wind_pass:${sourceSector}`]: { normal: 1, elite: 0 } },
|
|
territory: 'polar_sink', sector: 0,
|
|
};
|
|
assert.throws(() => applyAction(g, 'f', { ...action, source: 'sietch_tabr' }),
|
|
/desert territory/);
|
|
assert.throws(() => applyAction(g, 'f', {
|
|
...action, forces: { constructor: { normal: 20, elite: 0 } },
|
|
}), /physical forces/);
|
|
const before = nexusReload(g);
|
|
assert.deepEqual(g, before);
|
|
g = applyAction(g, 'f', action);
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
|
assert.equal(g.players[0].forces[`wind_pass:${sourceSector}`] ?? 0, 0);
|
|
assert.equal(g.players[0].forces['polar_sink:0'], 1);
|
|
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
|
|
nexusInventory(g);
|
|
});
|
|
|
|
void test('an accepted Advanced additional-worm placement offers the distinct empty appearance', () => {
|
|
let g = nexusTurnTwo({ advanced: true });
|
|
const cards = g.nexusCards!.cards!;
|
|
const index = cards.deck.indexOf('fremen');
|
|
assert.ok(index >= 0);
|
|
cards.hands.f = cards.deck.splice(index, 1)[0];
|
|
const sourceSector = g.storm === 14 ? 15 : 14;
|
|
g.players[0].reserves--;
|
|
g.players[0].forces[`wind_pass:${sourceSector}`] = 1;
|
|
const empty = TERRITORIES.find(row => row.type === 'sand' &&
|
|
g.players.every(player => presenceAt(player, row.id) === 0));
|
|
assert.ok(empty);
|
|
orderNexusSpice(g, ['worm', 'land']);
|
|
const priorWorm = g.spiceDeck.findIndex((card, i) => i > 1 && 'worm' in card);
|
|
assert.ok(priorWorm > 1);
|
|
g.spiceDiscard[0].push(g.spiceDeck.splice(priorWorm, 1)[0]);
|
|
g = nexusReady(g);
|
|
assert.equal(g.decision?.kind, 'wormPlacement');
|
|
g = applyAction(g, 'f', { type: 'decision', accept: true, territory: empty.id });
|
|
g = nexusAllow(g);
|
|
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
|
const occurrence = g.nexusFremenCunningOffer!.occurrence;
|
|
assert.equal(occurrence.origin, 'additional');
|
|
assert.equal(occurrence.territory, empty.id);
|
|
assert.equal(occurrence.initiallyEmpty, true);
|
|
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
|
|
g = applyAction(g, 'f', { type: 'decision', event: occurrence.event, accept: true });
|
|
g = nexusAllow(g);
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
|
|
assert.equal(g.nexusFremenCunningRides?.[0].occurrence.event, occurrence.event);
|
|
assert.equal(g.nexusCards!.cards!.hands.f, null);
|
|
nexusInventory(g);
|
|
});
|
|
|
|
void test('original Tech and Strongholds preserve the private empty-worm opportunity and one remote ride', () => {
|
|
const fixture = nexusFremenComponentsAppearance({ advanced: true, tech: true, strongholds: true });
|
|
const { actor, source } = fixture;
|
|
let g = fixture.game;
|
|
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
|
const event = g.nexusFremenCunningOffer!.occurrence.event;
|
|
const components = { tech: structuredClone(g.techTokens), strongholds: structuredClone(g.strongholdCards) };
|
|
const fremen = g.players.find(player => player.id === actor)!;
|
|
const sourceBefore = fremen.forces[source];
|
|
const reserves = fremen.reserves, spice = fremen.spice;
|
|
const observer = g.players.find(player => player.id !== actor)!.id;
|
|
const absent = nexusReload(g);
|
|
absent.nexusCards!.cards!.hands[actor] = null;
|
|
absent.nexusCards!.cards!.deck.push('fremen');
|
|
assert.deepEqual(viewGame(absent, observer).decision, viewGame(g, observer).decision);
|
|
assert.throws(() => applyAction(absent, actor, { type: 'decision', event, accept: true }));
|
|
const declined = applyAction(absent, actor, { type: 'decision', event, accept: false });
|
|
assert.equal(declined.nexusFremenCunningOffer, null);
|
|
g = applyAction(g, actor, { type: 'decision', event, accept: true });
|
|
g = completeNexusFremenComponentsRide(g);
|
|
g = applyAction(g, actor, { type: 'decision', event, accept: true,
|
|
source: 'wind_pass', forces: { [source]: { normal: 2, elite: 0 } },
|
|
territory: 'polar_sink', sector: 0 });
|
|
const moved = g.players.find(player => player.id === actor)!;
|
|
assert.equal(moved.forces[source] ?? 0, sourceBefore - 2);
|
|
assert.equal(moved.forces['polar_sink:0'], 2);
|
|
assert.equal(moved.reserves, reserves);
|
|
assert.equal(moved.spice, spice);
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
|
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
|
assert.deepEqual(g.techTokens, components.tech);
|
|
assert.deepEqual(g.strongholdCards, components.strongholds);
|
|
assert.throws(() => applyAction(g, actor, { type: 'decision', event, accept: true,
|
|
source: 'wind_pass', forces: { [source]: { normal: 2, elite: 0 } },
|
|
territory: 'polar_sink', sector: 0 }));
|
|
});
|