389 lines
19 KiB
TypeScript
389 lines
19 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { TERRITORIES } from '../game/board';
|
|
import { applyAction, viewGame, type Game } from '../game/engine';
|
|
import { presenceAt } from '../game/force-presence';
|
|
import {
|
|
nexusAllow, nexusInventory, nexusReady, nexusReload, nexusTurnTwo,
|
|
orderNexusSpice,
|
|
} from './fixture-nexus-cards';
|
|
|
|
const source = 'wind_pass';
|
|
const destination = 'polar_sink:0';
|
|
|
|
function fixture(ordinaryRide = false) {
|
|
// This is the audited three-seat game after a completed first turn, not a
|
|
// synthetic spice pile or duplicate Nexus/Karama card.
|
|
let g = nexusTurnTwo({ advanced: true });
|
|
const cards = g.nexusCards!.cards!;
|
|
const nexusIndex = cards.deck.indexOf('fremen');
|
|
assert.ok(nexusIndex >= 0);
|
|
cards.hands.f = cards.deck.splice(nexusIndex, 1)[0];
|
|
const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
|
|
assert.ok(karamaIndex >= 0);
|
|
g.players[0].hand.push(g.deck.splice(karamaIndex, 1)[0]);
|
|
const counterKarama = g.deck.findIndex(card => card.effect === 'karama');
|
|
assert.ok(counterKarama >= 0);
|
|
g.players[1].hand.push(g.deck.splice(counterKarama, 1)[0]);
|
|
const previous = g.spiceDiscard[0].findLast(card => 'territory' in card);
|
|
assert.ok(!ordinaryRide || (previous && 'territory' in previous));
|
|
const rideTerritory = ordinaryRide && previous && 'territory' in previous
|
|
? previous.territory : null;
|
|
// The prior Spice Blow can be rock. Its worm still grants a native ride,
|
|
// but the separate Cunning ride needs a physically occupied desert source.
|
|
const sourceTerritory = rideTerritory &&
|
|
TERRITORIES.find(row => row.id === rideTerritory)?.type === 'sand'
|
|
? rideTerritory : source;
|
|
const sourceSector = sourceTerritory === source
|
|
? g.storm === 14 ? 15 : 14
|
|
: TERRITORIES.find(row => row.id === sourceTerritory)!.sectors.find(sector => sector !== g.storm)!;
|
|
const sourceKey = `${sourceTerritory}:${sourceSector}`;
|
|
const rideSector = rideTerritory && rideTerritory !== sourceTerritory
|
|
? TERRITORIES.find(row => row.id === rideTerritory)!.sectors.find(sector => sector !== g.storm)!
|
|
: null;
|
|
const fremen = g.players[0];
|
|
assert.ok(fremen.reserves >= (rideSector === null ? 2 : 3));
|
|
fremen.reserves -= rideSector === null ? 2 : 3;
|
|
fremen.forces[sourceKey] = (fremen.forces[sourceKey] ?? 0) + 2;
|
|
if (rideSector !== null) {
|
|
const rideKey = `${rideTerritory}:${rideSector}`;
|
|
fremen.forces[rideKey] = (fremen.forces[rideKey] ?? 0) + 1;
|
|
}
|
|
if (ordinaryRide) {
|
|
// The preceding turn left a physical land card in pile zero. A real worm
|
|
// drawn next attacks that land; Fremen there earn an ordinary ride.
|
|
const wormIndex = g.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker);
|
|
assert.ok(wormIndex >= 0);
|
|
g.spiceDeck.unshift(g.spiceDeck.splice(wormIndex, 1)[0]);
|
|
const landIndex = g.spiceDeck.findIndex((card, index) => index > 0 && 'territory' in card);
|
|
assert.ok(landIndex > 0);
|
|
g.spiceDeck.splice(1, 0, g.spiceDeck.splice(landIndex, 1)[0]);
|
|
} else orderNexusSpice(g, ['land', 'land']);
|
|
g = nexusReady(g);
|
|
if (ordinaryRide) {
|
|
g = nexusAllow(g);
|
|
assert.deepEqual(g.wormRides, [rideTerritory]);
|
|
}
|
|
assert.ok(g.spiceWindow && g.spiceSequence);
|
|
const target = TERRITORIES.find(row => row.type === 'sand' &&
|
|
row.id !== sourceTerritory && row.id !== g.spiceWindow!.territory &&
|
|
g.players.every(player => presenceAt(player, row.id) === 0));
|
|
assert.ok(target, 'The audited setup must leave an empty sand territory.');
|
|
assert.equal(g.phase, 1);
|
|
return { g, target: target.id, sourceKey, sourceTerritory, rideTerritory };
|
|
}
|
|
|
|
function summon(g: Game, target: string): Game {
|
|
return applyAction(g, 'f', {
|
|
type: 'card', mode: 'special', card: g.players[0].hand.find(card => card.effect === 'karama')!.id,
|
|
territory: target,
|
|
});
|
|
}
|
|
|
|
function accepted(g: Game) {
|
|
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
|
const pending = g.nexusFremenCunningOffer!;
|
|
const event = pending.occurrence.event;
|
|
assert.equal(pending.owner, 'f');
|
|
assert.equal(pending.occurrence.origin, 'summoned');
|
|
assert.equal(pending.occurrence.turn, 2);
|
|
assert.equal(pending.occurrence.initiallyEmpty, true);
|
|
const parent = g.summonedWorm?.event;
|
|
assert.ok(parent);
|
|
assert.match(event, /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i);
|
|
assert.match(parent, /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i);
|
|
assert.equal(pending.occurrence.parent, parent);
|
|
assert.notEqual(event, parent);
|
|
assert.equal(viewGame(g, 'a').nexusCards?.card, null);
|
|
assert.equal(viewGame(g, 'f').nexusCards?.card, 'fremen');
|
|
const before = structuredClone(g);
|
|
assert.throws(() => applyAction(g, 'a', { type: 'decision', event, accept: true }));
|
|
assert.deepEqual(g, before, 'Other seats cannot spend Fremen Cunning.');
|
|
assert.throws(() => applyAction(g, 'f', {
|
|
type: 'decision', event: 'stale-summon-occurrence', accept: true,
|
|
}), /empty-worm Nexus opportunity/);
|
|
assert.deepEqual(g, before, 'A stale event cannot change the summon or its card.');
|
|
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.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
|
assert.equal(g.nexusFremenCunningRides?.length, 1);
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'pending');
|
|
assert.equal(g.nexusFremenCunningRides?.[0].occurrence.event, event);
|
|
return { g, event };
|
|
}
|
|
|
|
void test('special Karama accepts one real Fremen card, survives a response reload, and restores the Spice Blow and ordinary ride before its remote ride', () => {
|
|
const { g: original, target, sourceKey, sourceTerritory, rideTerritory } = fixture(true);
|
|
const parentSequence = structuredClone(original.spiceSequence);
|
|
const parentWindow = structuredClone(original.spiceWindow);
|
|
const originalSource = original.players[0].forces[sourceKey] ?? 0;
|
|
let g = summon(original, target);
|
|
assert.equal(g.summonedWorm?.territory, target);
|
|
assert.equal(g.nexusFremenCunningOffer?.occurrence.territory, target);
|
|
({ g } = accepted(nexusReload(g)));
|
|
g = nexusAllow(nexusReload(g));
|
|
assert.equal(g.summonedWorm, null);
|
|
assert.equal(g.nexusFremenCunningOffer, null);
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
|
|
assert.deepEqual(g.spiceSequence, parentSequence);
|
|
assert.deepEqual(g.spiceWindow, parentWindow);
|
|
assert.deepEqual(g.wormRides, [rideTerritory]);
|
|
assert.equal(g.nexus, true);
|
|
assert.equal(g.players[0].forces[sourceKey], originalSource);
|
|
assert.equal(g.discard.filter(card => card.effect === 'karama').length, 1);
|
|
g = nexusReady(g); // Finish the interrupted blow; then close the summoned Nexus.
|
|
assert.equal(g.nexus, true);
|
|
g = nexusReady(g);
|
|
assert.equal(g.decision?.kind, 'wormRide');
|
|
assert.equal(g.decision?.kind === 'wormRide' && g.decision.territory, rideTerritory);
|
|
g = applyAction(g, 'f', { type: 'decision', accept: false });
|
|
assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
|
|
const event = g.nexusFremenCunningRides![0].occurrence.event;
|
|
assert.equal(g.decision?.kind === 'nexusFremenCunningRide' && g.decision.event, event);
|
|
const before = nexusReload(g);
|
|
assert.throws(() => applyAction(g, 'a', { type: 'decision', event, accept: false }));
|
|
assert.throws(() => applyAction(g, 'f', {
|
|
type: 'decision', event: 'stale-summon-occurrence', accept: false,
|
|
}), /pending Fremen Cunning remote worm ride/);
|
|
assert.deepEqual(g, before);
|
|
g = applyAction(nexusReload(g), 'f', {
|
|
type: 'decision', event, accept: true, source: sourceTerritory,
|
|
forces: { [sourceKey]: { normal: 1, elite: 0 } },
|
|
territory: 'polar_sink', sector: 0,
|
|
});
|
|
assert.equal(g.players[0].forces[sourceKey], originalSource - 1);
|
|
assert.equal(g.players[0].forces[destination],
|
|
(original.players[0].forces[destination] ?? 0) + 1);
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
|
assert.equal(g.nexusFremenCunningRides?.length, 1);
|
|
nexusInventory(g);
|
|
});
|
|
|
|
void test('Karama cancellation stops only the accepted remote ride; declining leaves the card in hand', () => {
|
|
const { g: before, target, sourceKey } = fixture();
|
|
const offered = summon(before, target);
|
|
const event = offered.nexusFremenCunningOffer!.occurrence.event;
|
|
const declined = applyAction(nexusReload(offered), 'f', {
|
|
type: 'decision', event, accept: false,
|
|
});
|
|
assert.equal(declined.nexusCards!.cards!.hands.f, 'fremen');
|
|
assert.equal(declined.nexusFremenCunningRides?.length ?? 0, 0);
|
|
assert.equal(declined.summonedWorm, null);
|
|
assert.ok(declined.spiceWindow);
|
|
const { g: acceptedGame } = accepted(offered);
|
|
const canceled = applyAction(nexusReload(acceptedGame), 'a', {
|
|
type: 'card', mode: 'cancel', card: acceptedGame.players[1].hand[0].id,
|
|
});
|
|
assert.equal(canceled.response, null);
|
|
assert.equal(canceled.nexusFremenCunningRides?.[0].stage, 'complete');
|
|
assert.equal(canceled.nexusCards!.cards!.hands.f, null);
|
|
assert.equal(canceled.players[0].forces[sourceKey], before.players[0].forces[sourceKey]);
|
|
assert.equal(canceled.summonedWorm, null);
|
|
assert.deepEqual(canceled.spiceSequence, before.spiceSequence);
|
|
assert.deepEqual(canceled.spiceWindow, before.spiceWindow);
|
|
assert.ok(canceled.log.some(line => line.text.includes('Shai-Hulud appeared')));
|
|
nexusInventory(canceled);
|
|
});
|
|
|
|
void test('occupied appearance, absent desert source, or alliance has no Cunning offer; the summon still resolves', () => {
|
|
for (const unavailable of ['occupied', 'source', 'allied'] as const) {
|
|
const { g, target, sourceKey } = fixture();
|
|
if (unavailable === 'occupied') {
|
|
const other = g.players[1];
|
|
const sector = TERRITORIES.find(row => row.id === target)!.sectors.find(s => s !== g.storm)!;
|
|
other.reserves--;
|
|
other.forces[`${target}:${sector}`] = (other.forces[`${target}:${sector}`] ?? 0) + 1;
|
|
} else if (unavailable === 'source') {
|
|
g.players[0].reserves += g.players[0].forces[sourceKey];
|
|
delete g.players[0].forces[sourceKey];
|
|
for (const [key, count] of Object.entries(g.players[0].forces)) {
|
|
g.players[0].reserves += count;
|
|
delete g.players[0].forces[key];
|
|
}
|
|
} else {
|
|
g.players[0].ally = 'a';
|
|
g.players[1].ally = 'f';
|
|
g.nexusCards!.cards!.hands.f = null;
|
|
g.nexusCards!.cards!.deck.push('fremen');
|
|
}
|
|
const summoned = summon(g, target);
|
|
assert.notEqual(summoned.decision?.kind, 'nexusFremenCunningOffer', unavailable);
|
|
assert.equal(summoned.nexusFremenCunningOffer ?? null, null);
|
|
assert.equal(summoned.summonedWorm, null);
|
|
assert.deepEqual(summoned.spiceSequence, g.spiceSequence);
|
|
assert.ok(summoned.log.some(line => line.text.includes('Shai-Hulud appeared')));
|
|
nexusInventory(summoned);
|
|
}
|
|
});
|
|
|
|
void test('card-blind empty appearance cannot spend a Fremen card it does not physically hold', () => {
|
|
const { g, target } = fixture();
|
|
g.nexusCards!.cards!.hands.f = null;
|
|
g.nexusCards!.cards!.deck.push('fremen');
|
|
const offered = summon(g, target);
|
|
assert.equal(offered.decision?.kind, 'nexusFremenCunningOffer');
|
|
const event = offered.nexusFremenCunningOffer!.occurrence.event;
|
|
const before = structuredClone(offered);
|
|
assert.throws(() => applyAction(offered, 'f', {
|
|
type: 'decision', event, accept: true,
|
|
}), /Fremen Cunning needs/);
|
|
assert.deepEqual(offered, before);
|
|
const passed = applyAction(offered, 'f', { type: 'decision', event, accept: false });
|
|
assert.equal(passed.nexusFremenCunningOffer, null);
|
|
assert.equal(passed.nexusCards!.cards!.hands.f, null);
|
|
assert.equal(passed.summonedWorm, null);
|
|
nexusInventory(passed);
|
|
});
|
|
|
|
void test('restored summon offer and spent response reject a replaced parent before a card or force change', () => {
|
|
const { g: parent, target } = fixture();
|
|
const offered = summon(parent, target);
|
|
const event = offered.nexusFremenCunningOffer!.occurrence.event;
|
|
const wrongOffer = nexusReload(offered);
|
|
wrongOffer.summonedWorm!.event = 'different-special-karama';
|
|
const savedOffer = structuredClone(wrongOffer);
|
|
assert.throws(() => applyAction(wrongOffer, 'f', {
|
|
type: 'decision', event, accept: true,
|
|
}), /original special Karama parent/);
|
|
assert.deepEqual(wrongOffer, savedOffer);
|
|
const pending = applyAction(offered, 'f', { type: 'decision', event, accept: true });
|
|
assert.equal(pending.response?.kind, 'nexusFremenCunning');
|
|
const wrongResponse = nexusReload(pending);
|
|
wrongResponse.summonedWorm!.event = 'different-special-karama';
|
|
const savedResponse = structuredClone(wrongResponse);
|
|
assert.throws(() => applyAction(wrongResponse, 'a', { type: 'passResponse' }),
|
|
/summoned worm parent/);
|
|
assert.deepEqual(wrongResponse, savedResponse);
|
|
assert.equal(pending.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
|
nexusInventory(pending);
|
|
});
|
|
|
|
function naturalOfferWithUnusedSummon(secondFaction: 'atreides' | 'beneGesserit' = 'atreides') {
|
|
let g = nexusTurnTwo({ advanced: true, secondFaction });
|
|
const cards = g.nexusCards!.cards!;
|
|
const cardIndex = cards.deck.indexOf('fremen');
|
|
assert.ok(cardIndex >= 0);
|
|
cards.hands.f = cards.deck.splice(cardIndex, 1)[0];
|
|
for (const seat of [0, 1]) {
|
|
const index = g.deck.findIndex(card => card.effect === 'karama');
|
|
assert.ok(index >= 0);
|
|
g.players[seat].hand.push(g.deck.splice(index, 1)[0]);
|
|
}
|
|
const sourceSector = g.storm === 14 ? 15 : 14;
|
|
g.players[0].reserves -= 2;
|
|
g.players[0].forces[`wind_pass:${sourceSector}`] = 2;
|
|
orderNexusSpice(g, ['worm', 'land']);
|
|
const empty = g.spiceDeck.find((card, index) => index > 1 && 'territory' in card &&
|
|
card.territory !== 'wind_pass' &&
|
|
g.players.every(player => presenceAt(player, card.territory) === 0));
|
|
assert.ok(empty && 'territory' in empty);
|
|
g.spiceDeck.splice(g.spiceDeck.indexOf(empty), 1);
|
|
g.spiceDiscard[0].push(empty);
|
|
g = nexusReady(g);
|
|
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
|
nexusInventory(g);
|
|
return g;
|
|
}
|
|
|
|
void test('a real special summon suspends and restores an earlier natural Cunning offer across worm survival', () => {
|
|
const original = naturalOfferWithUnusedSummon();
|
|
const event = original.nexusFremenCunningOffer!.occurrence.event;
|
|
const suspended = summon(original, 'wind_pass');
|
|
assert.equal(suspended.response?.kind, 'wormSurvival');
|
|
assert.equal(suspended.summonedWorm?.resume.decision?.kind, 'nexusFremenCunningOffer');
|
|
assert.equal(suspended.nexusFremenCunningOffer?.occurrence.event, event);
|
|
let g = nexusAllow(nexusReload(suspended));
|
|
assert.equal(g.summonedWorm, null);
|
|
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
|
assert.equal(g.nexusFremenCunningOffer?.occurrence.event, event);
|
|
g = applyAction(g, 'f', { type: 'decision', event, accept: true });
|
|
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
|
nexusInventory(g);
|
|
});
|
|
|
|
void test('a summon during a selected remote ride reopens its Nexus before resuming that ride', () => {
|
|
let g = naturalOfferWithUnusedSummon();
|
|
const event = g.nexusFremenCunningOffer!.occurrence.event;
|
|
g = applyAction(g, 'f', { type: 'decision', event, accept: true });
|
|
g = nexusAllow(g);
|
|
for (let i = 0; g.phase === 1 && !g.decision && i < 6; i++)
|
|
g = g.response ? nexusAllow(g) : nexusReady(g);
|
|
assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
|
|
const target = TERRITORIES.find(row => row.type === 'sand' && row.id !== 'wind_pass' &&
|
|
g.players.every(player => presenceAt(player, row.id) === 0));
|
|
assert.ok(target);
|
|
g = summon(g, target.id);
|
|
assert.equal(g.summonedWorm, null);
|
|
assert.equal(g.nexus, true);
|
|
assert.equal(g.decision, null, 'The new Nexus must precede the interrupted ride.');
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
|
|
g = nexusReady(g);
|
|
assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
|
|
assert.equal(g.nexusFremenCunningRides?.[0].occurrence.event, event);
|
|
g = applyAction(g, 'f', { type: 'decision', event, accept: false });
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
|
nexusInventory(g);
|
|
});
|
|
|
|
void test('a summoned survival response preserves the earlier pending Cunning Karama response', () => {
|
|
let g = naturalOfferWithUnusedSummon();
|
|
const event = g.nexusFremenCunningOffer!.occurrence.event;
|
|
g = applyAction(g, 'f', { type: 'decision', event, accept: true });
|
|
assert.equal(g.response?.kind, 'nexusFremenCunning');
|
|
g = summon(g, 'wind_pass');
|
|
assert.equal(g.response?.kind, 'wormSurvival');
|
|
assert.equal(g.summonedWorm?.resume.response?.kind, 'nexusFremenCunning');
|
|
g = nexusReload(g);
|
|
for (let i = 0; g.summonedWorm && i < 6; i++) {
|
|
assert.equal(g.response?.kind, 'wormSurvival');
|
|
const id = g.players.find(player => !g.response!.passed.includes(player.id))!.id;
|
|
g = applyAction(g, id, { type: 'passResponse' });
|
|
}
|
|
assert.equal(g.summonedWorm, null);
|
|
assert.equal(g.response?.kind, 'nexusFremenCunning');
|
|
assert.equal(g.response.intent, event);
|
|
g = nexusAllow(g);
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
|
|
nexusInventory(g);
|
|
});
|
|
|
|
void test('nested BG Karama conversions preserve the suspended Cunning cancellation', () => {
|
|
let g = naturalOfferWithUnusedSummon('beneGesserit');
|
|
const event = g.nexusFremenCunningOffer!.occurrence.event;
|
|
for (let i = 0; i < 2; i++) {
|
|
const index = g.deck.findIndex(card => card.kind === 'worthless');
|
|
assert.ok(index >= 0);
|
|
g.players[1].hand.push(g.deck.splice(index, 1)[0]);
|
|
}
|
|
const counterKarama = g.players[1].hand.findIndex(card => card.effect === 'karama');
|
|
assert.ok(counterKarama >= 0);
|
|
g.players[2].hand.push(g.players[1].hand.splice(counterKarama, 1)[0]);
|
|
g = applyAction(g, 'f', { type: 'decision', event, accept: true });
|
|
assert.equal(g.response?.kind, 'nexusFremenCunning');
|
|
g = applyAction(g, 'a', {
|
|
type: 'card', mode: 'cancel', card: g.players[1].hand.find(card => card.kind === 'worthless')!.id,
|
|
});
|
|
assert.equal(g.response?.kind, 'worthlessKarama');
|
|
assert.equal(g.pendingKarama?.use.kind, 'cancel');
|
|
g = summon(g, 'wind_pass');
|
|
assert.equal(g.response?.kind, 'wormSurvival');
|
|
assert.equal(g.summonedWorm?.resume.pendingKarama?.use.kind, 'cancel');
|
|
g = applyAction(g, 'a', {
|
|
type: 'card', mode: 'cancel', card: g.players[1].hand.find(card => card.kind === 'worthless')!.id,
|
|
});
|
|
assert.equal(g.response?.kind, 'worthlessKarama');
|
|
assert.equal(g.pendingKarama?.use.kind, 'cancel');
|
|
assert.equal(g.summonedWorm?.resume.pendingKarama?.use.kind, 'cancel');
|
|
g = applyAction(nexusReload(g), 'h', { type: 'passResponse' });
|
|
assert.equal(g.summonedWorm, null);
|
|
assert.equal(g.response?.kind, 'worthlessKarama');
|
|
assert.equal(g.pendingKarama?.use.kind, 'cancel');
|
|
if (g.pendingKarama?.use.kind === 'cancel')
|
|
assert.equal(g.pendingKarama.use.response.kind === 'nexusFremenCunning' &&
|
|
g.pendingKarama.use.response.intent, event);
|
|
g = nexusAllow(g);
|
|
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
|
nexusInventory(g);
|
|
});
|