412 lines
13 KiB
TypeScript
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);
|
|
}
|
|
});
|