Initial commit
This commit is contained in:
commit
8c709fb52f
771 files changed
+222983
No files matched your search
@@ -0,0 +1,309 @@
|
||||
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]);
|
||||
});
|
||||
Reference in new issue
Block a user