Connect bounded Moritani Atomics Aftermath
This commit is contained in:
1 parent
b2603f6715
commit
66a427ab55
17 files changed
+1036
-68
No files matched your search
@@ -126,10 +126,9 @@ void test('all four bot profiles can legally perform Ecaz setup', () => {
|
||||
let g = readyTable(['beneGesserit', 'ecaz', 'atreides', 'harkonnen']);
|
||||
g.players.forEach((p) => (p.bot = difficulty));
|
||||
g = initializeFactionExpansionsGameForAudit(g);
|
||||
for (let i = 0; i < 20 && g.status === 'setup'; i++) {
|
||||
g = runBots(g);
|
||||
if (g.setupStage === undefined) break;
|
||||
}
|
||||
// Stop at the setup boundary; a bulk bot run may already finish a short game.
|
||||
for (let i = 0; i < 200 && g.status === 'setup'; i++)
|
||||
g = runBots(g, 1);
|
||||
assert.equal(g.status, 'playing', `${difficulty} did not complete setup`);
|
||||
assert.ok(g.ecazLoyalty?.card);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
||||
|
||||
/** A clean classic Moritani arrival; the room variant supplies authenticated IDs. */
|
||||
export function atomicsFixtureGame(
|
||||
game: Game,
|
||||
ids: { moritani: string; entrant: string; ally: string },
|
||||
options: { advanced?: boolean; allied?: boolean; target?: string } = {},
|
||||
) {
|
||||
const { moritani, entrant, ally } = ids;
|
||||
const owner = game.players.find(p => p.id === moritani)!;
|
||||
const incoming = game.players.find(p => p.id === entrant)!;
|
||||
const partner = game.players.find(p => p.id === ally)!;
|
||||
const target = options.target ?? 'arrakeen';
|
||||
Object.assign(game, {
|
||||
status: 'playing', phase: 5, turn: 2, storm: 18, active: entrant,
|
||||
order: [entrant, moritani, ally], deck: baseDeck(),
|
||||
movementRemaining: [entrant, moritani, ally],
|
||||
});
|
||||
for (const p of game.players)
|
||||
Object.assign(p, { hand: [], spice: 20, forces: {}, reserves: 20, tanks: 0,
|
||||
shipped: false, moved: 0 });
|
||||
if (options.allied !== false) {
|
||||
owner.ally = ally;
|
||||
partner.ally = moritani;
|
||||
}
|
||||
owner.forces = { 'carthag:11': 1 };
|
||||
owner.reserves = 19;
|
||||
partner.forces = { 'arrakeen:10': 2 };
|
||||
partner.reserves = 18;
|
||||
if (options.advanced) {
|
||||
incoming.forces = { 'arrakeen:10': 1 };
|
||||
incoming.reserves = 19;
|
||||
incoming.elites = {
|
||||
reserves: 2, tanks: 0, forces: { 'arrakeen:10': 1 }, revived: 0,
|
||||
};
|
||||
}
|
||||
owner.hand = game.deck.splice(0, 4);
|
||||
if (options.allied !== false) partner.hand = game.deck.splice(0, 4);
|
||||
game.moritaniTerror = createTerrorState(() => 0.2);
|
||||
const token = game.moritaniTerror.tokens.find(row => row.kind === 'atomics')!;
|
||||
game.moritaniTerror = placeTerror(game.moritaniTerror, token.id, target, 1);
|
||||
return { game, token: token.id };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MoritaniEntry, MoritaniTerrorSupply } from '../components/moritani-terror';
|
||||
import { TerrorBoardMarkers } from '../components/terror-board-markers';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
|
||||
import { createTerrorState, placeTerror, revealTerror } from '../game/moritani-terror';
|
||||
|
||||
function fixture() {
|
||||
const game = createGame('ATOMICS-CONTROLS', newPlayer('m', 'Moritani', 'moritani'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('f', 'Fremen', 'fremen'));
|
||||
game.players.find((player) => player.id === 'm')!.ally = 'a';
|
||||
game.players.find((player) => player.id === 'a')!.ally = 'm';
|
||||
game.moritaniTerror = createTerrorState(() => 0);
|
||||
const token = game.moritaniTerror.tokens.find((item) => item.kind === 'atomics')!;
|
||||
game.moritaniTerror = placeTerror(game.moritaniTerror, token.id, 'arrakeen', 1);
|
||||
return { game, token };
|
||||
}
|
||||
|
||||
const supply = (view: GameView) =>
|
||||
renderToStaticMarkup(createElement(MoritaniTerrorSupply, { game: view }));
|
||||
const markers = (view: GameView) =>
|
||||
renderToStaticMarkup(createElement('svg', null, createElement(TerrorBoardMarkers, {
|
||||
tokens: view.moritaniTerror?.tokens ?? [], atomics: view.moritaniAtomics,
|
||||
})));
|
||||
|
||||
void test('a placed hidden face stays private; only owner sees the Atomics reveal consequences', () => {
|
||||
const { game } = fixture();
|
||||
const owner = viewGame(game, 'm');
|
||||
owner.decision = { kind: 'moritaniTerror', player: 'm', entrant: 'f', territory: 'arrakeen' };
|
||||
owner.terrorEntry = {
|
||||
entrant: 'f', territory: 'arrakeen', sector: 10, cause: 'shipment', stage: 'offer',
|
||||
kind: 'atomics', canReveal: true, canOfferAlliance: false,
|
||||
};
|
||||
const offer = renderToStaticMarkup(createElement(MoritaniEntry, { game: owner, busy: false, act() {} }));
|
||||
assert.match(offer, /Reveal Atomics/);
|
||||
assert.match(offer, /every faction’s forces/);
|
||||
assert.match(offer, /including Fremen reinforcements/);
|
||||
assert.match(offer, /discards one random card per excess/);
|
||||
const rival = viewGame(game, 'f');
|
||||
assert.match(supply(rival), /Inspect Hidden Terror/);
|
||||
assert.doesNotMatch(supply(rival), /Inspect Atomics|Atomics Aftermath/);
|
||||
assert.match(markers(rival), /hidden Terror token in Arrakeen/);
|
||||
assert.doesNotMatch(markers(rival), /Atomics Aftermath/);
|
||||
assert.equal(renderToStaticMarkup(createElement(MoritaniEntry, { game: rival, busy: false, act() {} })), '');
|
||||
});
|
||||
|
||||
void test('after revelation all seats see persistent Aftermath and its exact shipment and hand-limit consequences', () => {
|
||||
const { game, token } = fixture();
|
||||
game.moritaniTerror = revealTerror(game.moritaniTerror!, token.id);
|
||||
game.moritaniAtomics = { territory: 'arrakeen', turn: 2, moritaniId: 'm',
|
||||
allyAtActivation: 'a', alliancePolicy: 'unresolved' };
|
||||
game.turn = 2;
|
||||
game.players.find((player) => player.id === 'm')!.atomicsHandLimitPenalty = true;
|
||||
game.players.find((player) => player.id === 'a')!.atomicsHandLimitPenalty = true;
|
||||
for (const id of ['m', 'a', 'f']) {
|
||||
const view = viewGame(game, id);
|
||||
const notice = supply(view);
|
||||
assert.match(notice, /Atomics Aftermath · Arrakeen/);
|
||||
assert.match(notice, /From turn 2, no faction may ship forces/);
|
||||
assert.match(notice, /including Fremen reinforcements/);
|
||||
assert.match(notice, /ordinary movement and Sneak Attack remain possible/);
|
||||
assert.match(notice, /Moritani and Atreides/);
|
||||
assert.match(notice, /lasting hand-limit reduction of one/);
|
||||
const board = markers(view);
|
||||
assert.match(board, /Atomics Aftermath in Arrakeen: permanent shipment ban/);
|
||||
assert.doesNotMatch(board, /hidden Terror token in Arrakeen/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, newPlayer, normalizeAutomaticGame, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { placeTerror } from '../game/moritani-terror';
|
||||
import { botArrivalBlock } from '../game/bot-arrival';
|
||||
import { botActions } from '../game/bots';
|
||||
import { atomicsFixtureGame } from './fixture-moritani-atomics';
|
||||
|
||||
const seat = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
const ship: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 };
|
||||
const move: Action = { type: 'move', from: 'imperial_basin:10', territory: 'arrakeen', sector: 10, amount: 2 };
|
||||
function rejected(g: Game, id: string, action: Action, pattern?: RegExp) {
|
||||
const before = structuredClone(g);
|
||||
if (pattern) assert.throws(() => applyAction(g, id, action), pattern);
|
||||
else assert.throws(() => applyAction(g, id, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
function fixture(advanced = false, ally = true, target = 'arrakeen') {
|
||||
const g = createGame('ATOMIC01', newPlayer('m', 'Moritani', 'moritani'), advanced);
|
||||
g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'));
|
||||
const { token } = atomicsFixtureGame(g,
|
||||
{ moritani: 'm', entrant: 'e', ally: 'a' },
|
||||
{ advanced, allied: ally, target });
|
||||
return { g, token };
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Atomics destroys exact groups, discards excess at random and persists Aftermath`, () => {
|
||||
const { g, token } = fixture(advanced);
|
||||
const before = structuredClone(g);
|
||||
const mCards = seat(g, 'm').hand.map(card => card.id);
|
||||
const aCards = seat(g, 'a').hand.map(card => card.id);
|
||||
let state = applyAction(g, 'e', ship);
|
||||
assert.equal(state.pendingTerrorEntry?.stage, 'offer');
|
||||
const owner = viewGame(state, 'm');
|
||||
assert.equal(owner.terrorEntry?.kind, 'atomics');
|
||||
assert.equal(owner.terrorEntry?.canReveal, true);
|
||||
assert.equal('kind' in (viewGame(state, 'e').terrorEntry ?? {}), false);
|
||||
assert.equal(viewGame(state, 'e').moritaniAtomics, null);
|
||||
state = applyAction(reload(state), 'm', { type: 'decision', reveal: true });
|
||||
state = normalizeAutomaticGame(reload(state));
|
||||
assert.equal(state.moritaniAtomics?.territory, 'arrakeen');
|
||||
assert.equal(state.moritaniAtomics?.allyAtActivation, 'a');
|
||||
assert.equal(state.moritaniTerror!.tokens.find(row => row.id === token)!.status, 'removed');
|
||||
assert.equal(state.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(state.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(seat(state, 'e').forces['arrakeen:10'] ?? 0, 0);
|
||||
assert.equal(seat(state, 'm').forces['arrakeen:10'] ?? 0, 0);
|
||||
assert.equal(seat(state, 'a').forces['arrakeen:10'] ?? 0, 0);
|
||||
assert.equal(seat(state, 'm').forces['carthag:11'], 1);
|
||||
assert.equal(seat(state, 'e').tanks, advanced ? 3 : 2);
|
||||
assert.equal(seat(state, 'e').elites?.tanks ?? 0, advanced ? 1 : 0);
|
||||
assert.equal(seat(state, 'm').tanks, 0);
|
||||
assert.equal(seat(state, 'a').tanks, 2);
|
||||
for (const id of ['m', 'a']) {
|
||||
assert.equal(seat(state, id).hand.length, 3);
|
||||
assert.equal(viewGame(state, id).players.find(p => p.id === id)!.handLimit, 3);
|
||||
assert.equal(seat(state, id).atomicsHandLimitPenalty, true);
|
||||
}
|
||||
assert.equal(state.discard.filter(card => mCards.includes(card.id)).length, 1);
|
||||
assert.equal(state.discard.filter(card => aCards.includes(card.id)).length, 1);
|
||||
assert.equal(before.moritaniAtomics ?? null, null);
|
||||
assert.equal(seat(before, 'e').reserves, advanced ? 19 : 20);
|
||||
for (const id of ['m', 'e', 'a'])
|
||||
assert.equal(viewGame(state, id).moritaniAtomics?.territory, 'arrakeen');
|
||||
rejected(state, 'm', { type: 'decision', reveal: true });
|
||||
});
|
||||
}
|
||||
|
||||
void test('Aftermath blocks every ordinary shipment, including Fremen reinforcement, but not movement', () => {
|
||||
const { g } = fixture(false, false);
|
||||
let state = applyAction(g, 'e', ship);
|
||||
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
|
||||
assert.equal(viewGame(state, 'e').moritaniAtomics?.territory, 'arrakeen');
|
||||
state.active = 'a';
|
||||
seat(state, 'a').spice = 20;
|
||||
rejected(state, 'a', ship, /Atomics Aftermath/);
|
||||
assert.match(String(botArrivalBlock(viewGame(state, 'a'), ship)), /Atomics Aftermath/);
|
||||
seat(state, 'a').forces = { 'imperial_basin:10': 2 };
|
||||
seat(state, 'a').reserves = 18;
|
||||
seat(state, 'a').shipped = true;
|
||||
const moved = applyAction(reload(state), 'a', move);
|
||||
assert.equal(seat(moved, 'a').forces['arrakeen:10'], 2);
|
||||
assert.equal(moved.moritaniAtomics?.territory, 'arrakeen');
|
||||
});
|
||||
|
||||
void test('unallied Atomics destroys Moritani forces but reduces only its own hand', () => {
|
||||
const { g } = fixture(false, false);
|
||||
seat(g, 'a').forces = {};
|
||||
seat(g, 'a').reserves = 20;
|
||||
seat(g, 'm').forces = { 'arrakeen:10': 1, 'carthag:11': 1 };
|
||||
seat(g, 'm').reserves = 18;
|
||||
let state = applyAction(g, 'e', ship);
|
||||
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
|
||||
assert.equal(state.moritaniAtomics?.allyAtActivation, null);
|
||||
assert.equal(seat(state, 'm').tanks, 1);
|
||||
assert.equal(seat(state, 'm').forces['carthag:11'], 1);
|
||||
assert.equal(seat(state, 'm').hand.length, 3);
|
||||
assert.equal(seat(state, 'a').atomicsHandLimitPenalty, undefined);
|
||||
assert.equal(viewGame(state, 'a').players.find(p => p.id === 'a')!.handLimit, 4);
|
||||
});
|
||||
|
||||
void test('Harkonnen ally loses one of its eight hand slots and one random physical card', () => {
|
||||
const g = createGame('ATOMHARK', newPlayer('m', 'Moritani', 'moritani'));
|
||||
g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Harkonnen', 'harkonnen'));
|
||||
atomicsFixtureGame(g, { moritani: 'm', entrant: 'e', ally: 'a' });
|
||||
seat(g, 'a').hand.push(...g.deck.splice(0, 4));
|
||||
const before = seat(g, 'a').hand.map(card => card.id);
|
||||
let state = applyAction(g, 'e', ship);
|
||||
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
|
||||
assert.equal(seat(state, 'a').hand.length, 7);
|
||||
assert.equal(viewGame(state, 'a').players.find(p => p.id === 'a')!.handLimit, 7);
|
||||
assert.equal(state.discard.filter(card => before.includes(card.id)).length, 1);
|
||||
});
|
||||
|
||||
void test('Fremen free reinforcement into Aftermath is rejected without spending its shipment', () => {
|
||||
const { g } = fixture(false, false, 'sietch_tabr');
|
||||
g.players.push(newPlayer('f', 'Fremen', 'fremen'));
|
||||
g.order.push('f');
|
||||
g.movementRemaining!.push('f');
|
||||
const arrival: Action = { type: 'ship', territory: 'sietch_tabr', sector: 14, amount: 1 };
|
||||
let state = applyAction(g, 'e', arrival);
|
||||
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
|
||||
state.active = 'f';
|
||||
seat(state, 'f').spice = 20;
|
||||
rejected(state, 'f', arrival, /Atomics Aftermath/);
|
||||
assert.match(String(botArrivalBlock(viewGame(state, 'f'), arrival)), /Atomics Aftermath/);
|
||||
assert.equal(seat(state, 'f').shipped, false);
|
||||
});
|
||||
|
||||
void test('Sneak Attack remains a free reserve entry into an Atomics Aftermath stronghold', () => {
|
||||
const { g } = fixture(false, false);
|
||||
let state = applyAction(g, 'e', ship);
|
||||
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
|
||||
const token = state.moritaniTerror!.tokens.find(row => row.kind === 'sneakAttack')!;
|
||||
state.moritaniTerror = placeTerror(state.moritaniTerror!, token.id, 'arrakeen', state.turn);
|
||||
seat(state, 'e').forces = { 'imperial_basin:10': 1 };
|
||||
seat(state, 'e').reserves--;
|
||||
seat(state, 'e').moved = 0;
|
||||
state.active = 'e';
|
||||
const entered = applyAction(state, 'e', {
|
||||
type: 'move', from: 'imperial_basin:10', amount: 1,
|
||||
territory: 'arrakeen', sector: 10,
|
||||
});
|
||||
assert.equal(entered.pendingTerrorEntry?.stage, 'offer');
|
||||
const revealed = applyAction(entered, 'm', { type: 'decision', reveal: true });
|
||||
assert.equal(revealed.pendingTerrorEntry?.stage, 'sneakAttack');
|
||||
const done = applyAction(revealed, 'm', { type: 'decision', amount: 1 });
|
||||
assert.equal(seat(done, 'm').forces['arrakeen:10'], 1);
|
||||
assert.equal(done.moritaniAtomics?.territory, 'arrakeen');
|
||||
});
|
||||
|
||||
void test('later Moritani alliance change and forged hand penalties reject atomically', () => {
|
||||
const { g } = fixture(false, true);
|
||||
let state = applyAction(g, 'e', ship);
|
||||
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
|
||||
state.phase = 1;
|
||||
state.nexus = true;
|
||||
state.spiceWindow = null;
|
||||
state.spiceResolution = null;
|
||||
rejected(state, 'm', { type: 'alliance', target: null }, /Atomics/);
|
||||
for (const mutate of [
|
||||
(game: Game) => { delete seat(game, 'a').atomicsHandLimitPenalty; },
|
||||
(game: Game) => { game.moritaniAtomics = { ...game.moritaniAtomics!, allyAtActivation: 'e' }; },
|
||||
(game: Game) => { game.moritaniTerror!.tokens.find(row => row.kind === 'atomics')!.status = 'placed'; },
|
||||
]) {
|
||||
const damaged = reload(state);
|
||||
mutate(damaged);
|
||||
rejected(damaged, 'e', { type: 'ready' });
|
||||
}
|
||||
});
|
||||
|
||||
void test('a later Nexus filters impossible Moritani offers before bots can stall', () => {
|
||||
const { g } = fixture(false, false);
|
||||
let state = applyAction(g, 'e', ship);
|
||||
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
|
||||
state.phase = 1;
|
||||
state.nexus = true;
|
||||
state.spiceWindow = null;
|
||||
state.spiceResolution = null;
|
||||
state.ready = [];
|
||||
state.allianceOffers = { m: 'e' };
|
||||
for (const p of state.players) state.deck.push(...p.hand.splice(0));
|
||||
seat(state, 'm').bot = 'Medium';
|
||||
seat(state, 'e').bot = 'Medium';
|
||||
rejected(state, 'e', { type: 'alliance', target: 'm' }, /Atomics/);
|
||||
rejected(state, 'm', { type: 'alliance', target: 'e' }, /Atomics/);
|
||||
assert.deepEqual(botActions(viewGame(state, 'm'))[0], { type: 'ready' });
|
||||
assert.equal(botActions(viewGame(state, 'e')).some(action =>
|
||||
action.type === 'alliance' && action.target === 'm'), false);
|
||||
const withdrawn = applyAction(state, 'm', { type: 'alliance' });
|
||||
assert.equal(withdrawn.allianceOffers.m, undefined);
|
||||
assert.equal(withdrawn.players.find(p => p.id === 'm')!.ally, null);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { viewGame, type Action } from '../game/engine';
|
||||
import { atomicsFixtureGame } from './fixture-moritani-atomics';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: RoomsClock = { now: () => 45000, sleep: async () => {} };
|
||||
const shipment: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 };
|
||||
|
||||
void test('Atomics persists one reveal, random overflow, custody and public Aftermath across concurrent SQL workers', async t => {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom('Atomics SQL', 'moritani', false, []);
|
||||
const code = made.view.code;
|
||||
const entrant = await store.rooms.joinRoom(code, 'Emperor', 'emperor');
|
||||
const ally = await store.rooms.joinRoom(code, 'Atreides', 'atreides');
|
||||
const tokens = [made.token, entrant.token!, ally.token!];
|
||||
const auth = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
const [m, e, a] = auth.map(seat => seat.playerId);
|
||||
const g = await store.rooms.readRoom(code);
|
||||
const { token } = atomicsFixtureGame(g, { moritani: m, entrant: e, ally: a });
|
||||
const originalM = g.players.find(p => p.id === m)!.hand.map(card => card.id);
|
||||
const originalA = g.players.find(p => p.id === a)!.hand.map(card => card.id);
|
||||
g.code = code;
|
||||
g.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
store.writes.length = 0;
|
||||
const rooms = store.restart();
|
||||
const initialM = await rooms.readSeatView(code, auth[0]);
|
||||
const initialE = await rooms.readSeatView(code, auth[1]);
|
||||
assert.equal(initialM.moritaniAtomics, null);
|
||||
assert.equal(initialE.moritaniAtomics, null);
|
||||
assert.equal('kind' in initialE.moritaniTerror!.tokens.find(row => row.id === token)!, false);
|
||||
|
||||
await store.restart().act(code, auth[1], g.version, shipment, clock);
|
||||
const offered = await store.restart().readRoom(code);
|
||||
assert.equal(offered.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.equal(viewGame(offered, m).terrorEntry?.kind, 'atomics');
|
||||
assert.equal('kind' in (viewGame(offered, e).terrorEntry ?? {}), false);
|
||||
const reveal: Action = { type: 'decision', reveal: true };
|
||||
const outcomes = await Promise.allSettled([
|
||||
store.restart().act(code, auth[0], offered.version, reveal, clock),
|
||||
store.restart().act(code, auth[0], offered.version, reveal, clock),
|
||||
]);
|
||||
assert.equal(outcomes.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(outcomes.filter(result => result.status === 'rejected').length, 1);
|
||||
const saved = await store.restart().readRoom(code);
|
||||
assert.equal(saved.moritaniAtomics?.territory, 'arrakeen');
|
||||
assert.equal(saved.moritaniAtomics?.allyAtActivation, a);
|
||||
assert.equal(saved.moritaniTerror!.tokens.find(row => row.id === token)!.status, 'removed');
|
||||
assert.equal(saved.pendingTerrorEntry, null);
|
||||
assert.equal(saved.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(saved.players.find(p => p.id === e)!.tanks, 2);
|
||||
assert.equal(saved.players.find(p => p.id === a)!.tanks, 2);
|
||||
assert.equal(saved.players.find(p => p.id === m)!.hand.length, 3);
|
||||
assert.equal(saved.players.find(p => p.id === a)!.hand.length, 3);
|
||||
assert.equal(saved.discard.filter(card => originalM.includes(card.id)).length, 1);
|
||||
assert.equal(saved.discard.filter(card => originalA.includes(card.id)).length, 1);
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const authFresh = await store.restart().authenticate(code, tokens[i]);
|
||||
const view = await store.restart().readSeatView(code, authFresh);
|
||||
assert.deepEqual(view, viewGame(saved, authFresh.playerId));
|
||||
assert.equal(view.moritaniAtomics?.territory, 'arrakeen');
|
||||
assert.equal(view.players.find(p => p.id === m)!.handLimit, 3);
|
||||
assert.equal(view.players.find(p => p.id === a)!.handLimit, 3);
|
||||
}
|
||||
const beforeReplay = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code);
|
||||
await assert.rejects(store.restart().act(code, auth[0], saved.version, reveal, clock));
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), beforeReplay);
|
||||
let later = saved;
|
||||
for (const index of [1, 0]) {
|
||||
await store.restart().act(code, auth[index], later.version, { type: 'endMovement' }, clock);
|
||||
later = await store.restart().readRoom(code);
|
||||
}
|
||||
assert.equal(later.active, a);
|
||||
const row = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code);
|
||||
await assert.rejects(store.restart().act(code, auth[2], later.version, shipment, clock), /Atomics Aftermath/);
|
||||
await store.restart().continueRoomAutomatic(code, clock);
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), row);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
atomicsAllianceStatus,
|
||||
atomicsEffectiveHandLimit,
|
||||
atomicsShipmentBlocked,
|
||||
quoteMoritaniAtomics,
|
||||
type AtomicsPlayer,
|
||||
type AtomicsAftermath,
|
||||
} from '../game/moritani-atomics';
|
||||
import type { TerrorToken } from '../game/moritani-terror';
|
||||
|
||||
const token: TerrorToken = {
|
||||
id: 'terror-opaque', kind: 'atomics', status: 'placed', location: 'arrakeen',
|
||||
};
|
||||
const moritani: AtomicsPlayer = {
|
||||
id: 'm', faction: 'moritani', ally: null, forces: {}, tanks: 0,
|
||||
handSize: 4, baseHandLimit: 4,
|
||||
};
|
||||
const quote = (players: readonly AtomicsPlayer[], aftermath: AtomicsAftermath | null = null) =>
|
||||
quoteMoritaniAtomics({ token, territory: 'arrakeen', players,
|
||||
moritaniId: 'm', turn: 3, aftermath });
|
||||
|
||||
void test('no ally at activation: all owners lose typed forces in the territory, not elsewhere', () => {
|
||||
const players: AtomicsPlayer[] = [
|
||||
{ ...moritani, forces: { 'arrakeen:10': 2, 'carthag:11': 3 } },
|
||||
{ id: 'ix', faction: 'ixians', ally: null, forces: {
|
||||
'arrakeen:10': 4, 'carthag:11': 2,
|
||||
}, elites: { reserves: 0, tanks: 0, revived: 0,
|
||||
forces: { 'arrakeen:10': 2 } }, tanks: 1,
|
||||
handSize: 5, baseHandLimit: 4 },
|
||||
{ id: 'bg', faction: 'beneGesserit', ally: null,
|
||||
forces: { 'arrakeen:10': 1, 'carthag:11': 2 },
|
||||
advisors: { arrakeen: {} }, tanks: 0, handSize: 4, baseHandLimit: 4 },
|
||||
{ id: 'zero', faction: 'fremen', ally: null,
|
||||
forces: { 'arrakeen:10': 0 }, tanks: 0,
|
||||
handSize: 0, baseHandLimit: 4 },
|
||||
];
|
||||
const before = structuredClone(players);
|
||||
const plan = quote(players);
|
||||
assert.deepEqual(plan.casualties, [
|
||||
{ playerId: 'm', location: 'arrakeen:10', normal: 2, elite: 0, advisor: 0 },
|
||||
{ playerId: 'ix', location: 'arrakeen:10', normal: 2, elite: 2, advisor: 0 },
|
||||
{ playerId: 'bg', location: 'arrakeen:10', normal: 1, elite: 0, advisor: 1 },
|
||||
]);
|
||||
assert.deepEqual(plan.handReductions,
|
||||
[{ playerId: 'm', limit: 3, randomDiscards: 1 }]);
|
||||
assert.deepEqual(plan.aftermath, { territory: 'arrakeen', turn: 3,
|
||||
moritaniId: 'm', allyAtActivation: null, alliancePolicy: 'unresolved' });
|
||||
assert.deepEqual(players, before);
|
||||
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'ix', null, 4), 4);
|
||||
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'm', null, 4), 3);
|
||||
assert.equal(atomicsAllianceStatus(plan.aftermath, null), 'activation-alliance');
|
||||
assert.equal(atomicsAllianceStatus(plan.aftermath, 'newAlly'), 'clarification-required');
|
||||
assert.throws(() => atomicsEffectiveHandLimit(plan.aftermath, 'm', 'newAlly', 4),
|
||||
/alliance change/);
|
||||
});
|
||||
|
||||
void test('activation ally is reduced with Moritani; counts exceed-one overflow without selecting cards', () => {
|
||||
const plan = quote([
|
||||
{ ...moritani, ally: 'choam', handSize: 6, baseHandLimit: 4 },
|
||||
{ id: 'choam', faction: 'choam', ally: 'm', tanks: 0,
|
||||
forces: { 'arrakeen:10': 1 }, handSize: 6, baseHandLimit: 5 },
|
||||
{ id: 'other', faction: 'harkonnen', ally: null, tanks: 0,
|
||||
forces: {}, handSize: 9, baseHandLimit: 8 },
|
||||
]);
|
||||
assert.deepEqual(plan.handReductions, [
|
||||
{ playerId: 'm', limit: 3, randomDiscards: 3 },
|
||||
{ playerId: 'choam', limit: 4, randomDiscards: 2 },
|
||||
]);
|
||||
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'choam', 'choam', 5), 4);
|
||||
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'other', 'choam', 8), 8);
|
||||
for (const next of [null, 'other']) {
|
||||
assert.equal(atomicsAllianceStatus(plan.aftermath, next), 'clarification-required');
|
||||
assert.throws(() => atomicsEffectiveHandLimit(plan.aftermath, 'other', next, 8),
|
||||
/alliance change/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Aftermath blocks shipment by territory regardless of faction or sector, not other territories', () => {
|
||||
const aftermath = quote([moritani]).aftermath;
|
||||
assert.equal(atomicsShipmentBlocked(aftermath, 'arrakeen'), true);
|
||||
assert.equal(atomicsShipmentBlocked(aftermath, 'carthag'), false);
|
||||
assert.equal(atomicsShipmentBlocked(null, 'arrakeen'), false);
|
||||
assert.equal(atomicsShipmentBlocked(aftermath, 'arrakeen:10'), false);
|
||||
});
|
||||
|
||||
void test('placement, existing Aftermath, alliance inconsistency and malformed physical custody fail closed', () => {
|
||||
const aftermath = quote([moritani]).aftermath;
|
||||
assert.throws(() => quote([moritani], aftermath), /already on the board/);
|
||||
for (const wrong of [
|
||||
{ ...token, status: 'removed', location: null } as TerrorToken,
|
||||
{ ...token, kind: 'robbery' } as TerrorToken,
|
||||
{ ...token, location: 'hidden_mobile_stronghold' } as TerrorToken,
|
||||
{ ...token, location: 'carthag' } as TerrorToken,
|
||||
]) assert.throws(() => quoteMoritaniAtomics({ token: wrong,
|
||||
territory: 'arrakeen', moritaniId: 'm', turn: 3,
|
||||
aftermath: null, players: [moritani] }),
|
||||
/still-placed token/);
|
||||
assert.throws(() => quoteMoritaniAtomics({ token, territory: 'carthag',
|
||||
moritaniId: 'm', turn: 3, aftermath: null, players: [moritani] }),
|
||||
/entered ordinary stronghold/);
|
||||
assert.throws(() => quote([{ ...moritani, ally: 'missing' }]), /mutual ally/);
|
||||
assert.throws(() => quote([{ ...moritani, forces: { 'arrakeen:11': 1 } }]),
|
||||
/valid typed forces/);
|
||||
assert.throws(() => quote([{ ...moritani, forces: { 'arrakeen:10': 1 },
|
||||
elites: { reserves: 0, tanks: 0, revived: 0,
|
||||
forces: { 'arrakeen:10': 2 } } }]), /valid typed forces/);
|
||||
assert.throws(() => quote([{ ...moritani, noField: {
|
||||
tokens: [{ id: 'zero', value: 0 }], lastShipped: null,
|
||||
deployed: { tokenId: 'zero', controller: 'm',
|
||||
location: { territory: 'arrakeen', sector: 10 } },
|
||||
} }]), /concealed No-Field/);
|
||||
});
|
||||
|
||||
void test('a token at a storm-exposed stronghold still quotes all occupied counters', () => {
|
||||
const stormExposed = quoteMoritaniAtomics({
|
||||
token: { ...token, location: 'tueks_sietch' }, territory: 'tueks_sietch', players: [
|
||||
{ ...moritani, forces: { 'tueks_sietch:5': 1, 'arrakeen:10': 1 } },
|
||||
], moritaniId: 'm', turn: 4, aftermath: null,
|
||||
});
|
||||
assert.deepEqual(stormExposed.casualties,
|
||||
[{ playerId: 'm', location: 'tueks_sietch:5', normal: 1, elite: 0, advisor: 0 }]);
|
||||
assert.equal(stormExposed.aftermath.territory, 'tueks_sietch');
|
||||
});
|
||||
Reference in new issue
Block a user