Files
Dune/tests/richese-no-field-engine.test.ts
T

412 lines
13 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
createGame,
newPlayer,
applyAction,
viewGame,
battles,
normalizeAutomaticGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import {
createRicheseNoField,
deployRicheseNoField,
} from '../game/richese-no-field';
import { presenceAt } from '../game/force-presence';
import { TERRITORIES } from '../game/board';
const ids = ['opaque-a', 'opaque-b', 'opaque-c'];
function fixture(advanced = false) {
const g = createGame(
'NOFIELD2',
newPlayer('r', 'Richese', 'richese'),
advanced,
['choam'],
);
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.order = ['r', 'h'];
g.active = 'r';
g.storm = 18;
g.deck = baseDeck();
g.discard = [];
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.spice = 10;
p.hand = [];
p.traitorChoices = [];
p.traitors = [];
}
g.players[0].noField = createRicheseNoField(ids);
g.players[0].noFieldEvent = 'initial-event';
return g;
}
function send(g: Game, id: string, a: Action) {
return applyAction(g, id, a);
}
function ship(
g: Game,
value: 0 | 3 | 5 = 5,
territory = 'arrakeen',
sector = 10,
) {
return send(g, 'r', {
type: 'ship',
noField: ids[[0, 3, 5].indexOf(value)],
event: g.players[0].noFieldEvent,
territory,
sector,
});
}
function reveal(g: Game) {
return send(g, 'r', {
type: 'revealNoField',
event: g.players[0].noFieldEvent,
token: g.players[0].noField!.deployed!.tokenId,
});
}
function conserve(g: Game) {
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
}
function marker(
g: Game,
value: 0 | 3 | 5 = 5,
territory = 'arrakeen',
sector = 10,
) {
g.players[0].noField = deployRicheseNoField(g.players[0].noField!, {
tokenId: ids[[0, 3, 5].indexOf(value)],
controller: 'r',
location: { territory, sector },
});
return g;
}
void test('own No-Field pays one-force price and keeps actual reserves and public denomination secret', () => {
for (const value of [0, 3, 5] as const) {
const before = fixture();
const g = ship(before, value);
conserve(g);
assert.equal(g.players[0].spice, 9);
assert.equal(g.players[0].reserves, 20);
assert.deepEqual(g.players[0].forces, {});
assert.equal(presenceAt(g.players[0], 'arrakeen'), 1);
assert.equal(g.response, null);
assert.equal(g.players[0].shipped, true);
assert.equal(before.players[0].noField!.deployed, null);
const other = viewGame(g, 'h');
assert.equal(other.richeseNoField!.private, null);
assert.equal(
JSON.stringify(other).includes(ids[[0, 3, 5].indexOf(value)]),
false,
);
assert.equal(other.players[0].noField!.deployed!.effectiveForces, 1);
}
});
void test('Karama cancellation leaves price, supply and history untouched and ordinary shipment available', () => {
const before = fixture();
const k = before.deck.findIndex((c) => c.effect === 'karama');
before.players[1].hand.push(...before.deck.splice(k, 1));
let g = ship(before);
assert.equal(g.response?.kind, 'richeseNoField');
assert.equal(g.players[0].spice, 10);
assert.equal(g.players[0].noField!.deployed, null);
g = send(JSON.parse(JSON.stringify(g)), 'h', {
type: 'card',
mode: 'cancel',
card: g.players[1].hand[0].id,
});
assert.equal(g.players[0].noField!.lastShipped, null);
assert.equal(g.players[0].shipped, false);
assert.equal(g.players[0].spice, 10);
assert.throws(() => ship(g), /prevented No-Field/);
g = send(g, 'r', {
type: 'ship',
amount: 2,
territory: 'arrakeen',
sector: 10,
});
assert.equal(g.players[0].forces['arrakeen:10'], 2);
conserve(g);
});
void test('voluntary reveal materializes only remaining reserves, including zero, and preserves no-repeat history', () => {
for (const value of [0, 3, 5] as const) {
let g = fixture();
g.players[0].reserves = 2;
g.players[0].tanks = 18;
g = ship(g, value);
const event = g.players[0].noFieldEvent;
g = reveal(g);
assert.equal(g.players[0].forces['arrakeen:10'] ?? 0, Math.min(value, 2));
assert.equal(
g.players[0].noField!.lastShipped,
ids[[0, 3, 5].indexOf(value)],
);
conserve(g);
assert.throws(
() =>
send(g, 'r', {
type: 'revealNoField',
event,
token: ids[[0, 3, 5].indexOf(value)],
}),
/No concealed/,
);
g.players[0].shipped = false;
assert.throws(() => ship(g, value), /twice in a row/);
}
});
void test('marker-only and mixed physical movement preserve physical conservation and rotate stale selection events', () => {
let g = marker(fixture(), 5, 'imperial_basin', 10);
g.players[0].forces = { 'imperial_basin:10': 2 };
g.players[0].reserves = 18;
const event = g.players[0].noFieldEvent;
g = send(g, 'r', {
type: 'move',
forces: { 'imperial_basin:10': 2 },
noField: ids[2],
event,
territory: 'arrakeen',
sector: 10,
});
assert.deepEqual(g.players[0].forces, { 'arrakeen:10': 2 });
assert.equal(presenceAt(g.players[0], 'arrakeen'), 3);
assert.equal(g.players[0].reserves, 18);
conserve(g);
assert.notEqual(g.players[0].noFieldEvent, event);
let single = marker(fixture(), 0, 'imperial_basin', 10);
single = send(single, 'r', {
type: 'move',
forces: {},
noField: ids[0],
event: single.players[0].noFieldEvent,
territory: 'arrakeen',
sector: 10,
});
assert.deepEqual(single.players[0].forces, {});
assert.equal(single.players[0].moved, 1);
conserve(single);
});
void test('No-Field occupancy blocks a third stronghold faction but admits allied reserve visitors with ending losses', () => {
for (const advanced of [false, true]) {
let g = marker(fixture(advanced));
g.players.push(newPlayer('e', 'Emperor', 'emperor'));
g.players[2].forces = { 'arrakeen:10': 1 };
g.players[2].reserves = 19;
g.active = 'h';
const before = structuredClone(g);
assert.throws(
() => send(g, 'h', {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 1,
}),
/three occupying factions/,
);
assert.deepEqual(g, before);
g.players.pop();
g.players[0].ally = 'h';
g.players[1].ally = 'r';
g.movementRemaining = ['h', 'r'];
g = normalizeAutomaticGame(g);
assert.equal(g.decision?.kind, 'richeseAllyOpportunity');
g = send(g, 'r', { type: 'decision', decline: true });
// Conserved pre-existing ally counters distinguish Basic visitor losses
// from Advanced separation of the actor's entire shared group.
g.players[1].forces['arrakeen:10'] = 2;
g.players[1].reserves = 18;
g = send(g, 'h', {
type: 'ship', territory: 'arrakeen', sector: 10, amount: 1,
});
assert.equal(g.players[1].forces['arrakeen:10'], 3);
assert.equal(g.players[1].reserves, 17);
assert.equal(g.players[1].spice, 9);
assert.ok(g.players[0].noField!.deployed);
conserve(g);
g = send(g, 'h', { type: 'endMovement' });
assert.equal(g.players[1].forces['arrakeen:10'] ?? 0, advanced ? 0 : 2);
assert.equal(g.players[1].tanks, advanced ? 3 : 1);
assert.equal(g.players[1].reserves, 17);
assert.ok(g.players[0].noField!.deployed);
assert.equal(g.players[0].reserves, 20);
conserve(g);
}
});
void test('marker-only collection treats every secret value as one collector, without reserve transfer', () => {
const target = TERRITORIES.find(
(t) =>
t.type === 'sand' &&
t.id !== 'imperial_basin' &&
t.sectors.some((s) => s !== 18),
)!;
const sector = target.sectors.find((s) => s !== 18)!;
for (const value of [0, 3, 5] as const) {
let g = marker(fixture(), value, target.id, sector);
g.phase = 5;
g.active = 'r';
g.movementRemaining = ['r'];
g.spice[`${target.id}:${sector}`] = 9;
g.ready = [];
g = send(g, 'r', { type: 'endMovement' });
assert.equal(g.phase, 7);
assert.equal(g.players[0].spice, 12);
assert.equal(g.spice[`${target.id}:${sector}`], 7);
assert.equal(g.players[0].reserves, 20);
conserve(g);
}
});
void test('storm exposure reveals exact marker sector and loses only actual available physical units', () => {
const target = TERRITORIES.find(
(t) =>
t.type === 'sand' && t.id !== 'imperial_basin' && t.sectors.includes(1),
)!;
let g = marker(fixture(), 5, target.id, 1);
g.players[0].reserves = 2;
g.players[0].tanks = 18;
g.phase = 0;
g.storm = 18;
g.stormPending = 1;
g.ready = [];
g = send(g, 'r', { type: 'ready' });
g = send(g, 'h', { type: 'ready' });
assert.equal(g.players[0].noField!.deployed, null);
assert.equal(
g.players[0].noField!.tokens.find(
(t) => t.id === g.players[0].noField!.lastShipped,
)!.value,
5,
);
assert.equal(g.players[0].tanks, 20);
assert.equal(g.players[0].reserves, 0);
assert.deepEqual(g.players[0].forces, {});
conserve(g);
});
void test('all hidden values can seal marker-only battles, reveal together, and retain a zero-token battle', () => {
for (const value of [0, 3, 5] as const) {
let g = marker(fixture(), value);
g.players[1].forces = { 'arrakeen:10': 1 };
g.players[1].reserves = 19;
g.phase = 6;
g.active = 'r';
assert.equal(battles(g).length, 1);
g = send(g, 'r', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'h',
});
for (const id of ['r', 'h'])
g = send(g, id, {
type: 'battlePreparationReady',
event: g.battle!.preLeader!.event,
});
assert.deepEqual(g.battle!.noFieldPlayers, ['r']);
assert.equal(viewGame(g, 'r').battle!.ownForces!.normal, value);
assert.equal(viewGame(g, 'h').battle!.ownForces!.normal, 1);
g = send(g, 'r', {
type: 'battlePlan',
dial: value,
leader: g.players[0].leaders[0].id,
});
assert.ok(g.players[0].noField!.deployed);
assert.equal(g.players[0].reserves, 20);
g = send(g, 'h', {
type: 'battlePlan',
dial: 1,
leader: g.players[1].leaders[0].id,
});
assert.equal(g.battle!.revealed, true);
assert.equal(g.players[0].noField!.deployed, null);
assert.equal(g.players[0].reserves, 20 - value);
assert.equal(g.players[0].forces['arrakeen:10'] ?? 0, value);
conserve(g);
g = send(g, 'r', { type: 'traitorCall', call: false });
g = send(g, 'h', { type: 'traitorCall', call: false });
conserve(g);
assert.deepEqual(
normalizeAutomaticGame(JSON.parse(JSON.stringify(g))),
JSON.parse(JSON.stringify(g)),
);
}
});
void test('Gamont reveals the selected No-Field sector and returns at most one actual force, even for zero', () => {
for (const value of [0, 3, 5] as const)
for (const ordinary of [0, 2]) {
let g = marker(fixture(), value);
const c = newPlayer('c', 'CHOAM', 'choam');
g.players.push(c);
g.order.push('c');
g.phase = 8;
g.active = null;
const r = g.players[0];
r.forces = ordinary ? { 'arrakeen:10': ordinary } : {};
r.reserves = 2;
r.tanks = 18 - ordinary;
const i = g.deck.findIndex((c) => c.name === 'Trip to Gamont');
c.hand.push(...g.deck.splice(i, 1));
const card = c.hand[0].id;
const option = viewGame(g, 'c').choamWorthless!.gamont.find(
(o) => o.target === 'r' && o.key === 'arrakeen:10',
)!;
assert.equal(option.noField, true);
g = send(g, 'c', {
type: 'card',
mode: 'choam',
card,
target: 'r',
from: 'arrakeen:10',
elite: 0,
});
const actual = ordinary + Math.min(value, 2),
returned = actual > 0 ? 1 : 0;
assert.equal(g.players[0].noField!.deployed, null);
assert.equal(g.players[0].forces['arrakeen:10'] ?? 0, actual - returned);
assert.equal(g.players[0].reserves, 2 - Math.min(value, 2) + returned);
assert.ok(g.discard.some((c) => c.id === card));
conserve(g);
}
});
void test('Gamont cancellation conceals the token; stale restored marker custody aborts without using the card', () => {
for (const cancel of [true, false]) {
let g = marker(fixture(), 5);
g.phase = 8;
g.active = null;
const c = newPlayer('c', 'CHOAM', 'choam');
g.players.push(c);
g.order.push('c');
const i = g.deck.findIndex((c) => c.name === 'Trip to Gamont');
c.hand.push(...g.deck.splice(i, 1));
const card = c.hand[0].id;
const k = g.deck.findIndex((c) => c.effect === 'karama');
g.players[1].hand.push(...g.deck.splice(k, 1));
const karama = g.players[1].hand[0].id;
g = send(g, 'c', {
type: 'card',
mode: 'choam',
card,
target: 'r',
from: 'arrakeen:10',
});
assert.equal(g.response?.kind, 'choamWorthless');
assert.ok(g.players[0].noField!.deployed);
assert.equal(g.players[0].reserves, 20);
g = JSON.parse(JSON.stringify(g));
if (cancel)
g = send(g, 'h', { type: 'card', mode: 'cancel', card: karama });
else {
g.players[0].noFieldEvent = 'different-incarnation';
g = send(g, 'h', { type: 'passResponse' });
}
assert.ok(g.players[0].noField!.deployed);
assert.equal(g.players[0].reserves, 20);
assert.ok(g.players[2].hand.some((c) => c.id === card));
conserve(g);
}
});