411 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
});
|