Files
Dune/tests/karama-no-field-cancellation.test.ts
T
2026-09-08 07:37:52 +10:00

411 lines
12 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 } from '../game/cards';
import {
createRicheseNoField,
deployRicheseNoField,
} from '../game/richese-no-field';
import {
quoteNoFieldCancellation,
NoFieldCancellationError,
} from '../game/karama-no-field-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 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 fixture(allied = false, value: 0 | 3 | 5 = 5, oldMarker = false) {
const g = createGame(
'CANCELFIELD',
newPlayer('r', 'Richese', 'richese'),
true,
['choam'],
);
g.players.push(
newPlayer('a', 'Ally', 'atreides'),
newPlayer('b', 'BG', 'beneGesserit'),
newPlayer('e', 'Emperor', 'emperor'),
);
Object.assign(g, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
active: allied ? 'a' : 'r',
order: allied ? ['a', 'r', 'b', 'e'] : ['r', 'a', 'b', 'e'],
movementRemaining: allied ? ['a', 'r', 'b', 'e'] : ['r', 'a', 'b', 'e'],
deck: baseDeck(),
phaseOpening: null,
});
for (const p of g.players)
Object.assign(p, {
hand: [],
spice: 20,
forces: {},
reserves: 20,
tanks: 0,
traitors: [],
traitorChoices: [],
});
player(g, 'r').noField = createRicheseNoField([
'opaque-zero',
'opaque-three',
'opaque-five',
]);
player(g, 'r').noFieldEvent = 'selection-2';
if (allied) {
player(g, 'r').ally = 'a';
player(g, 'a').ally = 'r';
}
if (oldMarker)
player(g, 'r').noField = deployRicheseNoField(player(g, 'r').noField!, {
tokenId: 'opaque-three',
controller: 'r',
location: { territory: 'imperial_basin', sector: 10 },
});
function 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');
return {
g,
allied,
value,
oldMarker,
worthless,
printed,
token: ['opaque-zero', 'opaque-three', 'opaque-five'][
[0, 3, 5].indexOf(value)
],
};
}
type Fixture = ReturnType<typeof fixture>;
function declare(f: Fixture) {
let g = f.g;
if (f.allied) {
g = act(g, 'r', {
type: 'offerRicheseNoField',
event: player(g, 'r').noFieldEvent,
token: f.token,
territory: 'arrakeen',
sector: 10,
payer: 'r',
});
g = act(g, 'a', {
type: 'decision',
event: g.richeseAllyOffer!.event,
accept: true,
elite: 0,
});
} else
g = act(g, 'r', {
type: 'ship',
noField: f.token,
event: player(g, 'r').noFieldEvent,
territory: 'arrakeen',
sector: 10,
});
assert.equal(g.response?.kind, 'richeseNoField');
return g;
}
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) {
g = act(g, bg ? 'b' : 'e', {
type: 'card',
card: bg ? f.worthless : f.printed,
mode: 'cancel',
});
return bg ? pass(g, 'worthlessKarama') : g;
}
function materials(g: Game) {
return g.players.map((p) => ({
id: p.id,
spice: p.spice,
reserves: p.reserves,
tanks: p.tanks,
forces: p.forces,
noField: p.noField,
noFieldEvent: p.noFieldEvent,
shipped: p.shipped,
}));
}
function privateViews(g: Game) {
for (const id of ['a', 'b', 'e']) {
const view = viewGame(g, id);
assert.equal(view.richeseNoField?.private, null);
for (const token of ['opaque-zero', 'opaque-three', 'opaque-five'])
assert.equal(JSON.stringify(view).includes(token), false);
}
}
void test('own and accepted allied declarations produce exact public canceled blocks for each physical denomination without RNG or hidden output', (t) => {
for (const allied of [false, true])
for (const value of [0, 3, 5] as const) {
const f = fixture(allied, value),
g = declare(f),
before = structuredClone(g);
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw new Error('No random work in declaration cancellation');
}),
uuid = t.mock.method(crypto, 'randomUUID', () => {
throw new Error('No marker event in declaration cancellation');
});
const quote = quoteNoFieldCancellation(g, g.response!);
assert.deepEqual(
quote,
allied
? {
kind: 'allied',
owner: 'r',
player: 'a',
blocked: { turn: 2, recipient: 'a' },
pendingShipment: null,
}
: { kind: 'own', player: 'r', blockedTurn: 2, pendingShipment: null },
);
assert.deepEqual(quoteNoFieldCancellation(reload(g), g.response!), quote);
assert.deepEqual(g, before);
assert.equal(JSON.stringify(quote).includes('opaque'), false);
assert.equal(JSON.stringify(quote).includes('arrakeen'), false);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
random.mock.restore();
uuid.mock.restore();
}
});
void test('printed and BG cancellations preserve every No-Field material and block exactly the active own or allied shipment opportunity', () => {
for (const allied of [false, true])
for (const bg of [false, true]) {
const f = fixture(allied),
source = declare(f),
before = materials(source);
const g = cancel(source, f, bg);
assert.deepEqual(materials(g), before);
assert.equal(g.pendingShipment, null);
if (allied) {
assert.deepEqual(g.richeseAllyBlocked, { turn: 2, recipient: 'a' });
assert.notEqual(player(g, 'r').noFieldBlockedTurn, 2);
} else {
assert.equal(player(g, 'r').noFieldBlockedTurn, 2);
assert.equal(g.richeseAllyBlocked, undefined);
}
privateViews(g);
assert.equal(
g.discard.filter((c) => c.id === (bg ? f.worthless : f.printed)).length,
1,
);
assert.throws(
() =>
act(
g,
'r',
allied
? {
type: 'offerRicheseNoField',
event: player(g, 'r').noFieldEvent,
token: f.token,
territory: 'arrakeen',
sector: 10,
payer: 'r',
}
: {
type: 'ship',
noField: f.token,
event: player(g, 'r').noFieldEvent,
territory: 'arrakeen',
sector: 10,
},
),
/prevented/,
);
const normal = act(g, allied ? 'a' : 'r', {
type: 'ship',
amount: 1,
territory: 'arrakeen',
sector: 10,
});
assert.equal(player(normal, allied ? 'a' : 'r').shipped, true);
}
});
void test('counter-canceling a BG conversion restores the real own or allied permission and permits one original shipment', () => {
for (const allied of [false, true]) {
const f = fixture(allied),
source = declare(f);
let g = act(source, 'b', {
type: 'card',
card: f.worthless,
mode: 'cancel',
});
assert.equal(g.response?.kind, 'worthlessKarama');
g = act(g, 'e', { type: 'card', card: f.printed, mode: 'cancel' });
g = pass(g, 'richeseNoField');
const controller = player(g, allied ? 'a' : 'r');
assert.equal(controller.shipped, true);
assert.equal(player(g, 'r').noField!.lastShipped, f.token);
assert.notEqual(player(g, 'r').noFieldBlockedTurn, 2);
assert.equal(g.richeseAllyBlocked, undefined);
if (allied) {
assert.equal(controller.forces['arrakeen:10'], 5);
assert.equal(controller.reserves, 15);
assert.equal(player(g, 'r').noField!.deployed, null);
} else {
assert.equal(controller.reserves, 20);
assert.equal(controller.noField!.deployed?.tokenId, f.token);
}
for (const card of [f.worthless, f.printed])
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
}
});
void test('denial does not read current funds, reserves, route capacity, rate or repeat-token availability and preserves an old allied marker', () => {
for (const allied of [false, true]) {
const f = fixture(allied, 5, allied),
source = declare(f),
g = reload(source);
for (const p of g.players) {
p.spice = 0;
p.reserves = 0;
}
g.storm = 10;
player(g, 'b').forces = { 'arrakeen:10': 1 };
player(g, 'e').forces = { 'arrakeen:10': 1 };
g.karamaShipping = { player: allied ? 'a' : 'r', owner: 'e' };
if (!allied) {
player(g, 'r').noField = deployRicheseNoField(player(g, 'r').noField!, {
tokenId: f.token,
controller: 'r',
location: { territory: 'imperial_basin', sector: 10 },
});
}
const before = structuredClone(g);
assert.ok(quoteNoFieldCancellation(g, g.response!));
const done = cancel(g, f, false);
assert.deepEqual(materials(done), materials(g));
assert.equal(done.karamaShipping?.player, allied ? 'a' : 'r');
assert.deepEqual(g, before);
const guarded = reload(source);
for (const p of guarded.players)
for (const key of ['spice', 'reserves', 'forces', 'elites'])
Object.defineProperty(p, key, {
get() {
throw new Error('Denied declaration must not read ' + key);
},
});
assert.ok(quoteNoFieldCancellation(guarded, guarded.response!));
}
});
void test('stale owner, controller, physical inventory, selection and opportunity bindings reject purely and before either new card cost', () => {
for (const allied of [false, true]) {
const f = fixture(allied),
g = declare(f);
const mutations: ((s: Game) => void)[] = [
(s) => {
s.phase = 6;
},
(s) => {
s.active = 'e';
},
(s) => {
player(s, allied ? 'a' : 'r').shipped = true;
},
(s) => {
s.pendingShipment!.turn = 1;
},
(s) => {
s.response!.owner = 'e';
},
(s) => {
player(s, 'r').noFieldEvent = 'changed';
},
(s) => {
player(s, 'r').noField!.tokens.pop();
},
(s) => {
s.pendingShipment!.noField = undefined;
s.pendingShipment!.alliedNoField = undefined;
},
];
if (allied)
mutations.push(
(s) => {
player(s, 'a').ally = null;
},
(s) => {
s.pendingShipment!.alliedNoField!.owner = 'a';
},
(s) => {
s.pendingShipment!.alliedNoField!.recipient = 'e';
},
(s) => {
s.pendingShipment!.alliedNoField!.tokenId = 'absent';
},
(s) => {
s.pendingShipment!.alliedNoField!.sector = 11;
},
(s) => {
s.richeseAllyOpportunity = { turn: 1, recipient: 'a' };
},
(s) => {
s.richeseAllyBlocked = { turn: 2, recipient: 'a' };
},
);
else
mutations.push(
(s) => {
s.pendingShipment!.noField!.tokenId = 'absent';
},
(s) => {
s.pendingShipment!.player = 'a';
},
(s) => {
s.pendingShipment!.amount = 3;
},
(s) => {
player(s, 'r').noFieldBlockedTurn = 2;
},
);
for (const change of mutations) {
const bad = reload(g);
change(bad);
const before = structuredClone(bad);
assert.throws(
() => quoteNoFieldCancellation(bad, bad.response!),
NoFieldCancellationError,
);
for (const bg of [false, true])
assert.throws(() =>
act(bad, bg ? 'b' : 'e', {
type: 'card',
card: bg ? f.worthless : f.printed,
mode: 'cancel',
}),
);
assert.deepEqual(bad, before);
assert.equal(bad.discard.length, 0);
}
}
});