310 lines
9.6 KiB
TypeScript
310 lines
9.6 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
normalizeAutomaticGame,
|
|
type Game,
|
|
type Action,
|
|
} from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
|
import {
|
|
createRicheseNoField,
|
|
deployRicheseNoField,
|
|
} from '../game/richese-no-field';
|
|
function fixture() {
|
|
const g = createGame(
|
|
'ORNIREVIEW',
|
|
newPlayer('r', 'Richese', 'richese'),
|
|
false,
|
|
['choam', 'ecaz'],
|
|
);
|
|
g.players.push(
|
|
newPlayer('m', 'Moritani', 'moritani'),
|
|
newPlayer('a', 'Atreides', 'atreides'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
active: 'r',
|
|
order: ['r', 'm', 'a'],
|
|
movementRemaining: ['r', 'm', 'a'],
|
|
storm: 18,
|
|
deck: baseDeck(),
|
|
richeseCache: richeseCards(),
|
|
});
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 10;
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
}
|
|
hold(g, 'r', 'Ornithopter');
|
|
g.players[0].forces = { 'imperial_basin:10': 3 };
|
|
g.players[0].reserves = 17;
|
|
return g;
|
|
}
|
|
function hold(g: Game, id: string, name: string) {
|
|
const pile = g.richeseCache!.some((c) => c.name === name)
|
|
? g.richeseCache!
|
|
: g.deck;
|
|
const i = pile.findIndex((c) => c.name === name);
|
|
assert.ok(i >= 0, name);
|
|
const c = pile.splice(i, 1)[0];
|
|
g.players.find((p) => p.id === id)!.hand.push(c);
|
|
return c.id;
|
|
}
|
|
const send = (g: Game, id: string, a: Action) => applyAction(g, id, a);
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
const first = (g: Game, extra: Partial<Action> = {}) =>
|
|
send(g, 'r', {
|
|
type: 'move',
|
|
movementCard: 'richese-ornithopter',
|
|
ornithopter: 'twoGroups',
|
|
forces: { 'imperial_basin:10': 1 },
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
...extra,
|
|
});
|
|
const second = (g: Game, extra: Partial<Action> = {}) =>
|
|
send(g, 'r', {
|
|
type: 'move',
|
|
ornithopterEvent: g.ornithopter!.event,
|
|
forces: { 'imperial_basin:10': 1 },
|
|
territory: 'carthag',
|
|
sector: 11,
|
|
...extra,
|
|
});
|
|
const inventory = (g: Game) =>
|
|
[
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.richeseCache!,
|
|
...g.players.flatMap((p) => p.hand),
|
|
...(g.ornithopter ? [g.ornithopter.card] : []),
|
|
]
|
|
.map((c) => c.id)
|
|
.sort();
|
|
|
|
void test('real Sabotage can discard an entrant hand card while Ornithopter remains in played escrow and its second group waits', () => {
|
|
let g = fixture();
|
|
const victim = hold(g, 'r', 'Shield');
|
|
const offered = hold(g, 'm', 'Snooper');
|
|
g.moritaniTerror = createTerrorState(() => 0);
|
|
const token = g.moritaniTerror.tokens.find((t) => t.kind === 'sabotage')!;
|
|
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1);
|
|
const before = inventory(g);
|
|
g = first(g);
|
|
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
|
assert.equal(g.ornithopter!.completed, 1);
|
|
assert.deepEqual(
|
|
g.players[0].hand.map((c) => c.id),
|
|
[victim],
|
|
);
|
|
const paused = structuredClone(g);
|
|
assert.throws(() => second(g));
|
|
assert.deepEqual(g, paused);
|
|
g = send(reload(g), 'm', { type: 'decision', reveal: true });
|
|
assert.equal(g.pendingTerrorEntry?.stage, 'gift');
|
|
assert.deepEqual(
|
|
g.discard.map((c) => c.id),
|
|
[victim],
|
|
);
|
|
assert.equal(g.players[0].hand.length, 0);
|
|
assert.equal(g.ornithopter!.card.id, 'richese-ornithopter');
|
|
g = send(reload(g), 'm', { type: 'decision', card: offered });
|
|
assert.equal(g.pendingTerrorEntry, null);
|
|
assert.equal(g.players[0].moved, 1);
|
|
g = second(g);
|
|
assert.equal(g.ornithopter, null);
|
|
assert.equal(g.players[0].moved, 2);
|
|
assert.equal(
|
|
g.discard.filter((c) => c.id === 'richese-ornithopter').length,
|
|
1,
|
|
);
|
|
assert.deepEqual(inventory(g), before);
|
|
});
|
|
|
|
void test('fixed card range survives a real CHOAM arrival decision and is not reduced to ordinary one-territory range on reload', () => {
|
|
let g = fixture();
|
|
g.players[1].faction = 'choam';
|
|
g.players[1].forces = { 'hagga_basin:12': 1 };
|
|
g.players[1].reserves = 19;
|
|
const before = inventory(g);
|
|
g = first(g, { ornithopter: 'range3', territory: 'hagga_basin', sector: 12 });
|
|
assert.equal(g.decision?.kind, 'choamMovement');
|
|
assert.equal(g.players[0].moved, 0);
|
|
assert.equal(g.ornithopter!.completed, 0);
|
|
assert.equal(g.pendingChoamMove!.ornithopterRange, true);
|
|
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
|
|
g = send(reload(g), 'm', { type: 'decision', decline: true });
|
|
assert.equal(g.players[0].forces['hagga_basin:12'], 1);
|
|
assert.equal(g.players[0].moved, 1);
|
|
assert.equal(g.ornithopter, null);
|
|
assert.deepEqual(inventory(g), before);
|
|
});
|
|
|
|
void test('canceling genuine Ixian normal-range reliance preserves the played card and permits a revised first group', () => {
|
|
let g = fixture();
|
|
g.advanced = true;
|
|
g.players[0].faction = 'ixians';
|
|
g.players[0].elites = {
|
|
forces: { 'imperial_basin:10': 1 },
|
|
reserves: 6,
|
|
tanks: 0,
|
|
revived: 0,
|
|
};
|
|
const k = hold(g, 'a', 'Karama');
|
|
g = first(g, {
|
|
eliteForces: { 'imperial_basin:10': 1 },
|
|
territory: 'hagga_basin',
|
|
sector: 12,
|
|
});
|
|
assert.equal(g.response?.kind, 'ixMovement');
|
|
const event = g.ornithopter!.event;
|
|
g = send(reload(g), 'a', { type: 'card', mode: 'cancel', card: k });
|
|
assert.equal(g.players[0].moved, 0);
|
|
assert.equal(g.ornithopter!.event, event);
|
|
assert.equal(g.ornithopter!.completed, 0);
|
|
g = send(g, 'r', {
|
|
type: 'move',
|
|
ornithopterEvent: event,
|
|
forces: { 'imperial_basin:10': 1 },
|
|
eliteForces: { 'imperial_basin:10': 1 },
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
});
|
|
assert.equal(g.players[0].moved, 1);
|
|
assert.equal(g.players[0].elites!.forces['arrakeen:10'], 1);
|
|
assert.equal(g.ornithopter!.cohort!.elites['imperial_basin:10'] ?? 0, 0);
|
|
g = second(reload(g));
|
|
assert.equal(g.players[0].moved, 2);
|
|
assert.equal(g.ornithopter, null);
|
|
assert.equal(
|
|
g.discard.filter((c) => c.id === 'richese-ornithopter').length,
|
|
1,
|
|
);
|
|
});
|
|
|
|
void test('unmoved No-Field revelation materializes eligible quota while a moved marker and its revealed forces never requalify', () => {
|
|
for (const moved of [false, true]) {
|
|
let g = fixture();
|
|
g.players[0].noField = createRicheseNoField([
|
|
'opaque-a',
|
|
'opaque-b',
|
|
'opaque-c',
|
|
]);
|
|
g.players[0].noField = deployRicheseNoField(g.players[0].noField, {
|
|
tokenId: 'opaque-b',
|
|
controller: 'r',
|
|
location: { territory: 'imperial_basin', sector: 10 },
|
|
});
|
|
g.players[0].noFieldEvent = 'marker-original';
|
|
g = first(
|
|
g,
|
|
moved
|
|
? { forces: {}, noField: 'opaque-b', event: 'marker-original' }
|
|
: {},
|
|
);
|
|
const event = g.ornithopter!.event;
|
|
assert.equal(!!g.ornithopter!.cohort!.noField, !moved);
|
|
g = send(reload(g), 'r', {
|
|
type: 'revealNoField',
|
|
token: 'opaque-b',
|
|
event: g.players[0].noFieldEvent,
|
|
});
|
|
if (moved) {
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() =>
|
|
second(g, {
|
|
forces: { 'arrakeen:10': 1 },
|
|
territory: 'imperial_basin',
|
|
sector: 10,
|
|
}),
|
|
/cohort/,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
} else {
|
|
assert.equal(g.ornithopter!.cohort!.forces['imperial_basin:10'], 5);
|
|
g = second(g, { forces: { 'imperial_basin:10': 5 } });
|
|
assert.equal(g.players[0].forces['carthag:11'], 5);
|
|
assert.equal(g.ornithopter, null);
|
|
}
|
|
assert.ok(event);
|
|
assert.equal(
|
|
g.players[0].reserves +
|
|
g.players[0].tanks +
|
|
Object.values(g.players[0].forces).reduce((a, b) => a + b, 0),
|
|
20,
|
|
);
|
|
}
|
|
});
|
|
|
|
void test('merged arrivals cannot increase typed original quota and a missing restored cohort must fail closed', () => {
|
|
let g = fixture();
|
|
g.players[0].forces = { 'imperial_basin:10': 2, 'arrakeen:10': 1 };
|
|
g = first(g, { forces: { 'imperial_basin:10': 2 } });
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() =>
|
|
second(g, {
|
|
forces: { 'arrakeen:10': 2 },
|
|
territory: 'imperial_basin',
|
|
sector: 10,
|
|
}),
|
|
/cohort/,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
const broken = reload(g);
|
|
delete broken.ornithopter!.cohort;
|
|
const snapshot = structuredClone(broken);
|
|
assert.throws(
|
|
() =>
|
|
second(broken, {
|
|
forces: { 'arrakeen:10': 2 },
|
|
territory: 'imperial_basin',
|
|
sector: 10,
|
|
}),
|
|
/cohort|inconsistent|custody/,
|
|
);
|
|
assert.deepEqual(broken, snapshot);
|
|
});
|
|
|
|
void test('opponent projections do not expose unmoved cohort marker identity or denomination and endMovement discards escrow once', () => {
|
|
const views = [];
|
|
for (const value of [0, 3, 5] as const) {
|
|
let g = fixture();
|
|
const ids = ['private-zero', 'private-three', 'private-five'];
|
|
g.players[0].noField = createRicheseNoField(ids);
|
|
g.players[0].noField = deployRicheseNoField(g.players[0].noField, {
|
|
tokenId: ids[[0, 3, 5].indexOf(value)],
|
|
controller: 'r',
|
|
location: { territory: 'carthag', sector: 11 },
|
|
});
|
|
g.players[0].noFieldEvent = 'marker-private';
|
|
g = first(g);
|
|
const other = viewGame(g, 'a');
|
|
assert.equal(other.ornithopter, null);
|
|
for (const id of ids)
|
|
assert.equal(JSON.stringify(other).includes(id), false);
|
|
views.push(other);
|
|
assert.ok(viewGame(g, 'r').ornithopter!.active!.cohort!.noField);
|
|
const before = inventory(g);
|
|
g = send(reload(g), 'r', { type: 'endMovement' });
|
|
assert.equal(g.ornithopter, null);
|
|
assert.equal(
|
|
g.discard.filter((c) => c.id === 'richese-ornithopter').length,
|
|
1,
|
|
);
|
|
assert.deepEqual(inventory(g), before);
|
|
}
|
|
assert.deepEqual(views[0], views[1]);
|
|
assert.deepEqual(views[1], views[2]);
|
|
});
|