Preserve current-sector losses and original Fremen casualty priority before the mandatory BG counter. Bind public sector facts to a private continuation and resume only the remaining storm once.
532 lines
18 KiB
TypeScript
532 lines
18 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
type Action,
|
|
} from '../game/engine';
|
|
import { baseDeck, spiceDeck } from '../game/cards';
|
|
import { territory } from '../game/board';
|
|
const shipmentSector = territory('the_great_flat').sectors[0];
|
|
const shipmentKey = `the_great_flat:${shipmentSector}`;
|
|
import {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
function fixture() {
|
|
const g = createGame(
|
|
'STORMWORM',
|
|
newPlayer('b', 'BG', 'beneGesserit'),
|
|
true,
|
|
['choam'],
|
|
);
|
|
g.players.push(
|
|
newPlayer('f', 'Fremen', 'fremen'),
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
newPlayer('r', 'Richese', 'richese'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 0,
|
|
turn: 1,
|
|
active: 'f',
|
|
storm: 6,
|
|
order: ['f', 'e', 'r', 'b'],
|
|
deck: baseDeck(),
|
|
phaseOpening: null,
|
|
stormDialers: ['f', 'e'],
|
|
stormDials: {},
|
|
ready: [],
|
|
});
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
p.tanks = 0;
|
|
p.spice = 20;
|
|
p.traitors = [];
|
|
p.traitorChoices = [];
|
|
}
|
|
const hold = (id: string, kind: string) => {
|
|
const i = g.deck.findIndex((c) => c.kind === kind || c.effect === kind);
|
|
assert.ok(i >= 0);
|
|
const c = g.deck.splice(i, 1)[0];
|
|
player(g, id).hand.push(c);
|
|
return c.id;
|
|
};
|
|
const worthless = hold('b', 'worthless'),
|
|
printed = hold('e', 'karama'),
|
|
special = hold('f', 'karama');
|
|
player(g, 'f').forces = { 'red_chasm:7': 4 };
|
|
player(g, 'f').reserves = 16;
|
|
player(g, 'f').elites = {
|
|
forces: { 'red_chasm:7': 2 },
|
|
reserves: 1,
|
|
tanks: 0,
|
|
revived: 0,
|
|
};
|
|
return { g, worthless, printed, special };
|
|
}
|
|
type Fixture = ReturnType<typeof fixture>;
|
|
function act(g: Game, id: string, a: Action) {
|
|
const before = structuredClone(g);
|
|
const next = applyAction(g, id, a);
|
|
assert.deepEqual(g, before);
|
|
return reload(next);
|
|
}
|
|
function pass(g: Game, kind: NonNullable<Game['response']>['kind']) {
|
|
for (let i = 0; g.response?.kind === kind; i++) {
|
|
assert.ok(i < 16);
|
|
g = act(g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, {
|
|
type: 'passResponse',
|
|
});
|
|
}
|
|
return g;
|
|
}
|
|
function cancel(g: Game, f: Fixture, bg: boolean) {
|
|
return act(g, bg ? 'b' : 'e', {
|
|
type: 'card',
|
|
card: bg ? f.worthless : f.printed,
|
|
mode: 'cancel',
|
|
});
|
|
}
|
|
function settledCancel(g: Game, f: Fixture, bg: boolean) {
|
|
g = cancel(g, f, bg);
|
|
return bg ? pass(g, 'worthlessKarama') : g;
|
|
}
|
|
function storm(f: Fixture, distance = 1) {
|
|
let g = act(f.g, 'f', { type: 'stormDial', amount: Math.min(20, distance) });
|
|
g = act(g, 'e', { type: 'stormDial', amount: Math.max(0, distance - 20) });
|
|
for (const p of g.players) g = act(g, p.id, { type: 'ready' });
|
|
assert.equal(g.response?.kind, 'stormProtection');
|
|
return g;
|
|
}
|
|
function shipment(f: Fixture) {
|
|
let g = f.g;
|
|
g.phase = 5;
|
|
g.turn = 2;
|
|
g.storm = shipmentSector;
|
|
player(g, 'f').forces = { [shipmentKey]: 4 };
|
|
player(g, 'f').elites!.forces = { [shipmentKey]: 2 };
|
|
g.movementRemaining = [...g.order];
|
|
g.stormDialers = [];
|
|
g.stormPending = null;
|
|
g = act(g, 'f', {
|
|
type: 'ship',
|
|
territory: 'the_great_flat',
|
|
sector: shipmentSector,
|
|
amount: 3,
|
|
elite: 1,
|
|
});
|
|
assert.equal(g.response?.kind, 'stormProtection');
|
|
assert.equal(g.response?.resume, 'shipment');
|
|
return g;
|
|
}
|
|
function naturalWorm(
|
|
f: Fixture,
|
|
ally = false,
|
|
additional = false,
|
|
site = 'red_chasm',
|
|
) {
|
|
let g = f.g;
|
|
g.phase = 1;
|
|
g.turn = 2;
|
|
g.storm = 18;
|
|
g.nexus = false;
|
|
g.stormPending = null;
|
|
const lands = spiceDeck().filter((c) => 'territory' in c);
|
|
const land = lands.find((c) => c.territory === site)!;
|
|
const worm = spiceDeck().find((c) => 'worm' in c)!;
|
|
g.spiceDiscard = [[additional ? worm : land], []];
|
|
g.spiceDeck = [worm, ...lands.filter((c) => c !== land)];
|
|
if (ally) {
|
|
player(g, 'f').ally = 'e';
|
|
player(g, 'e').ally = 'f';
|
|
player(g, 'e').forces = { 'red_chasm:7': 2 };
|
|
player(g, 'e').reserves = 18;
|
|
}
|
|
for (const p of g.players) g = act(g, p.id, { type: 'ready' });
|
|
if (additional) {
|
|
assert.equal(g.decision?.kind, 'wormPlacement');
|
|
g = act(g, 'f', { type: 'decision', accept: true, territory: 'red_chasm' });
|
|
} else if (ally) {
|
|
assert.equal(g.decision?.kind, 'wormProtection');
|
|
g = act(g, 'f', { type: 'decision', accept: true });
|
|
}
|
|
assert.equal(
|
|
g.response?.kind,
|
|
additional ? 'wormPlacement' : ally ? 'wormAllyProtection' : 'wormSurvival',
|
|
);
|
|
return g;
|
|
}
|
|
function noField(g: Game) {
|
|
const p = player(g, 'r');
|
|
p.noField = deployRicheseNoField(createRicheseNoField(['n0', 'n3', 'n5']), {
|
|
tokenId: 'n3',
|
|
controller: p.id,
|
|
location: { territory: 'red_chasm', sector: 7 },
|
|
});
|
|
p.noFieldEvent = 'deployed-n3';
|
|
}
|
|
function reject(g: Game, f: Fixture, bg: boolean) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => cancel(g, f, bg));
|
|
assert.deepEqual(g, before);
|
|
assert.ok(
|
|
player(g, bg ? 'b' : 'e').hand.some(
|
|
(c) => c.id === (bg ? f.worthless : f.printed),
|
|
),
|
|
);
|
|
}
|
|
|
|
void test('both Karama forms cancel real first-storm exposure beyond a full circuit and preserve typed force conservation', () => {
|
|
for (const bg of [false, true]) {
|
|
const f = fixture();
|
|
let g = storm(f, 40);
|
|
assert.equal(g.stormResolution?.distance, 40);
|
|
g = settledCancel(g, f, bg);
|
|
assert.equal(player(g, 'f').forces['red_chasm:7'], undefined);
|
|
assert.equal(player(g, 'f').tanks, 4);
|
|
assert.equal(player(g, 'f').elites?.tanks, 2);
|
|
assert.equal(g.storm, 10);
|
|
assert.equal(g.stormResolution, null);
|
|
assert.equal(
|
|
g.discard.filter((c) => c.id === (bg ? f.worthless : f.printed)).length,
|
|
1,
|
|
);
|
|
}
|
|
});
|
|
void test('printed counter-cancellation of BG storm protection restores genuine typed casualty choice and its pending continuation', () => {
|
|
const f = fixture();
|
|
player(f.g, 'f').forces['the_minor_erg:7'] = 4;
|
|
player(f.g, 'f').reserves = 12;
|
|
player(f.g, 'f').elites!.forces['the_minor_erg:7'] = 1;
|
|
player(f.g, 'f').elites!.reserves = 0;
|
|
let g = cancel(storm(f), f, true);
|
|
g = act(g, 'e', { type: 'card', card: f.printed, mode: 'cancel' });
|
|
g = pass(g, 'stormProtection');
|
|
assert.equal(g.decision?.kind, 'stormLosses');
|
|
assert.equal(g.stormResolution?.traversed, 1);
|
|
assert.equal(viewGame(g, 'f').decision?.kind, 'stormLosses');
|
|
assert.deepEqual(g.stormResolution!.pending, ['the_minor_erg:7']);
|
|
g = act(reload(g), 'f', { type: 'decision', elite: 1 });
|
|
assert.equal(g.decision?.kind, 'stormLosses');
|
|
assert.deepEqual(g.stormResolution!.pending, []);
|
|
g = act(reload(g), 'f', { type: 'decision', elite: 0 });
|
|
assert.equal(player(g, 'f').forces['the_minor_erg:7'], 2);
|
|
assert.equal(player(g, 'f').elites!.forces['the_minor_erg:7'], 1);
|
|
assert.equal(player(g, 'f').forces['red_chasm:7'], 2);
|
|
assert.equal(player(g, 'f').elites?.forces['red_chasm:7'], 1);
|
|
assert.equal(player(g, 'f').tanks, 4);
|
|
assert.equal(player(g, 'f').elites?.tanks, 1);
|
|
assert.equal(g.stormResolution, null);
|
|
});
|
|
void test('controlled storm staging holds alone advisors until the original mixed Fremen casualties finish', () => {
|
|
for (const prevented of [false, true]) {
|
|
const f = fixture();
|
|
// Controlled conserved placement, not an earned native setup journey:
|
|
// the existing mixed Red Chasm group still owns the real casualty choice.
|
|
const bg = player(f.g, 'b'), emperor = player(f.g, 'e');
|
|
bg.forces['the_minor_erg:8'] = 1;
|
|
bg.reserves--;
|
|
bg.advisors = { the_minor_erg: {} };
|
|
emperor.forces['the_minor_erg:7'] = 1;
|
|
emperor.reserves--;
|
|
|
|
let g = pass(storm(f), 'stormProtection');
|
|
assert.equal(player(g, 'e').forces['the_minor_erg:7'], undefined);
|
|
assert.equal(player(g, 'e').tanks, 1);
|
|
assert.equal(g.decision?.kind, 'stormLosses');
|
|
if (g.decision?.kind !== 'stormLosses') assert.fail('The original Fremen casualty choice must remain first.');
|
|
assert.equal(g.decision.key, 'red_chasm:7');
|
|
assert.equal(g.decision.amount, 2);
|
|
assert.equal(g.response, null, 'The held conversion must not interrupt mandatory Fremen losses.');
|
|
const held = g.pendingAutomaticAdvisorFlips?.find(source =>
|
|
source.owner === 'b' && source.territory === 'the_minor_erg');
|
|
assert.ok(held, 'The storm must capture the already-alone group before exposing the casualty choice.');
|
|
assert.equal(held.producer, 'storm');
|
|
assert.equal(held.phase, 0);
|
|
assert.deepEqual(held.forces, { 'the_minor_erg:8': 1 });
|
|
assert.equal(player(g, 'b').advisors?.the_minor_erg?.aloneFlipHold, held.event);
|
|
assert.equal(g.stormResolution?.traversed, 1);
|
|
assert.equal(g.storm, 6);
|
|
assert.deepEqual(g.stormResolution?.pending, [], 'The last casualty choice has already popped its queue item.');
|
|
assert.equal(g.pendingAutomaticAdvisorStorm?.ready, false, 'An unresolved casualty choice is not a ready sector.');
|
|
|
|
g = act(g, 'f', { type: 'decision', elite: 1 });
|
|
assert.equal(g.decision, null);
|
|
assert.equal(g.response?.kind, 'advisorFlip');
|
|
assert.equal(g.response?.automaticAdvisorFlip?.event, held.event);
|
|
assert.equal(g.pendingAutomaticAdvisorStorm?.ready, true);
|
|
assert.deepEqual(g.stormResolution?.pending, []);
|
|
assert.equal(g.stormResolution?.traversed, 1);
|
|
assert.equal(player(g, 'f').forces['red_chasm:7'], 2);
|
|
assert.equal(player(g, 'f').elites?.forces['red_chasm:7'], 1);
|
|
assert.equal(player(g, 'f').tanks, 2);
|
|
assert.equal(player(g, 'f').elites?.tanks, 1);
|
|
const physical = g.players.map(p => ({ id: p.id, forces: structuredClone(p.forces),
|
|
reserves: p.reserves, tanks: p.tanks, elites: structuredClone(p.elites) }));
|
|
|
|
g = prevented ? cancel(g, f, false) : pass(g, 'advisorFlip');
|
|
assert.notEqual(g.response?.kind, 'advisorFlip');
|
|
assert.equal(g.storm, 7);
|
|
assert.equal(g.stormResolution, null);
|
|
assert.equal(g.phase, 1);
|
|
assert.ok(!g.pendingAutomaticAdvisorFlips?.length);
|
|
assert.deepEqual(g.players.map(p => ({ id: p.id, forces: structuredClone(p.forces),
|
|
reserves: p.reserves, tanks: p.tanks, elites: structuredClone(p.elites) })), physical,
|
|
'Finishing the conversion must not repeat any storm casualty or physical transfer.');
|
|
assert.equal(!!player(g, 'b').advisors?.the_minor_erg, prevented);
|
|
if (prevented)
|
|
assert.equal(player(g, 'b').advisors?.the_minor_erg?.aloneFlipHold, held.event);
|
|
assert.equal(g.discard.filter(card => card.id === f.printed).length, Number(prevented));
|
|
}
|
|
});
|
|
void test('both forms cancel only a newly shipped typed Fremen cohort in the storm, leaving earlier occupants intact', () => {
|
|
for (const bg of [false, true]) {
|
|
const f = fixture();
|
|
let g = shipment(f);
|
|
assert.equal(player(g, 'f').forces[shipmentKey], 7);
|
|
g = settledCancel(g, f, bg);
|
|
assert.equal(player(g, 'f').forces[shipmentKey], 4);
|
|
assert.equal(player(g, 'f').elites?.forces[shipmentKey], 2);
|
|
assert.equal(player(g, 'f').tanks, 3);
|
|
assert.equal(player(g, 'f').elites?.tanks, 1);
|
|
assert.equal(player(g, 'f').shipped, true);
|
|
assert.equal(player(g, 'f').moved, 0);
|
|
}
|
|
});
|
|
void test('natural worm survival, allied protection and additional placement retain their distinct genuine cancellation consequences', () => {
|
|
for (const bg of [false, true])
|
|
for (const source of ['survival', 'ally', 'placement'] as const) {
|
|
const f = fixture();
|
|
let g = naturalWorm(f, source === 'ally', source === 'placement');
|
|
g = settledCancel(g, f, bg);
|
|
if (source === 'placement') {
|
|
assert.equal(g.wormPlacementCanceledTurn, 2);
|
|
assert.equal(player(g, 'f').tanks, 0);
|
|
assert.equal(player(g, 'f').forces['red_chasm:7'], 4);
|
|
} else if (source === 'survival') {
|
|
assert.equal(player(g, 'f').tanks, 4);
|
|
assert.equal(player(g, 'f').elites?.tanks, 2);
|
|
} else {
|
|
g = pass(g, 'wormSurvival');
|
|
assert.equal(player(g, 'f').tanks, 0);
|
|
assert.equal(player(g, 'e').tanks, 2);
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('malformed current storm and worm casualty custody is rejected before either physical Karama cost', () => {
|
|
for (const source of ['storm', 'shipment', 'worm'] as const)
|
|
for (const bg of [false, true]) {
|
|
const f = fixture();
|
|
const g =
|
|
source === 'storm'
|
|
? storm(f)
|
|
: source === 'shipment'
|
|
? shipment(f)
|
|
: naturalWorm(f);
|
|
const key = source === 'shipment' ? shipmentKey : 'red_chasm:7';
|
|
const corrupt: ((g: Game) => void)[] = [
|
|
(s) => {
|
|
player(s, 'f').forces[key] = -1;
|
|
},
|
|
(s) => {
|
|
player(s, 'f').forces[key] = 1.5;
|
|
},
|
|
(s) => {
|
|
player(s, 'f').elites!.forces[key] = 99;
|
|
},
|
|
(s) => {
|
|
player(s, 'f').elites!.tanks = -1;
|
|
},
|
|
(s) => {
|
|
player(s, 'f').tanks = Number.MAX_SAFE_INTEGER;
|
|
},
|
|
];
|
|
if (source === 'storm')
|
|
corrupt.push(
|
|
(s) => {
|
|
s.stormResolution = null;
|
|
},
|
|
(s) => {
|
|
s.stormResolution!.from = -1;
|
|
},
|
|
(s) => {
|
|
s.stormResolution!.distance = 1.5;
|
|
},
|
|
(s) => {
|
|
s.stormResolution!.traversed = 2;
|
|
},
|
|
);
|
|
if (source === 'shipment')
|
|
corrupt.push(
|
|
(s) => {
|
|
s.response!.elite = 99;
|
|
},
|
|
(s) => {
|
|
s.response!.amount = 99;
|
|
},
|
|
(s) => {
|
|
s.response!.location = 'red_chasm:8';
|
|
},
|
|
(s) => {
|
|
s.response!.resume = undefined;
|
|
},
|
|
);
|
|
if (source === 'worm')
|
|
corrupt.push(
|
|
(s) => {
|
|
s.spiceResolution = null;
|
|
s.spiceSequence = null;
|
|
},
|
|
(s) => {
|
|
s.spiceSequence!.pile = 2 as 0;
|
|
},
|
|
(s) => {
|
|
s.response!.location = 'arrakeen';
|
|
},
|
|
(s) => {
|
|
s.phase = 5;
|
|
},
|
|
);
|
|
if (source !== 'shipment') {
|
|
noField(g);
|
|
corrupt.push(
|
|
(s) => {
|
|
player(s, 'r').noField!.deployed!.controller = 'e';
|
|
},
|
|
(s) => {
|
|
player(s, 'r').noField!.tokens.pop();
|
|
},
|
|
(s) => {
|
|
player(s, 'r').reserves = -1;
|
|
},
|
|
);
|
|
}
|
|
for (const change of corrupt) {
|
|
const bad = reload(g);
|
|
change(bad);
|
|
reject(bad, f, bg);
|
|
}
|
|
}
|
|
});
|
|
void test('an already paid BG cancellation rechecks current typed casualty custody before allowing its suffix', () => {
|
|
for (const source of ['storm', 'shipment', 'worm'] as const) {
|
|
const f = fixture();
|
|
const key = source === 'shipment' ? shipmentKey : 'red_chasm:7';
|
|
const original =
|
|
source === 'storm'
|
|
? storm(f)
|
|
: source === 'shipment'
|
|
? shipment(f)
|
|
: naturalWorm(f);
|
|
const g = cancel(original, f, true);
|
|
player(g, 'f').elites!.forces[key] = 99;
|
|
g.response!.passed = g.players.filter((p) => p.id !== 'e').map((p) => p.id);
|
|
const before = structuredClone(g);
|
|
assert.throws(() => act(g, 'e', { type: 'passResponse' }));
|
|
assert.deepEqual(g, before);
|
|
assert.equal(g.discard.filter((c) => c.id === f.worthless).length, 1);
|
|
}
|
|
});
|
|
|
|
void test('valid concealed No-Fields reveal only available reserves before a canceled storm or worm destroys them', () => {
|
|
for (const source of ['storm', 'worm'] as const)
|
|
for (const bg of [false, true]) {
|
|
const f = fixture();
|
|
noField(f.g);
|
|
const richese = player(f.g, 'r');
|
|
richese.reserves = 2;
|
|
richese.tanks = 18;
|
|
let g = source === 'storm' ? storm(f) : naturalWorm(f);
|
|
g = settledCancel(g, f, bg);
|
|
assert.equal(player(g, 'r').noField?.deployed, null);
|
|
assert.equal(player(g, 'r').reserves, 0);
|
|
assert.equal(player(g, 'r').tanks, 20);
|
|
assert.equal(player(g, 'r').forces['red_chasm:7'], undefined);
|
|
assert.equal(
|
|
g.log.filter((entry) => entry.text.includes('revealed the 3 No-Field'))
|
|
.length,
|
|
1,
|
|
);
|
|
}
|
|
});
|
|
void test('a summoned worm before any natural spice draw supports both cancellation forms and restores its original pre-blow parent', () => {
|
|
for (const bg of [false, true]) {
|
|
const f = fixture();
|
|
f.g.phase = 1;
|
|
f.g.turn = 2;
|
|
f.g.storm = 18;
|
|
f.g.nexus = false;
|
|
f.g.stormPending = null;
|
|
const deck = structuredClone(f.g.spiceDeck);
|
|
let g = act(f.g, 'f', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: f.special,
|
|
territory: 'red_chasm',
|
|
});
|
|
assert.equal(g.response?.kind, 'wormSurvival');
|
|
assert.ok(g.summonedWorm);
|
|
assert.equal(g.spiceSequence, null);
|
|
g = settledCancel(g, f, bg);
|
|
assert.equal(g.summonedWorm, null);
|
|
assert.equal(g.nexus, false);
|
|
assert.equal(g.summonedBeforeBlow, true);
|
|
assert.equal(player(g, 'f').tanks, 4);
|
|
assert.deepEqual(g.spiceDeck, deck);
|
|
assert.equal(g.discard.filter((card) => card.id === f.special).length, 1);
|
|
}
|
|
});
|
|
|
|
void test('malformed natural resolution and summoned-parent ride restoration reject before cancellation cost', () => {
|
|
for (const bg of [false, true]) {
|
|
const natural = fixture();
|
|
const broken = naturalWorm(natural);
|
|
broken.spiceSequence = null;
|
|
broken.spiceResolution!.skipped = null as unknown as NonNullable<
|
|
Game['spiceResolution']
|
|
>['skipped'];
|
|
reject(broken, natural, bg);
|
|
const summoned = fixture();
|
|
summoned.g.phase = 1;
|
|
summoned.g.turn = 2;
|
|
summoned.g.storm = 18;
|
|
summoned.g.nexus = false;
|
|
summoned.g.stormPending = null;
|
|
const source = act(summoned.g, 'f', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: summoned.special,
|
|
territory: 'red_chasm',
|
|
});
|
|
source.summonedWorm!.resume.wormRides = null as unknown as string[];
|
|
reject(source, summoned, bg);
|
|
}
|
|
});
|
|
|
|
void test('natural worms from printed rock-territory spice cards support both cancellation forms', () => {
|
|
for (const bg of [false, true]) {
|
|
const f = fixture();
|
|
const key = 'sihaya_ridge:9';
|
|
player(f.g, 'f').forces = { [key]: 4 };
|
|
player(f.g, 'f').elites!.forces = { [key]: 2 };
|
|
player(f.g, 'e').forces = { [key]: 2 };
|
|
player(f.g, 'e').reserves = 18;
|
|
let g = naturalWorm(f, false, false, 'sihaya_ridge');
|
|
assert.equal(g.response?.location, 'sihaya_ridge');
|
|
g = settledCancel(g, f, bg);
|
|
assert.equal(player(g, 'f').tanks, 4);
|
|
assert.equal(player(g, 'f').elites?.tanks, 2);
|
|
assert.equal(player(g, 'e').tanks, 2);
|
|
assert.equal(player(g, 'f').forces[key], undefined);
|
|
}
|
|
});
|