Files
Dune/tests/karama-movement-cancellation.test.ts
T

633 lines
19 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import {
TERRITORIES,
distance,
location,
splitLocation,
mobileRoutes,
MOBILE_LOCATION,
} from '../game/board';
import {
quoteMovementCancellation,
MovementCancellationError,
} from '../game/karama-movement-cancellation';
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(faction: 'guild' | 'ixians' | 'fremen' = 'ixians') {
const g = createGame(
'MOVECANCEL',
newPlayer('b', 'Sisterhood', 'beneGesserit'),
true,
faction === 'ixians' ? ['ix'] : [],
);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('p', 'Mover', faction),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: 'p',
order: ['e', 'p', 'b'],
movementRemaining: ['e', 'p', 'b'],
deck: baseDeck(),
lastBattle: ['e', 'p'],
phaseOpening: null,
});
for (const p of g.players)
Object.assign(p, {
hand: [],
forces: {},
reserves: 20,
tanks: 0,
spice: 20,
traitors: [],
traitorChoices: [],
});
for (const [id, kind] of [
['b', 'worthless'],
['e', 'karama'],
] as const) {
const at = g.deck.findIndex((c) => c.kind === kind || c.effect === kind);
assert.ok(at >= 0);
player(g, id).hand.push(g.deck.splice(at, 1)[0]);
}
return g;
}
function ready(g: Game) {
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function enterMovement(g: Game) {
g.phase = 4;
g.active = null;
g.ready = [];
g = ready(g);
if (g.phaseOpening) g = ready(g);
assert.equal(g.phase, 5);
return g;
}
function destination() {
const to = TERRITORIES.filter((t) => t.type === 'sand')
.flatMap((t) => t.sectors.map((s) => location(t.id, s)))
.find(
(key) =>
distance('red_chasm:7', key, (k) => splitLocation(k).sector === 18) ===
2,
);
assert.ok(to);
return splitLocation(to);
}
type Case =
| 'guildFirst'
| 'guildLast'
| 'ix'
| 'fremen'
| 'mobile'
| 'advisorFollowup'
| 'advisorDeclaration'
| 'advisorRemaining';
function opportunity(kind: Case) {
if (kind === 'guildFirst' || kind === 'guildLast') {
let g = fixture('guild');
if (kind === 'guildLast') g.order = ['p', 'e', 'b'];
g = enterMovement(g);
assert.equal(g.decision?.kind, 'guildTiming');
return applyAction(g, 'p', {
type: 'decision',
take: kind === 'guildFirst',
});
}
if (kind === 'ix' || kind === 'fremen') {
let g = fixture(kind === 'ix' ? 'ixians' : 'fremen');
player(g, 'p').forces = { 'red_chasm:7': 4 };
player(g, 'p').reserves = 16;
player(g, 'p').elites = {
forces: { 'red_chasm:7': 1 },
tanks: 0,
reserves: kind === 'ix' ? 6 : 2,
revived: 0,
};
const to = destination();
g = applyAction(g, 'p', {
type: 'move',
from: 'red_chasm:7',
amount: 4,
elite: 1,
territory: to.territory,
sector: to.sector,
});
assert.equal(
g.response?.kind,
kind === 'ix' ? 'ixMovement' : 'fremenMovement',
);
return g;
}
if (kind === 'mobile') {
let g = fixture();
g.phase = 8;
g.turn = 1;
g.active = null;
g.mobileStronghold = { location: 'polar_sink:0' };
player(g, 'p').forces = { [MOBILE_LOCATION]: 6 };
player(g, 'p').reserves = 14;
player(g, 'p').elites = {
forces: { [MOBILE_LOCATION]: 3 },
reserves: 4,
tanks: 0,
revived: 1,
};
g = ready(g);
if (g.phaseOpening) g = ready(g);
assert.equal(g.decision?.kind, 'mobileStronghold');
const route = mobileRoutes(g, 3)[0];
assert.ok(route);
return applyAction(g, 'p', { type: 'decision', route, collect: true });
}
let g = fixture('fremen');
player(g, 'b').forces = { 'arrakeen:10': 2 };
player(g, 'b').reserves = 18;
if (kind === 'advisorFollowup') {
g.active = 'e';
g = applyAction(g, 'e', {
type: 'ship',
territory: 'arrakeen',
sector: 10,
amount: 2,
});
assert.equal(g.decision?.kind, 'intrusion');
return applyAction(g, 'b', { type: 'decision', accept: true });
}
player(g, 'b').advisors = { arrakeen: {} };
player(g, 'e').forces = { 'arrakeen:10': 2 };
player(g, 'e').reserves = 18;
if (kind === 'advisorRemaining') {
player(g, 'b').forces['carthag:11'] = 1;
player(g, 'b').reserves--;
player(g, 'b').advisors!.carthag = {};
player(g, 'e').forces['carthag:11'] = 1;
player(g, 'e').reserves--;
}
g = enterMovement(g);
assert.equal(g.decision?.kind, 'advisorBattle');
return applyAction(g, 'b', {
type: 'decision',
accept: true,
territory: 'arrakeen',
});
}
function quote(g: Game) {
assert.ok(g.response);
return quoteMovementCancellation(g, g.response);
}
function cancel(g: Game, bg = false) {
const id = bg ? 'b' : 'e',
card = player(g, id).hand[0].id;
let done = applyAction(g, id, { type: 'card', card, mode: 'cancel' });
if (bg) {
assert.equal(done.response?.kind, 'worthlessKarama');
while (done.response?.kind === 'worthlessKarama') {
const next = done.players.find(
(p) => !done.response!.passed.includes(p.id),
)!;
done = applyAction(done, next.id, { type: 'passResponse' });
}
}
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
return done;
}
for (const kind of [
'guildFirst',
'guildLast',
'ix',
'fremen',
'mobile',
'advisorFollowup',
'advisorDeclaration',
'advisorRemaining',
] as const)
void test(`pure ${kind} cancellation quote follows genuine declaration without mutation, hands or random effects`, (t) => {
const g = opportunity(kind),
before = structuredClone(g);
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw Error('Pure quote sampled randomness');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Pure quote created UUID');
});
const result = quote(g);
assert.ok(result);
assert.deepEqual(quote(g), result);
assert.deepEqual(g, before);
for (const p of g.players)
Object.defineProperty(p, 'hand', {
get() {
throw Error('Read private hand');
},
});
assert.deepEqual(quote(g), result);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
for (const bg of [false, true])
for (const kind of [
'guildFirst',
'guildLast',
'ix',
'fremen',
'mobile',
] as const)
void test(`${bg ? 'BG' : 'printed'} cancellation commits only the quoted ${kind} flag or successor`, () => {
const g = opportunity(kind),
result = quote(g)!,
before = structuredClone(g);
const done = cancel(reload(g), bg);
assert.deepEqual(g, before);
assert.deepEqual(
done.players.map((p) => ({
forces: p.forces,
reserves: p.reserves,
tanks: p.tanks,
spice: p.spice,
moved: p.moved,
})),
g.players.map((p) => ({
forces: p.forces,
reserves: p.reserves,
tanks: p.tanks,
spice: p.spice,
moved: p.moved,
})),
);
if (result.kind === 'guildTiming') {
assert.equal(done.active, result.active);
assert.equal(done.guildTimingLocked, true);
assert.deepEqual(done.movementRemaining, g.movementRemaining);
} else if (
result.kind === 'ixMovement' ||
result.kind === 'fremenMovement'
) {
assert.deepEqual(
result.kind === 'ixMovement'
? player(done, 'p').ixMovementBlocked
: player(done, 'p').fremenMovementBlocked,
{ turn: g.turn, move: player(g, 'p').moved },
);
assert.equal(
result.kind === 'ixMovement'
? done.pendingIxMove
: done.pendingFremenMove,
null,
);
} else {
assert.equal(done.pendingMobileMove, null);
assert.deepEqual(done.mobileStronghold, g.mobileStronghold);
assert.equal(done.turn, g.turn);
assert.equal(done.phase, 0);
assert.ok(
Number.isInteger(done.stormPending) &&
done.stormPending! >= 1 &&
done.stormPending! <= 6,
);
assert.equal(done.stormMovementSource?.kind, 'card');
assert.equal(done.stormMovementSource?.turn, done.turn);
assert.equal(done.stormMovementSource?.distance, done.stormPending);
for (const dialer of g.lastBattle)
assert.throws(
() => applyAction(done, dialer, { type: 'stormDial', amount: 1 }),
/not dialing/,
);
assert.equal(player(done, 'p').elites!.revived, 0);
assert.equal(
done.log.filter((l) => l.text === `Turn ${g.turn} begins.`).length,
1,
);
}
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
});
for (const kind of [
'advisorFollowup',
'advisorDeclaration',
'advisorRemaining',
] as const)
void test(`printed ${kind} cancellation preserves stance and opens only its quoted next choice`, () => {
const g = opportunity(kind),
result = quote(g)!;
const done = cancel(g);
assert.deepEqual(player(done, 'b').advisors, player(g, 'b').advisors);
assert.equal(result.kind, 'advisorFlip');
if (result.kind !== 'advisorFlip') throw Error('Wrong quote');
if (result.successor === 'advisor' || result.successor === 'advisorBattle')
assert.equal(done.decision?.kind, result.successor);
else {
assert.equal(done.decision, null);
assert.equal(done.active, g.movementRemaining![0]);
}
});
void test('canceled Ix move remains accepted when its uncommitted source or route is no longer usable', () => {
const g = opportunity('ix');
player(g, 'p').reserves += 4;
player(g, 'p').forces = {};
player(g, 'p').elites!.reserves++;
player(g, 'p').elites!.forces = {};
g.storm = splitLocation(g.response!.location!).sector;
assert.equal(quote(g)?.kind, 'ixMovement');
const done = cancel(g, true);
assert.equal(player(done, 'p').moved, 0);
assert.deepEqual(player(done, 'p').forces, {});
});
void test('future advisor shipment and remaining declaration choices are not pre-accepted by cancellation', () => {
const g = opportunity('advisorFollowup');
g.storm = 10;
assert.deepEqual(quote(g), { kind: 'advisorFlip', successor: 'advisor' });
const done = cancel(g);
assert.equal(done.decision?.kind, 'advisor');
const remaining = opportunity('advisorRemaining');
player(remaining, 'b').advisors!.carthag.lockedTurn = remaining.turn;
assert.deepEqual(quote(remaining), {
kind: 'advisorFlip',
successor: 'advisorBattle',
});
assert.equal(cancel(remaining).decision?.kind, 'advisorBattle');
});
void test('mobile cancellation never quotes a sampled replacement for a missing Fremen storm card', (t) => {
const g = opportunity('mobile');
g.players.push(newPlayer('f', 'Fremen', 'fremen'));
g.stormCard = null;
const before = structuredClone(g);
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw Error('Sampled future storm card');
});
assert.deepEqual(quote(g), {
kind: 'mobileStronghold',
successor: 'beginStormTurn',
});
assert.deepEqual(g, before);
assert.equal(random.mock.callCount(), 0);
});
void test('worm-ride and empty-queue phase-completion advisor chains are explicitly outside this bounded quote', () => {
const worm = opportunity('advisorFollowup');
worm.phase = 1;
worm.response!.advisorResume = 'wormRide';
delete worm.response!.advisorFollowup;
assert.equal(quote(worm), null);
const last = opportunity('advisorDeclaration');
last.movementRemaining = [];
assert.equal(quote(last), null);
});
for (const kind of [
'guildFirst',
'ix',
'fremen',
'mobile',
'advisorFollowup',
'advisorDeclaration',
] as const)
void test(`malformed ${kind} owner, phase and family-specific bindings reject the pure quote atomically`, () => {
const original = opportunity(kind);
const mutations: ((g: Game) => void)[] = [
(g) => {
g.response!.owner = 'missing';
},
(g) => {
g.phase = 7;
},
];
if (kind === 'guildFirst')
mutations.push(
(g) => {
g.movementRemaining = ['unseated'];
},
(g) => {
g.response!.take = false;
},
);
else if (kind === 'ix')
mutations.push(
(g) => {
g.pendingIxMove!.total++;
},
(g) => {
g.response!.elite = 99;
},
);
else if (kind === 'fremen')
mutations.push(
(g) => {
g.pendingFremenMove!.turn++;
},
(g) => {
g.pendingFremenMove!.move++;
},
);
else if (kind === 'mobile')
mutations.push(
(g) => {
g.pendingMobileMove!.route = ['polar_sink:0', 'red_chasm:7'];
},
(g) => {
g.stormCard = 99;
},
);
else if (kind === 'advisorFollowup')
mutations.push(
(g) => {
g.response!.advisorFollowup!.shipment = 'missing';
},
(g) => {
g.response!.advisorFollowup!.destination = 'arrakeen:00';
},
);
else
mutations.push((g) => {
g.response!.advisorRemaining = ['missing'];
});
for (const mutate of mutations) {
const g = reload(original);
mutate(g);
const before = structuredClone(g);
assert.throws(() => quote(g), MovementCancellationError);
assert.deepEqual(g, before);
}
});
void test('Basic Fremen and a second Hajr Ix movement bind the current unspent move index', () => {
const basic = fixture('fremen');
basic.advanced = false;
player(basic, 'p').forces = { 'red_chasm:7': 4 };
player(basic, 'p').reserves = 16;
const to = destination();
const response = applyAction(basic, 'p', {
type: 'move',
from: 'red_chasm:7',
amount: 4,
territory: to.territory,
sector: to.sector,
});
assert.deepEqual(quote(response), {
kind: 'fremenMovement',
player: 'p',
turn: 2,
move: 0,
});
assert.equal(player(cancel(response), 'p').moved, 0);
const second = opportunity('ix');
second.hajr = ['p'];
player(second, 'p').moved = 1;
assert.deepEqual(quote(second), {
kind: 'ixMovement',
player: 'p',
turn: 2,
move: 1,
});
assert.deepEqual(player(cancel(second), 'p').ixMovementBlocked, {
turn: 2,
move: 1,
});
});
void test('Fremen canonical order validation still rejects lost custody before either cancellation cost', () => {
for (const bg of [false, true]) {
const g = opportunity('fremen');
player(g, 'p').forces = {};
player(g, 'p').elites!.forces = {};
const before = structuredClone(g);
// This helper validates declaration shape; the existing engine additionally
// validates actual Fremen order custody/routes, unlike canceled Ix movement.
assert.equal(quote(g)?.kind, 'fremenMovement');
assert.throws(() => cancel(g, bg));
assert.deepEqual(g, before);
}
});
void test('canceled Guild timing retains the protected Sapho-last queue verbatim', () => {
const g = opportunity('guildFirst');
// Current public queue protection established before the Guild response;
// exercise the adapter without guessing a new first-versus-Guild ruling.
g.saphoMovementLast = {
turn: g.turn,
event: `movement:${g.turn}`,
player: 'b',
};
assert.deepEqual(quote(g), { kind: 'guildTiming', active: 'e' });
const done = cancel(g);
assert.deepEqual(done.saphoMovementLast, g.saphoMovementLast);
assert.deepEqual(done.movementRemaining, g.movementRemaining);
assert.equal(done.active, 'e');
});
// Invalid saved opportunities are constructed from genuine declarations. No
// signature is edited: these exercise the semantic check before the first cost.
for (const kind of [
'guildFirst',
'ix',
'fremen',
'mobile',
'advisorFollowup',
] as const)
for (const bg of [false, true]) {
if (kind === 'advisorFollowup' && bg) continue; // The owner cannot cancel itself.
void test(`${bg ? 'BG' : 'printed'} ${kind} rejects invalid live cancellation context before consuming its card`, (t) => {
const original = opportunity(kind);
const mutations: ((g: Game) => void)[] = [
(g) => {
g.phase = 7;
},
(g) => {
g.response!.owner = 'missing';
},
];
if (kind === 'guildFirst')
mutations.push(
(g) => {
g.movementRemaining = [];
},
(g) => {
g.movementRemaining = ['e', 'e', 'p'];
},
(g) => {
g.response!.take = false;
},
);
else if (kind === 'ix')
mutations.push(
(g) => {
g.pendingIxMove = null;
},
(g) => {
g.pendingIxMove!.total++;
},
(g) => {
g.response!.elite = 99;
},
);
else if (kind === 'fremen')
mutations.push(
(g) => {
g.pendingFremenMove = null;
},
(g) => {
g.pendingFremenMove!.turn++;
},
(g) => {
g.pendingFremenMove!.order.eliteGroup = {};
},
);
else if (kind === 'mobile')
mutations.push(
(g) => {
g.pendingMobileMove = null;
},
(g) => {
g.pendingMobileMove!.collect = 'yes' as unknown as boolean;
},
(g) => {
g.stormCard = 99;
},
);
else
mutations.push(
(g) => {
g.response!.advisorFollowup!.shipment = 'missing';
},
(g) => {
g.response!.advisorFollowup!.destination = 'arrakeen:00';
},
);
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw Error('Invalid cancellation sampled randomness');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Invalid cancellation generated UUID');
});
for (const mutate of mutations) {
const g = reload(original);
mutate(g);
const before = structuredClone(g),
id = bg ? 'b' : 'e',
card = player(g, id).hand[0].id;
assert.throws(() =>
applyAction(g, id, { type: 'card', card, mode: 'cancel' }),
);
assert.deepEqual(g, before);
assert.ok(player(g, id).hand.some((c) => c.id === card));
assert.equal(
g.discard.some((c) => c.id === card),
false,
);
assert.equal(g.pendingKarama, before.pendingKarama);
}
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
}