Adopt authorized Advanced Storm and alliance separation defaults
This commit is contained in:
1 parent
42324b4a99
commit
e1056a4db0
30 files changed
+2955
-82
No files matched your search
@@ -157,18 +157,51 @@ void test('Karama can stop Guild delaying its normal turn, but taking that norma
|
||||
assert.deepEqual(g.movementRemaining, ['g', 'h', 'f']);
|
||||
assert.equal(g.guildTimingLocked, true);
|
||||
});
|
||||
void test('allied co-occupation after Nexus uses actual completed movement turns when Guild changes order', () => {
|
||||
let g = fixture();
|
||||
g.players[0].ally = 'g';
|
||||
g.players[1].ally = 'e';
|
||||
g.players[0].forces = { 'arrakeen:10': 1 };
|
||||
g.players[0].reserves = 19;
|
||||
g.players[1].forces = { 'arrakeen:10': 5 };
|
||||
g = allow(choose(g, true));
|
||||
g = end(g);
|
||||
assert.equal(g.players[1].forces['arrakeen:10'], 5);
|
||||
assert.equal(g.active, 'e');
|
||||
g = end(g);
|
||||
assert.equal(g.players[0].forces['arrakeen:10'], undefined);
|
||||
assert.equal(g.players[0].tanks, 1);
|
||||
void test('allied co-occupation loses only the ending faction’s fighters when Guild changes order', () => {
|
||||
for (const guildFirst of [true, false]) {
|
||||
let g = fixture();
|
||||
g.players[0].ally = 'g';
|
||||
g.players[1].ally = 'e';
|
||||
g.players[0].forces = { 'arrakeen:10': 1 };
|
||||
g.players[0].reserves = 19;
|
||||
g.players[1].forces = { 'arrakeen:10': 5 };
|
||||
g.players[1].reserves = 15;
|
||||
g = allow(choose(g, guildFirst));
|
||||
const first = guildFirst ? 'g' : 'e';
|
||||
const survivor = guildFirst ? 'e' : 'g';
|
||||
const lost = guildFirst ? 5 : 1;
|
||||
const retained = guildFirst ? 1 : 5;
|
||||
assert.equal(g.active, first);
|
||||
g = end(g);
|
||||
assert.deepEqual(g.players.find((p) => p.id === first)!.forces, {});
|
||||
assert.equal(g.players.find((p) => p.id === first)!.tanks, lost);
|
||||
assert.deepEqual(g.players.find((p) => p.id === survivor)!.forces, {
|
||||
'arrakeen:10': retained,
|
||||
});
|
||||
assert.equal(g.players.find((p) => p.id === survivor)!.tanks, 0);
|
||||
const acted = [first];
|
||||
while (g.phase === 5) {
|
||||
if (g.decision?.kind === 'guildTiming') g = allow(choose(g, false));
|
||||
acted.push(g.active!);
|
||||
g = end(g);
|
||||
}
|
||||
assert.deepEqual(
|
||||
acted,
|
||||
guildFirst ? ['g', 'e', 'h', 'f'] : ['e', 'h', 'f', 'g'],
|
||||
);
|
||||
assert.deepEqual(g.players.find((p) => p.id === survivor)!.forces, {
|
||||
'arrakeen:10': retained,
|
||||
});
|
||||
assert.equal(g.players.find((p) => p.id === survivor)!.tanks, 0);
|
||||
for (const id of ['e', 'g']) {
|
||||
const p = g.players.find((p) => p.id === id)!;
|
||||
assert.equal(p.reserves, id === 'e' ? 19 : 15);
|
||||
assert.equal(
|
||||
p.reserves +
|
||||
p.tanks +
|
||||
Object.values(p.forces).reduce((sum, n) => sum + n, 0),
|
||||
20,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '../game/engine';
|
||||
import { spiceDeck, baseDeck } from '../game/cards';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { createStormSource } from '../game/discovery-storm';
|
||||
import { isStormCardDistance, stormCardDistance } from '../game/storm-cards';
|
||||
function fixture() {
|
||||
let g = createGame('ADVTEST2', newPlayer('f', 'Fremen', 'fremen'));
|
||||
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
||||
@@ -346,23 +348,57 @@ void test('canceling storm shipment protection destroys only the arriving group
|
||||
|
||||
void test('Weather Control overrides an advanced storm card after revelation and a new private forecast follows', () => {
|
||||
let g = fixture();
|
||||
g.turn = 2;
|
||||
g.phase = 0;
|
||||
g.stormDialers = [];
|
||||
g.stormPending = 3;
|
||||
g = applyAction(g, 'f', { type: 'stormDial', amount: 0 });
|
||||
g = applyAction(g, 'a', { type: 'stormDial', amount: 0 });
|
||||
g = allow(ready(g));
|
||||
const original = g.stormCard!;
|
||||
assert.ok(isStormCardDistance(original));
|
||||
g.phase = 8;
|
||||
g.ready = [];
|
||||
g = ready(g);
|
||||
assert.equal(g.turn, 2);
|
||||
assert.equal(g.phase, 0);
|
||||
assert.equal(g.stormPending, original);
|
||||
assert.deepEqual(
|
||||
g.stormMovementSource,
|
||||
createStormSource(g.turn, 'card', original),
|
||||
);
|
||||
const inspection = g.log.find(
|
||||
(entry) => stormCardDistance(entry.component) === original,
|
||||
)!;
|
||||
assert.equal(stormCardDistance(inspection.component), original);
|
||||
const previous = g.storm;
|
||||
const weather = baseDeck().find((c) => c.effect === 'weather')!;
|
||||
placeFixtureHand(g, 1, [weather]);
|
||||
const karama = baseDeck().find((c) => c.effect === 'karama')!;
|
||||
placeFixtureHand(g, 1, [weather, karama]);
|
||||
g = applyAction(g, 'f', { type: 'ready' });
|
||||
g = applyAction(g, 'a', { type: 'card', card: weather.id, amount: 9 });
|
||||
assert.equal(g.stormPending, 9);
|
||||
assert.deepEqual(
|
||||
g.stormMovementSource,
|
||||
createStormSource(g.turn, 'weather', 9),
|
||||
);
|
||||
for (const player of g.players)
|
||||
assert.equal(
|
||||
stormCardDistance(
|
||||
viewGame(g, player.id).log.find((entry) => entry.seq === inspection.seq)
|
||||
?.component,
|
||||
),
|
||||
original,
|
||||
);
|
||||
assert.deepEqual(g.ready, []);
|
||||
g = ready(g);
|
||||
assert.equal(g.storm, ((previous - 1 + 9) % 18) + 1);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(viewGame(g, 'f').stormForecast, g.stormCard);
|
||||
assert.equal(g.stormMovementSource, undefined);
|
||||
assert.equal(g.stormPending, null);
|
||||
assert.equal(g.discard.filter((card) => card.id === weather.id).length, 1);
|
||||
assert.equal(g.response?.kind, 'stormPeek');
|
||||
const forecast = g.stormCard!;
|
||||
assert.ok(isStormCardDistance(forecast));
|
||||
assert.equal(viewGame(g, 'f').stormForecast, null);
|
||||
assert.equal(viewGame(g, 'a').stormForecast, null);
|
||||
g = allow(g);
|
||||
assert.ok(viewGame(g, 'f').stormForecast);
|
||||
assert.equal(viewGame(g, 'f').stormForecast, forecast);
|
||||
assert.equal(viewGame(g, 'a').stormForecast, null);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { advanceToForecast, createAdvancedSourceFixture, settleAdvancedSourceResponses } from './fixture-advanced-source';
|
||||
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
},
|
||||
});
|
||||
// Next/CSS test loading hooks must be registered before this UI dependency is loaded.
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
|
||||
function table(game: GameView, busy = false): string {
|
||||
return renderToStaticMarkup(createElement(GameTable, { game, send: async () => {}, busy, onExit() {} }));
|
||||
}
|
||||
|
||||
void test('actual first Storm gives the recorded dialers battle wheels and no public card or private forecast', () => {
|
||||
const fixture = createAdvancedSourceFixture({ storm: 'first' });
|
||||
for (const player of fixture.game.players) {
|
||||
const own = viewGame(fixture.game, player.id);
|
||||
const html = table(own);
|
||||
if (own.stormDialers.includes(player.id)) {
|
||||
assert.match(html, /id="storm-dial"[^>]*min="0"[^>]*max="20"/);
|
||||
assert.match(html, />Lock storm dial/);
|
||||
} else {
|
||||
assert.doesNotMatch(html, /id="storm-dial"/);
|
||||
}
|
||||
assert.doesNotMatch(html, /aria-label="Inspect Storm Card:/);
|
||||
assert.equal(own.stormForecast, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('subsequent Advanced Storm without Fremen offers the public card and existing confirmation, not dials or forecast', () => {
|
||||
const fixture = createAdvancedSourceFixture({ storm: 'next', fremen: false });
|
||||
for (const player of fixture.game.players) {
|
||||
const own = viewGame(fixture.game, player.id);
|
||||
const html = table(own);
|
||||
assert.match(html, />Confirm storm movement/);
|
||||
assert.match(html, /aria-label="Inspect Storm Card: [1-6] sectors? · Public reveal"/);
|
||||
assert.doesNotMatch(html, /id="storm-dial"|>Lock storm dial|Waiting for secret storm dials|Your storm forecast:/);
|
||||
assert.equal(own.stormForecast, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('later Basic Storm keeps native recorded dial controls and confirms their revealed sum rather than a card', () => {
|
||||
const fixture = createAdvancedSourceFixture({ advanced: false, storm: 'next' });
|
||||
let game = fixture.game;
|
||||
assert.equal(game.stormPending, null);
|
||||
assert.equal(game.stormDialers.length, 2);
|
||||
for (const dialer of fixture.game.stormDialers) {
|
||||
const own = viewGame(game, dialer);
|
||||
assert.match(table(own), /id="storm-dial"[^>]*min="1"[^>]*max="3"/);
|
||||
game = applyAction(game, dialer, { type: 'stormDial', amount: 2 });
|
||||
}
|
||||
for (const player of game.players) {
|
||||
const html = table(viewGame(game, player.id));
|
||||
assert.match(html, />Confirm storm movement/);
|
||||
assert.doesNotMatch(html, /id="storm-dial"|aria-label="Inspect Storm Card:/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('each ending seat sees only its own public mandatory loss warning; normal end remains available without a second prompt', () => {
|
||||
const fixture = createAdvancedSourceFixture({ alliance: 'newThisTurn' });
|
||||
const own = viewGame(fixture.game, fixture.actor);
|
||||
assert.ok(own.advancedAllySeparation?.territories.length);
|
||||
const html = table(own);
|
||||
for (const name of own.advancedAllySeparation.territories) assert.ok(html.includes(name));
|
||||
assert.match(html, /aria-describedby="advanced-ally-separation"[^>]*>Finish shipment & movement/);
|
||||
assert.doesNotMatch(html, />Confirm (?:alliance|force loss)/);
|
||||
for (const player of fixture.game.players.filter(player => player.id !== fixture.actor)) {
|
||||
const other = viewGame(fixture.game, player.id);
|
||||
assert.equal(other.advancedAllySeparation, null);
|
||||
assert.doesNotMatch(table(other), /id="advanced-ally-separation"/);
|
||||
}
|
||||
const finished = applyAction(fixture.game, fixture.actor, { type: 'endMovement' });
|
||||
assert.doesNotMatch(table(viewGame(finished, fixture.actor)), /id="advanced-ally-separation"/);
|
||||
});
|
||||
|
||||
void test('room and transport locks keep the existing end button disabled despite mandatory loss being legal', () => {
|
||||
const fixture = createAdvancedSourceFixture({ alliance: 'newThisTurn' });
|
||||
const own = viewGame(fixture.game, fixture.actor);
|
||||
assert.match(table(own, true), /disabled=""[^>]*>Finish shipment & movement/);
|
||||
for (const restriction of ['paused', 'closed', 'autopilot'] as const) {
|
||||
const restricted: ComponentProps<typeof GameTable>['game'] = structuredClone(own);
|
||||
if (restriction === 'autopilot') restricted.players.find(player => player.id === own.me)!.autopilot = 'Easy';
|
||||
else restricted.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
|
||||
assert.match(table(restricted), /disabled=""[^>]*>Finish shipment & movement/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native forecast response owns the surface and reveals a private face only to Fremen after it settles', () => {
|
||||
const fixture = createAdvancedSourceFixture({ storm: 'first', fremen: true, karama: true });
|
||||
const pending = advanceToForecast(fixture.game);
|
||||
assert.equal(pending.response?.kind, 'stormPeek');
|
||||
const counter = pending.players.find(player => player.faction !== 'fremen' && player.hand.some(card => card.effect === 'karama'));
|
||||
assert.ok(counter, 'the native deal supplies a genuine counter to pass');
|
||||
const card = counter.hand.find(card => card.effect === 'karama')!;
|
||||
assert.ok(viewGame(pending, counter.id).responseControls?.cancelCards.includes(card.id));
|
||||
for (const player of pending.players) {
|
||||
const own = viewGame(pending, player.id);
|
||||
const html = table(own);
|
||||
assert.equal(own.stormForecast, null);
|
||||
assert.doesNotMatch(html, />Confirm storm movement|>Finish shipment & movement|id="advanced-ally-separation"|Your storm forecast:/);
|
||||
}
|
||||
const settled = settleAdvancedSourceResponses(pending);
|
||||
assert.ok(settled.players.find(player => player.id === counter.id)!.hand.some(held => held.id === card.id));
|
||||
assert.equal(settled.discard.some(discarded => discarded.id === card.id), false);
|
||||
for (const player of settled.players) {
|
||||
const own = viewGame(settled, player.id);
|
||||
const html = table(own);
|
||||
if (player.faction === 'fremen') {
|
||||
assert.equal(own.stormForecast, pending.stormCard);
|
||||
assert.match(html, /aria-label="Inspect Storm Card: [1-6] sectors? · Private forecast"/);
|
||||
} else {
|
||||
assert.equal(own.stormForecast, null);
|
||||
assert.doesNotMatch(html, /aria-label="Inspect Storm Card: [1-6] sectors? · Private forecast"/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
aliases.deregister();
|
||||
@@ -0,0 +1,251 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {applyAction, viewGame, type Game} from '../game/engine';
|
||||
import {splitLocation, territory} from '../game/board';
|
||||
import {stormCardDistance} from '../game/storm-cards';
|
||||
import {createAdvancedSourceFixture, advanceToForecast, advanceToNextStorm, settleAdvancedSourceResponses} from './fixture-advanced-source';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
function confirmStorm(state: Game): Game {
|
||||
let game = state;
|
||||
for (const player of state.players) game = applyAction(game, player.id, {type: 'ready'});
|
||||
return game;
|
||||
}
|
||||
function receiptFaces(game: Game): number[] {
|
||||
return game.log.flatMap(entry => {
|
||||
const face = stormCardDistance(entry.component);
|
||||
return face === null ? [] : [face];
|
||||
});
|
||||
}
|
||||
function group(game: Game, id: string, where: string[]) {
|
||||
const player = game.players.find(p => p.id === id)!;
|
||||
const amount = Object.entries(player.forces).filter(([key]) => where.includes(splitLocation(key).territory))
|
||||
.reduce((sum, [, n]) => sum + n, 0);
|
||||
const elite = Object.entries(player.elites?.forces ?? {}).filter(([key]) => where.includes(splitLocation(key).territory))
|
||||
.reduce((sum, [, n]) => sum + n, 0);
|
||||
return {amount, elite, tanks: player.tanks, eliteTanks: player.elites?.tanks ?? 0};
|
||||
}
|
||||
|
||||
function custody(game: Game, id: string) {
|
||||
const player = game.players.find(p => p.id === id)!;
|
||||
return {forces: player.forces, reserves: player.reserves, tanks: player.tanks,
|
||||
elites: player.elites ?? null, advisors: player.advisors ?? null,
|
||||
noField: player.noField ?? null, hand: player.hand};
|
||||
}
|
||||
|
||||
void test('actual first Advanced storm keeps two native 0..20 wheels and commits once', () => {
|
||||
let {game} = createAdvancedSourceFixture();
|
||||
assert.equal(game.turn, 1);
|
||||
assert.equal(game.stormDialers.length, 2);
|
||||
assert.equal(game.stormPending, null);
|
||||
const [first, second] = game.stormDialers;
|
||||
game = applyAction(game, first, {type: 'stormDial', amount: 20});
|
||||
const checkpoint = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, first, {type: 'stormDial', amount: 0}), /locked/);
|
||||
assert.equal(JSON.stringify(game), checkpoint);
|
||||
game = applyAction(reload(game), second, {type: 'stormDial', amount: 20});
|
||||
assert.equal(game.stormPending, 40);
|
||||
assert.equal(game.stormMovementSource?.kind, 'dials');
|
||||
game = confirmStorm(game);
|
||||
assert.equal(game.phase, 1);
|
||||
assert.equal(game.storm, 5);
|
||||
assert.equal(game.stormMovementSource, undefined);
|
||||
assert.deepEqual(receiptFaces(game), []);
|
||||
});
|
||||
|
||||
void test('every subsequent Advanced opening uses one canonical public card without granting a Fremen forecast', () => {
|
||||
let {game} = createAdvancedSourceFixture({storm: 'next'});
|
||||
assert.equal(game.turn, 2);
|
||||
assert.deepEqual(game.stormDialers, []);
|
||||
const face = game.stormPending!;
|
||||
assert.ok(face >= 1 && face <= 6);
|
||||
assert.equal(game.stormMovementSource?.kind, 'card');
|
||||
assert.deepEqual(receiptFaces(game), [face]);
|
||||
const before = JSON.stringify(game);
|
||||
for (const p of game.players) {
|
||||
const view = viewGame(game, p.id);
|
||||
assert.equal(view.stormForecast, null);
|
||||
assert.equal(view.stormPending, face);
|
||||
assert.equal(view.stormRevealed, null);
|
||||
}
|
||||
assert.equal(JSON.stringify(game), before);
|
||||
const first = game.players[0].id;
|
||||
assert.throws(() => applyAction(game, first, {type: 'stormDial', amount: 1}), /not dialing/);
|
||||
game = applyAction(reload(game), first, {type: 'ready'});
|
||||
const savedSource = game.stormMovementSource;
|
||||
assert.deepEqual(receiptFaces(game), [face]);
|
||||
for (const p of game.players.slice(1)) game = applyAction(game, p.id, {type: 'ready'});
|
||||
assert.equal(game.phase, 1);
|
||||
assert.equal(game.storm, 1 + face);
|
||||
assert.deepEqual(receiptFaces(game), [face]);
|
||||
assert.equal(savedSource?.distance, face);
|
||||
game = advanceToNextStorm(game);
|
||||
assert.equal(game.turn, 3);
|
||||
assert.deepEqual(game.stormDialers, []);
|
||||
assert.equal(receiptFaces(game).length, 2);
|
||||
});
|
||||
|
||||
void test('native forecast remains private and the held physical face is the next actual reveal, including cancellation', () => {
|
||||
for (const cancel of [false, true]) {
|
||||
const fixture = createAdvancedSourceFixture({fremen: true, karama: true});
|
||||
let game = advanceToForecast(fixture.game);
|
||||
const fremen = game.players.find(p => p.faction === 'fremen')!;
|
||||
const face = game.stormCard!;
|
||||
const counter = game.players.find(p => p.faction !== 'fremen' && p.hand.some(c => c.effect === 'karama'))!;
|
||||
assert.ok(counter, 'the native deal supplies a genuine counter even when it will pass');
|
||||
const card = counter.hand.find(c => c.effect === 'karama')!;
|
||||
assert.equal(game.response?.kind, 'stormPeek');
|
||||
assert.ok(viewGame(game, counter.id).responseControls?.cancelCards.includes(card.id));
|
||||
const forces = structuredClone(game.players.map(p => ({id: p.id, custody: custody(game, p.id)})));
|
||||
assert.equal(viewGame(game, fremen.id).stormForecast, null);
|
||||
if (cancel) {
|
||||
game = applyAction(game, counter.id, {type: 'card', card: card.id, mode: 'cancel'});
|
||||
} else game = settleAdvancedSourceResponses(game);
|
||||
for (const before of forces) {
|
||||
const after = custody(game, before.id);
|
||||
assert.deepEqual({...after, hand: undefined}, {...before.custody, hand: undefined});
|
||||
}
|
||||
assert.equal(game.players.find(p => p.id === counter.id)!.hand.some(c => c.id === card.id), !cancel);
|
||||
assert.equal(game.discard.filter(c => c.id === card.id).length, cancel ? 1 : 0);
|
||||
for (const p of game.players)
|
||||
assert.equal(viewGame(game, p.id).stormForecast, !cancel && p.id === fremen.id ? face : null);
|
||||
assert.equal(game.stormCard, face);
|
||||
assert.deepEqual(receiptFaces(game), []);
|
||||
game = advanceToNextStorm(reload(game));
|
||||
assert.equal(game.stormPending, face);
|
||||
assert.deepEqual(receiptFaces(game), [face]);
|
||||
assert.equal(game.stormCard, null);
|
||||
}
|
||||
const automatic = createAdvancedSourceFixture({fremen: true, karama: false});
|
||||
let game = advanceToForecast(automatic.game);
|
||||
assert.equal(game.response, null, 'a native forecast without an eligible counter resolves automatically');
|
||||
assert.ok(game.stormCardKnown);
|
||||
const face = game.stormCard!;
|
||||
assert.ok(face >= 1 && face <= 6);
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.hand.some(card => card.effect === 'karama'), false);
|
||||
assert.equal(viewGame(game, player.id).stormForecast, player.faction === 'fremen' ? face : null);
|
||||
}
|
||||
assert.deepEqual(receiptFaces(game), []);
|
||||
game = advanceToNextStorm(reload(game));
|
||||
assert.equal(game.stormPending, face);
|
||||
assert.deepEqual(receiptFaces(game), [face]);
|
||||
assert.equal(game.stormCard, null);
|
||||
});
|
||||
|
||||
void test('Basic next-turn wheels retain their native 1..3 bounds and never become the Advanced Deck', () => {
|
||||
let {game} = createAdvancedSourceFixture({advanced: false, storm: 'next'});
|
||||
assert.equal(game.stormDialers.length, 2);
|
||||
assert.equal(game.stormPending, null);
|
||||
assert.equal(game.stormCard, null);
|
||||
const before = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, game.stormDialers[0], {type: 'stormDial', amount: 0}), /Storm dial/);
|
||||
assert.equal(JSON.stringify(game), before);
|
||||
for (const id of game.stormDialers) game = applyAction(game, id, {type: 'stormDial', amount: 3});
|
||||
assert.equal(game.stormPending, 6);
|
||||
assert.equal(game.stormMovementSource?.kind, 'dials');
|
||||
assert.deepEqual(receiptFaces(game), []);
|
||||
});
|
||||
|
||||
void test('corrupt or orphaned card source cannot mutate a saved native pending Storm', () => {
|
||||
const original = createAdvancedSourceFixture({storm: 'next'}).game;
|
||||
for (const corrupt of [
|
||||
(game: Game) => {game.stormMovementSource!.signature = 'foreign';},
|
||||
(game: Game) => {delete game.stormMovementSource;},
|
||||
(game: Game) => {game.stormPending = game.stormPending === 6 ? 5 : 6;},
|
||||
(game: Game) => {game.stormMovementSource!.turn++;},
|
||||
]) {
|
||||
const game = reload(original);
|
||||
corrupt(game);
|
||||
const before = JSON.stringify(game);
|
||||
assert.throws(() => viewGame(game, game.players[0].id), /source|storm/i);
|
||||
assert.throws(() => applyAction(game, game.players[0].id, {type: 'ready'}), /source|storm/i);
|
||||
assert.equal(JSON.stringify(game), before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced ends own exact normal and elite custody now, even with the new ally still to act', () => {
|
||||
for (const alliance of ['firstEnding', 'laterEnding'] as const) {
|
||||
const fixture = createAdvancedSourceFixture({alliance});
|
||||
const {actor, ally, lossTerritories} = fixture;
|
||||
let game = fixture.game;
|
||||
assert.ok(game.movementRemaining!.includes(ally!));
|
||||
assert.equal(game.players.find(p => p.id === actor)!.allySinceTurn, game.turn);
|
||||
assert.equal(game.players.find(p => p.id === ally)!.allySinceTurn, game.turn);
|
||||
if (alliance === 'laterEnding') assert.ok(game.order.indexOf(actor) > 0);
|
||||
const own = group(game, actor, lossTerritories);
|
||||
assert.equal(own.amount, 2);
|
||||
assert.equal(own.elite, 1);
|
||||
const ownCustody = structuredClone(custody(game, actor));
|
||||
const other = structuredClone(custody(game, ally!));
|
||||
const immutable = JSON.stringify(game);
|
||||
assert.deepEqual(viewGame(game, actor).advancedAllySeparation,
|
||||
{territories: lossTerritories.map(id => territory(id).name)});
|
||||
assert.equal(viewGame(game, ally!).advancedAllySeparation, null);
|
||||
assert.equal(JSON.stringify(game), immutable);
|
||||
game = applyAction(reload(game), actor, {type: 'endMovement'});
|
||||
const after = group(game, actor, lossTerritories);
|
||||
assert.deepEqual(after, {amount: 0, elite: 0, tanks: own.tanks + own.amount, eliteTanks: own.eliteTanks + own.elite});
|
||||
const endedCustody = custody(game, actor);
|
||||
assert.equal(endedCustody.reserves, ownCustody.reserves);
|
||||
assert.equal(endedCustody.elites?.reserves, ownCustody.elites?.reserves);
|
||||
assert.deepEqual(endedCustody.advisors, ownCustody.advisors);
|
||||
assert.deepEqual(endedCustody.noField, ownCustody.noField);
|
||||
assert.deepEqual(endedCustody.hand, ownCustody.hand);
|
||||
assert.deepEqual(custody(game, ally!), other);
|
||||
assert.ok(!game.movementRemaining?.includes(actor));
|
||||
const committed = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, actor, {type: 'endMovement'}), /movement turn/);
|
||||
assert.equal(JSON.stringify(game), committed);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic preserves newly allied overlap and only the later old ally loses its own group', () => {
|
||||
const fresh = createAdvancedSourceFixture({advanced: false, alliance: 'newThisTurn'});
|
||||
const before = structuredClone(custody(fresh.game, fresh.actor));
|
||||
const after = applyAction(fresh.game, fresh.actor, {type: 'endMovement'});
|
||||
assert.deepEqual(custody(after, fresh.actor), before);
|
||||
assert.equal(viewGame(fresh.game, fresh.actor).advancedAllySeparation, null);
|
||||
const old = createAdvancedSourceFixture({advanced: false, alliance: 'laterEnding'});
|
||||
assert.equal(old.turn, 3);
|
||||
assert.ok(!old.game.movementRemaining?.includes(old.ally!));
|
||||
const own = group(old.game, old.actor, old.lossTerritories);
|
||||
const ally = structuredClone(custody(old.game, old.ally!));
|
||||
const ended = applyAction(old.game, old.actor, {type: 'endMovement'});
|
||||
assert.equal(group(ended, old.actor, old.lossTerritories).tanks, own.tanks + own.amount);
|
||||
assert.equal(group(ended, old.actor, old.lossTerritories).amount, 0);
|
||||
assert.deepEqual(custody(ended, old.ally!), ally);
|
||||
});
|
||||
|
||||
void test('native advisor stance exempts both directions; Ecaz and Polar custody also survives end', () => {
|
||||
for (const alliance of ['ownAdvisors', 'allyAdvisors', 'ecaz', 'polar'] as const) {
|
||||
const fixture = createAdvancedSourceFixture({alliance});
|
||||
const {game, actor, ally} = fixture;
|
||||
const where = alliance === 'ecaz' ? ['imperial_basin'] : alliance === 'polar' ? ['polar_sink'] : ['arrakeen'];
|
||||
const own = group(game, actor, where);
|
||||
assert.ok(own.amount > 0);
|
||||
assert.ok(group(game, ally!, where).amount > 0);
|
||||
assert.equal(viewGame(game, actor).advancedAllySeparation, null);
|
||||
const ownCustody = structuredClone(custody(game, actor));
|
||||
const other = structuredClone(custody(game, ally!));
|
||||
const ended = applyAction(game, actor, {type: 'endMovement'});
|
||||
assert.deepEqual(group(ended, actor, where), own);
|
||||
assert.deepEqual(custody(ended, actor), ownCustody);
|
||||
assert.deepEqual(custody(ended, ally!), other);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual dealt Hajr retires once and the final native end still applies exactly one owned loss', () => {
|
||||
const fixture = createAdvancedSourceFixture({alliance: 'firstEnding', hajr: true});
|
||||
let game = fixture.game;
|
||||
const actor = fixture.actor;
|
||||
const card = game.players.find(p => p.id === actor)!.hand.find(c => c.effect === 'hajr')!;
|
||||
const own = group(game, actor, fixture.lossTerritories);
|
||||
game = applyAction(game, actor, {type: 'card', card: card.id});
|
||||
assert.deepEqual(group(game, actor, fixture.lossTerritories), own);
|
||||
assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
|
||||
assert.ok(game.hajr.includes(actor));
|
||||
game = applyAction(reload(game), actor, {type: 'endMovement'});
|
||||
assert.equal(group(game, actor, fixture.lossTerritories).tanks, own.tanks + own.amount);
|
||||
assert.equal(game.discard.filter(c => c.id === card.id).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,600 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, type GameView } from '../game/engine';
|
||||
import { stormCardDistance, isStormCardDistance } from '../game/storm-cards';
|
||||
import { createStormSource } from '../game/discovery-storm';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { RoomsClock, SeatAuth } from '../db/rooms';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { splitLocation, territory } from '../game/board';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { createAdvancedSourceFixture, createRecordedAdvancedSourceFixture, advanceToForecast, advanceToNextStorm,
|
||||
type AdvancedSourceFixtureOptions as Options, type AdvancedSourceFixture } from './fixture-advanced-source';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
type SqlRow = Record<string, SQLOutputValue>;
|
||||
interface Fixture extends AdvancedSourceFixture {
|
||||
sqlite: DatabaseSync;
|
||||
restart(): typeof Rooms;
|
||||
hooks: { beforeWrite?: () => Promise<void> };
|
||||
initial: Game;
|
||||
code: string;
|
||||
tokens: string[];
|
||||
auths: SeatAuth[];
|
||||
save(g: Game): void;
|
||||
credentialRows: { seats: SqlRow[]; entries: SqlRow[]; keys: SqlRow[]; recoveries: SqlRow[] };
|
||||
unrelatedCode: string;
|
||||
unrelatedToken: string;
|
||||
unrelatedRow: SqlRow | undefined;
|
||||
stock: string[];
|
||||
forces: { id: string; total: number; elite: number }[];
|
||||
}
|
||||
function player(g: Game, id: string) {
|
||||
const p = g.players.find(seat => seat.id === id);
|
||||
assert.ok(p, `missing native seat ${id}`);
|
||||
return p;
|
||||
}
|
||||
function credentials(sqlite: DatabaseSync) {
|
||||
return {
|
||||
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
|
||||
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,operation_hash').all(),
|
||||
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(),
|
||||
recoveries: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(),
|
||||
};
|
||||
}
|
||||
function forceStock(g: Game) {
|
||||
return g.players.map(p => ({ id: p.id,
|
||||
total: p.reserves + p.tanks + Object.values(p.forces).reduce((n, amount) => n + amount, 0),
|
||||
elite: p.elites ? p.elites.reserves + p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((n, amount) => n + amount, 0) : 0 }));
|
||||
}
|
||||
function cardStock(g: Game) {
|
||||
const ids = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
|
||||
...(g.auction?.cards.slice(g.auction.index) ?? [])].map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'each physical Treachery card has one custodian');
|
||||
return ids;
|
||||
}
|
||||
function receiptFaces(g: { log: readonly { component?: unknown }[] }) {
|
||||
return g.log.flatMap(entry => {
|
||||
const distance = stormCardDistance(entry.component);
|
||||
return distance === null ? [] : [distance];
|
||||
});
|
||||
}
|
||||
async function fixture(t: test.TestContext, options: Options = {}, recorded?: Game): Promise<Fixture> {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const factions: FactionId[] = recorded ? recorded.players.map(p => p.faction) :
|
||||
options.alliance === 'ecaz' ? ['ecaz', 'fremen', 'atreides'] :
|
||||
options.alliance === 'ownAdvisors' || options.alliance === 'allyAdvisors' ? ['atreides', 'beneGesserit', 'fremen'] :
|
||||
options.alliance === 'firstEnding' || options.alliance === 'laterEnding' || options.alliance === 'newThisTurn' ?
|
||||
['atreides', 'emperor', 'guild'] : options.alliance ? ['atreides', 'fremen', 'guild'] : ['atreides', 'emperor'];
|
||||
if (!options.alliance && options.fremen) factions.push('fremen');
|
||||
const made = await store.rooms.createRoom('Advanced source recovery', factions[0], options.advanced ?? true,
|
||||
options.alliance === 'ecaz' ? ['ecaz'] : []);
|
||||
const code = made.view.code, tokens = [made.token];
|
||||
for (const faction of factions.slice(1)) tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
let auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
if (recorded) {
|
||||
// Bind isolated credentials to the already native saved IDs, never rewrite
|
||||
// the captured Game's identity or its historical source.
|
||||
for (const [index, auth] of auths.entries())
|
||||
assert.equal(store.sqlite.prepare('UPDATE seats SET player_id = ? WHERE room_code = ? AND token_hash = ?')
|
||||
.run(recorded.players[index].id, code, auth.tokenHash).changes, 1);
|
||||
auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
}
|
||||
const native = recorded ? createRecordedAdvancedSourceFixture(recorded) :
|
||||
createAdvancedSourceFixture({ ...options, seatIds: auths.map(auth => auth.playerId) });
|
||||
const initial = native.game;
|
||||
assert.deepEqual(initial.players.map(p => p.faction), factions, 'authenticated seats match the native constructor roster');
|
||||
initial.code = code;
|
||||
initial.host = auths[0].playerId;
|
||||
initial.version = (await store.rooms.readRoom(code)).version;
|
||||
const save = (g: Game) => {
|
||||
assert.equal(store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(g), g.version, code).changes, 1);
|
||||
};
|
||||
save(initial);
|
||||
const unrelated = await store.rooms.createRoom('Unrelated preserved room', 'harkonnen', false, []);
|
||||
return { ...store, ...native, initial, code, tokens, auths, save,
|
||||
credentialRows: credentials(store.sqlite), unrelatedCode: unrelated.view.code,
|
||||
unrelatedToken: unrelated.token,
|
||||
unrelatedRow: store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(unrelated.view.code),
|
||||
stock: cardStock(initial), forces: forceStock(initial) };
|
||||
}
|
||||
const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.code);
|
||||
function totalChanges(f: Fixture) {
|
||||
const n = f.sqlite.prepare('SELECT total_changes() AS changes').get()?.changes;
|
||||
assert.equal(typeof n, 'number');
|
||||
return n as number;
|
||||
}
|
||||
function protectedRows(f: Fixture) {
|
||||
assert.deepEqual(credentials(f.sqlite), f.credentialRows);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.unrelatedCode), f.unrelatedRow);
|
||||
}
|
||||
async function authenticate(f: Fixture, id: string) {
|
||||
const index = f.auths.findIndex(auth => auth.playerId === id);
|
||||
assert.ok(index >= 0);
|
||||
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
|
||||
assert.equal(auth.playerId, id);
|
||||
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
|
||||
return auth;
|
||||
}
|
||||
async function act(f: Fixture, g: Game, id: string, action: Action) {
|
||||
const changes = totalChanges(f);
|
||||
await f.restart().act(f.code, await authenticate(f, id), g.version, action, clock);
|
||||
assert.equal(totalChanges(f), changes + 1, 'one authenticated native action commits one room row');
|
||||
protectedRows(f);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
async function rejected(f: Fixture, g: Game, auth: SeatAuth, action: Action, version = g.version) {
|
||||
const before = row(f), changes = totalChanges(f);
|
||||
await assert.rejects(f.restart().act(f.code, auth, version, action, clock));
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(totalChanges(f), changes, 'rejected action writes no durable table');
|
||||
protectedRows(f);
|
||||
}
|
||||
async function restored(f: Fixture, g: Game) {
|
||||
const before = row(f), changes = totalChanges(f), original = plain(g);
|
||||
assert.deepEqual(plain(await f.restart().readRoom(f.code)), original);
|
||||
assert.deepEqual(plain(normalizeAutomaticGame(g)), original);
|
||||
const views: GameView[] = [];
|
||||
for (const auth of f.auths) {
|
||||
const view = await f.restart().readSeatView(f.code, await authenticate(f, auth.playerId));
|
||||
assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId)));
|
||||
for (const key of ['stormMovementSource', 'stormResolution', 'stormCard', 'stormCardKnown', 'stormDials'])
|
||||
assert.equal(Object.hasOwn(view, key), false, `${key} stays outside the public projection`);
|
||||
for (const rival of view.players.filter(p => p.id !== auth.playerId))
|
||||
for (const key of ['hand', 'traitors', 'spice']) assert.equal(Object.hasOwn(rival, key), false);
|
||||
views.push(view);
|
||||
}
|
||||
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock), f.restart().continueRoomAutomatic(f.code, clock)]);
|
||||
assert.deepEqual(plain(g), original, 'read and normalize leave their caller-owned input immutable');
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(totalChanges(f), changes, 'restart, reads and idle recovery perform no durable writes');
|
||||
assert.deepEqual(cardStock(g), f.stock);
|
||||
protectedRows(f);
|
||||
return views;
|
||||
}
|
||||
async function readyStorm(f: Fixture, state: Game) {
|
||||
let g = state;
|
||||
for (const id of state.players.map(p => p.id)) g = await act(f, g, id, { type: 'ready' });
|
||||
return g;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} first Storm preserves actual private dials across authenticated restart`, async t => {
|
||||
const f = await fixture(t, { advanced, storm: 'first' });
|
||||
let g = f.initial;
|
||||
assert.equal(g.turn, 1);
|
||||
assert.equal(g.stormDialers.length, 2);
|
||||
assert.equal(g.stormPending, null);
|
||||
await restored(f, g);
|
||||
const [first, second] = g.stormDialers;
|
||||
g = await act(f, g, first, { type: 'stormDial', amount: 2 });
|
||||
for (const view of await restored(f, g)) {
|
||||
assert.equal(view.stormRevealed, null);
|
||||
assert.deepEqual(view.stormSubmitted, [first]);
|
||||
assert.equal(view.stormForecast, null);
|
||||
}
|
||||
await rejected(f, g, await authenticate(f, first), { type: 'stormDial', amount: 3 });
|
||||
await rejected(f, g, await authenticate(f, second), { type: 'stormDial', amount: 0 }, f.initial.version);
|
||||
g = await act(f, g, second, { type: 'stormDial', amount: 0 });
|
||||
assert.equal(g.stormPending, 2);
|
||||
assert.equal(g.stormMovementSource?.kind, 'dials');
|
||||
assert.equal(g.stormMovementSource?.distance, 2);
|
||||
for (const view of await restored(f, g)) assert.deepEqual(view.stormRevealed, { [first]: 2, [second]: 0 });
|
||||
const origin = g.storm;
|
||||
g = await readyStorm(f, g);
|
||||
assert.equal(g.storm, ((origin + 1) % 18) + 1);
|
||||
assert.equal(g.phase, 1);
|
||||
assert.equal(g.stormPending, null);
|
||||
assert.deepEqual(receiptFaces(g), []);
|
||||
await restored(f, g);
|
||||
});
|
||||
}
|
||||
|
||||
void test('SQLite subsequent Advanced Storm without Fremen reveals one canonical public card and traverses its saved distance once', async t => {
|
||||
const f = await fixture(t, { storm: 'next' });
|
||||
let g = f.initial;
|
||||
assert.equal(g.turn, 2);
|
||||
assert.equal(g.players.some(p => p.faction === 'fremen'), false);
|
||||
assert.deepEqual(g.stormDialers, []);
|
||||
assert.equal(g.stormCard, null, 'no absent faction receives a held private forecast');
|
||||
assert.equal(g.stormCardKnown, false);
|
||||
const face = g.stormPending;
|
||||
assert.ok(isStormCardDistance(face));
|
||||
assert.equal(g.stormMovementSource?.kind, 'card');
|
||||
assert.equal(g.stormMovementSource?.distance, face);
|
||||
assert.deepEqual(receiptFaces(g), [face]);
|
||||
for (const view of await restored(f, g)) {
|
||||
assert.deepEqual(view.stormDialers, []);
|
||||
assert.equal(view.stormRevealed, null);
|
||||
assert.equal(view.stormForecast, null);
|
||||
assert.equal(view.stormPending, face);
|
||||
assert.deepEqual(receiptFaces(view), [face]);
|
||||
}
|
||||
for (const auth of f.auths) await rejected(f, g, auth, { type: 'stormDial', amount: 0 });
|
||||
const origin = g.storm;
|
||||
g = await act(f, g, g.players[0].id, { type: 'ready' });
|
||||
await restored(f, g);
|
||||
assert.equal(g.stormPending, face);
|
||||
assert.deepEqual(receiptFaces(g), [face]);
|
||||
await rejected(f, g, f.auths[0], { type: 'ready' });
|
||||
for (const p of g.players.slice(1)) g = await act(f, g, p.id, { type: 'ready' });
|
||||
assert.equal(g.storm, ((origin + face - 1) % 18) + 1);
|
||||
assert.equal(g.stormPending, null);
|
||||
assert.equal(g.phase, 1);
|
||||
assert.notEqual(g.response?.kind, 'stormPeek', 'there is no absent-Fremen forecast response or extra confirmation');
|
||||
assert.equal(g.stormCard, null);
|
||||
assert.equal(g.stormCardKnown, false);
|
||||
assert.deepEqual(receiptFaces(g), [face]);
|
||||
await restored(f, g);
|
||||
await rejected(f, g, f.auths[0], { type: 'ready' }, f.initial.version);
|
||||
});
|
||||
|
||||
void test('SQLite subsequent Basic Storm without Fremen retains its native wheel protocol', async t => {
|
||||
const f = await fixture(t, { advanced: false, storm: 'next' });
|
||||
let g = f.initial;
|
||||
assert.equal(g.turn, 2);
|
||||
assert.equal(g.stormPending, null);
|
||||
assert.equal(g.stormDialers.length, 2);
|
||||
assert.deepEqual(receiptFaces(g), []);
|
||||
await restored(f, g);
|
||||
for (const [index, id] of g.stormDialers.entries()) g = await act(f, g, id, { type: 'stormDial', amount: index + 1 });
|
||||
assert.equal(g.stormPending, 3);
|
||||
assert.equal(g.stormMovementSource?.kind, 'dials');
|
||||
await restored(f, g);
|
||||
const origin = g.storm;
|
||||
g = await readyStorm(f, g);
|
||||
assert.equal(g.storm, ((origin + 2) % 18) + 1);
|
||||
assert.deepEqual(receiptFaces(g), []);
|
||||
await restored(f, g);
|
||||
});
|
||||
|
||||
void test('SQLite genuinely recorded Advanced wheel opening survives current restart, seals its original dials once and cuts over only on the next turn', async t => {
|
||||
// Synthetic native capture generated from exact 8a56299155d0f12c8c4678bfbe5913be71fea5bb:
|
||||
// createGame Advanced Atreides/Emperor, actual readiness/start and setup,
|
||||
// then legal first-turn phase actions through the original next-Storm opener.
|
||||
const captured = JSON.parse(readFileSync(fileURLToPath(new URL('./fixtures/advanced-recorded-storm-8a.json',
|
||||
import.meta.url)), 'utf8')) as Game;
|
||||
const original = plain(captured);
|
||||
const f = await fixture(t, {}, captured);
|
||||
assert.deepEqual(captured, original, 'the stored native capture remains caller-owned and immutable');
|
||||
let g = f.initial;
|
||||
assert.equal(g.advanced, true);
|
||||
assert.equal(g.turn, 2);
|
||||
assert.equal(g.phase, 0);
|
||||
assert.equal(g.players.some(p => p.faction === 'fremen'), false);
|
||||
assert.equal(g.players.find(p => p.faction === 'emperor')?.elites?.reserves, 5,
|
||||
'the historical setup is genuinely Advanced, not a changed Basic flag');
|
||||
assert.deepEqual(g.players, captured.players);
|
||||
assert.deepEqual(g.stormDialers, captured.stormDialers);
|
||||
assert.equal(g.stormPending, null);
|
||||
assert.equal(g.stormMovementSource, undefined);
|
||||
await restored(f, g);
|
||||
const [first, second] = g.stormDialers;
|
||||
g = await act(f, g, first, { type: 'stormDial', amount: 3 });
|
||||
await restored(f, g);
|
||||
assert.deepEqual(g.stormDialers, captured.stormDialers);
|
||||
assert.deepEqual(g.stormDials, { [first]: 3 });
|
||||
assert.equal(g.stormPending, null);
|
||||
await rejected(f, g, await authenticate(f, first), { type: 'stormDial', amount: 1 });
|
||||
await rejected(f, g, await authenticate(f, second), { type: 'stormDial', amount: 1 }, f.initial.version);
|
||||
g = await act(f, g, second, { type: 'stormDial', amount: 1 });
|
||||
assert.equal(g.stormPending, 4);
|
||||
assert.equal(g.stormMovementSource?.kind, 'dials');
|
||||
assert.equal(g.stormMovementSource?.distance, 4);
|
||||
for (const view of await restored(f, g)) {
|
||||
assert.deepEqual(view.stormRevealed, { [first]: 3, [second]: 1 });
|
||||
assert.equal(view.stormForecast, null);
|
||||
assert.deepEqual(receiptFaces(view), []);
|
||||
}
|
||||
const origin = g.storm;
|
||||
g = await act(f, g, first, { type: 'ready' });
|
||||
await restored(f, g);
|
||||
g = await race(f, g, second, { type: 'ready' });
|
||||
assert.equal(g.storm, ((origin + 3) % 18) + 1);
|
||||
assert.equal(g.phase, 1);
|
||||
assert.deepEqual(receiptFaces(g), []);
|
||||
await restored(f, g);
|
||||
await rejected(f, g, await authenticate(f, second), { type: 'ready' }, g.version - 1);
|
||||
g = advanceToNextStorm(g);
|
||||
f.save(g);
|
||||
assert.equal(g.turn, 3);
|
||||
assert.deepEqual(g.stormDialers, []);
|
||||
assert.equal(g.stormMovementSource?.kind, 'card');
|
||||
assert.ok(isStormCardDistance(g.stormPending));
|
||||
assert.deepEqual(receiptFaces(g), [g.stormPending]);
|
||||
for (const view of await restored(f, g)) {
|
||||
assert.equal(view.stormRevealed, null);
|
||||
assert.equal(view.stormForecast, null);
|
||||
}
|
||||
});
|
||||
|
||||
for (const cancel of [false, true]) {
|
||||
void test(`SQLite native Fremen forecast remains private after ${cancel ? 'a genuine Karama counter' : 'all responses pass'} and supplies the next public card`, async t => {
|
||||
const f = await fixture(t, { fremen: true, storm: 'first', karama: true });
|
||||
let g = advanceToForecast(f.initial);
|
||||
f.save(g);
|
||||
assert.equal(g.response?.kind, 'stormPeek');
|
||||
const face = g.stormCard;
|
||||
assert.ok(isStormCardDistance(face));
|
||||
const fremen = g.players.find(p => p.faction === 'fremen')!;
|
||||
const counter = g.players.find(p => p.faction !== 'fremen' && p.hand.some(c => c.effect === 'karama'));
|
||||
assert.ok(counter, 'the native constructor genuinely dealt a counter even when it will pass');
|
||||
const card = counter.hand.find(c => c.effect === 'karama')!;
|
||||
assert.ok(viewGame(g, counter.id).responseControls?.cancelCards.includes(card.id));
|
||||
assert.deepEqual(forceStock(g), f.forces);
|
||||
for (const view of await restored(f, g)) assert.equal(view.stormForecast, null);
|
||||
if (cancel) {
|
||||
g = await act(f, g, counter.id, { type: 'card', card: card.id, mode: 'cancel' });
|
||||
} else {
|
||||
while (g.response) {
|
||||
const responder = g.players.find(p => !g.response!.passed.includes(p.id));
|
||||
assert.ok(responder);
|
||||
g = await act(f, g, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
}
|
||||
assert.equal(g.stormCard, face, 'a counter hides knowledge without replacing the physical upcoming card');
|
||||
assert.equal(player(g, counter.id).hand.some(c => c.id === card.id), !cancel);
|
||||
assert.equal(g.discard.filter(c => c.id === card.id).length, cancel ? 1 : 0);
|
||||
assert.deepEqual(forceStock(g), f.forces);
|
||||
for (const view of await restored(f, g)) {
|
||||
assert.equal(view.stormForecast, !cancel && view.me === fremen.id ? face : null);
|
||||
assert.deepEqual(receiptFaces(view), []);
|
||||
}
|
||||
g = advanceToNextStorm(g);
|
||||
f.save(g);
|
||||
assert.equal(g.stormPending, face);
|
||||
assert.deepEqual(g.stormDialers, []);
|
||||
assert.equal(g.stormCard, null);
|
||||
assert.deepEqual(forceStock(g), f.forces);
|
||||
for (const view of await restored(f, g)) {
|
||||
assert.equal(view.stormForecast, null);
|
||||
assert.deepEqual(receiptFaces(view), [face]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function race(f: Fixture, g: Game, id: string, action: Action) {
|
||||
const auth = await authenticate(f, id), changes = totalChanges(f);
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
try {
|
||||
const results = await Promise.allSettled([0, 1].map(() =>
|
||||
f.restart().act(f.code, auth, g.version, action, clock).finally(release)));
|
||||
assert.equal(arrivals, 2, 'both native submissions reach the SQLite version fence');
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
|
||||
} finally { delete f.hooks.beforeWrite; }
|
||||
assert.equal(totalChanges(f), changes + 1, 'only the CAS winner changes any durable row');
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, g.version + 1);
|
||||
protectedRows(f);
|
||||
return done;
|
||||
}
|
||||
|
||||
void test('SQLite competing final Advanced card confirmations commit the saved distance once', async t => {
|
||||
const f = await fixture(t, { storm: 'next' });
|
||||
let g = f.initial;
|
||||
const origin = g.storm, face = g.stormPending;
|
||||
assert.ok(isStormCardDistance(face));
|
||||
for (const p of g.players.slice(0, -1)) g = await act(f, g, p.id, { type: 'ready' });
|
||||
await restored(f, g);
|
||||
const actor = g.players[g.players.length - 1].id;
|
||||
g = await race(f, g, actor, { type: 'ready' });
|
||||
assert.equal(g.storm, ((origin + face - 1) % 18) + 1);
|
||||
assert.equal(g.phase, 1);
|
||||
assert.deepEqual(receiptFaces(g), [face]);
|
||||
await restored(f, g);
|
||||
await rejected(f, g, await authenticate(f, actor), { type: 'ready' }, g.version - 1);
|
||||
});
|
||||
|
||||
function custody(g: Game, id: string) {
|
||||
const p = player(g, id);
|
||||
return { forces: p.forces, elites: p.elites ?? null, advisors: p.advisors ?? null,
|
||||
reserves: p.reserves, tanks: p.tanks, noField: p.noField ?? null, hand: p.hand };
|
||||
}
|
||||
function lossCounts(g: Game, id: string, territories: readonly string[]) {
|
||||
const p = player(g, id);
|
||||
return {
|
||||
total: Object.entries(p.forces).reduce((n, [key, amount]) =>
|
||||
n + (territories.includes(splitLocation(key).territory) ? amount : 0), 0),
|
||||
elite: Object.entries(p.elites?.forces ?? {}).reduce((n, [key, amount]) =>
|
||||
n + (territories.includes(splitLocation(key).territory) ? amount : 0), 0),
|
||||
};
|
||||
}
|
||||
for (const alliance of ['firstEnding', 'laterEnding'] as const) {
|
||||
void test(`SQLite Advanced ${alliance} newly allied ending sends only that player's shared group to Tanks once through CAS`, async t => {
|
||||
const f = await fixture(t, { alliance });
|
||||
const before = f.initial, actor = player(before, f.actor);
|
||||
assert.equal(before.phase, 5);
|
||||
assert.equal(before.active, actor.id);
|
||||
assert.ok(f.ally);
|
||||
assert.equal(actor.ally, f.ally);
|
||||
assert.equal(actor.allySinceTurn, before.turn, 'newly formed alliances receive the same mandatory ending consequence');
|
||||
assert.ok(before.movementRemaining?.includes(f.ally), 'the ally has not reached its own ending yet');
|
||||
assert.equal(before.order.indexOf(actor.id), alliance === 'firstEnding' ? 0 : 1,
|
||||
'the later scenario genuinely follows a completed nonallied seat');
|
||||
if (alliance === 'laterEnding') {
|
||||
assert.ok(f.other);
|
||||
assert.equal(before.movementRemaining?.includes(f.other), false);
|
||||
}
|
||||
const losses = lossCounts(before, actor.id, f.lossTerritories);
|
||||
assert.ok(losses.total > 0, 'genuine native actions placed a shared fighter group');
|
||||
assert.equal(actor.faction, 'emperor');
|
||||
assert.equal(losses.elite, 1, 'the genuine paid shipment exercises elite classification within the physical loss');
|
||||
const otherCustody = before.players.filter(p => p.id !== actor.id).map(p => [p.id, plain(custody(before, p.id))]);
|
||||
const beforeTanks = actor.tanks, beforeEliteTanks = actor.elites?.tanks ?? 0;
|
||||
const outside = Object.fromEntries(Object.entries(actor.forces).filter(([key]) =>
|
||||
!f.lossTerritories.includes(splitLocation(key).territory)));
|
||||
const views = await restored(f, before);
|
||||
for (const view of views) assert.deepEqual(view.advancedAllySeparation,
|
||||
view.me === actor.id ? { territories: f.lossTerritories.map(id => territory(id).name) } : null);
|
||||
if (alliance === 'firstEnding') {
|
||||
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
|
||||
await rejected(f, before, { playerId: actor.id, tokenHash: foreign.tokenHash }, { type: 'endMovement' });
|
||||
await rejected(f, before, await authenticate(f, f.ally), { type: 'endMovement' });
|
||||
}
|
||||
const done = await race(f, before, actor.id, { type: 'endMovement' });
|
||||
const ended = player(done, actor.id);
|
||||
assert.equal(ended.tanks, beforeTanks + losses.total);
|
||||
assert.equal(ended.elites?.tanks ?? 0, beforeEliteTanks + losses.elite);
|
||||
assert.deepEqual(lossCounts(done, actor.id, f.lossTerritories), { total: 0, elite: 0 });
|
||||
assert.deepEqual(Object.fromEntries(Object.entries(ended.forces).filter(([key]) =>
|
||||
!f.lossTerritories.includes(splitLocation(key).territory))), outside);
|
||||
for (const [id, expected] of otherCustody) assert.deepEqual(custody(done, id as string), expected);
|
||||
assert.deepEqual(forceStock(done), f.forces, 'casualties conserve physical and elite inventory');
|
||||
assert.deepEqual(cardStock(done), f.stock);
|
||||
await restored(f, done);
|
||||
await rejected(f, done, await authenticate(f, actor.id), { type: 'endMovement' });
|
||||
await rejected(f, done, await authenticate(f, actor.id), { type: 'endMovement' }, before.version);
|
||||
assert.equal(player(await f.restart().readRoom(f.code), actor.id).tanks, beforeTanks + losses.total);
|
||||
});
|
||||
}
|
||||
|
||||
void test('SQLite native dealt Hajr retires without an early alliance casualty and the subsequent real ending loses the group once', async t => {
|
||||
const f = await fixture(t, { alliance: 'firstEnding', hajr: true });
|
||||
let g = f.initial;
|
||||
const actor = player(g, f.actor), card = actor.hand.find(c => c.effect === 'hajr');
|
||||
assert.ok(card, 'the actual native starting deal supplies Hajr to the ending actor');
|
||||
assert.ok(f.ally);
|
||||
const losses = lossCounts(g, actor.id, f.lossTerritories);
|
||||
assert.ok(losses.total > 0);
|
||||
const beforeForces = plain(actor.forces), beforeElite = plain(actor.elites), allyCustody = plain(custody(g, f.ally));
|
||||
g = await act(f, g, actor.id, { type: 'card', card: card.id });
|
||||
assert.equal(player(g, actor.id).tanks, actor.tanks);
|
||||
assert.deepEqual(player(g, actor.id).forces, beforeForces);
|
||||
assert.deepEqual(player(g, actor.id).elites, beforeElite);
|
||||
assert.equal(player(g, actor.id).hand.some(c => c.id === card.id), false);
|
||||
assert.equal(g.discard.filter(c => c.id === card.id).length, 1);
|
||||
assert.equal(g.hajr.includes(actor.id), true);
|
||||
await restored(f, g);
|
||||
g = await race(f, g, actor.id, { type: 'endMovement' });
|
||||
assert.equal(player(g, actor.id).tanks, actor.tanks + losses.total);
|
||||
assert.deepEqual(lossCounts(g, actor.id, f.lossTerritories), { total: 0, elite: 0 });
|
||||
assert.deepEqual(custody(g, f.ally), allyCustody);
|
||||
assert.equal(g.discard.filter(c => c.id === card.id).length, 1);
|
||||
assert.deepEqual(forceStock(g), f.forces);
|
||||
await restored(f, g);
|
||||
await rejected(f, g, await authenticate(f, actor.id), { type: 'card', card: card.id });
|
||||
await rejected(f, g, await authenticate(f, actor.id), { type: 'endMovement' });
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const alliance of ['ownAdvisors', 'allyAdvisors', 'ecaz', 'polar'] as const) {
|
||||
if (!advanced && (alliance === 'ownAdvisors' || alliance === 'allyAdvisors')) continue;
|
||||
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} ${alliance} retains both allied groups on genuine native ending`, async t => {
|
||||
const f = await fixture(t, { advanced, alliance });
|
||||
const before = f.initial;
|
||||
assert.equal(before.active, f.actor);
|
||||
assert.ok(f.ally);
|
||||
const shared = alliance === 'polar' ? 'polar_sink' : alliance === 'ecaz' ? 'imperial_basin' : 'arrakeen';
|
||||
assert.ok(lossCounts(before, f.actor, [shared]).total > 0);
|
||||
assert.ok(lossCounts(before, f.ally, [shared]).total > 0, 'the exception is exercised with actual co-present groups');
|
||||
if (alliance === 'ownAdvisors' || alliance === 'allyAdvisors') {
|
||||
const advisor = player(before, alliance === 'ownAdvisors' ? f.actor : f.ally);
|
||||
assert.equal(advisor.faction, 'beneGesserit');
|
||||
assert.ok(advisor.advisors?.[shared], 'actual BG advisor classification, not merely a BG faction');
|
||||
}
|
||||
const occupants = before.players.map(p => [p.id, plain(custody(before, p.id))]);
|
||||
for (const view of await restored(f, before)) assert.equal(view.advancedAllySeparation, null);
|
||||
const done = await act(f, before, f.actor, { type: 'endMovement' });
|
||||
for (const [id, expected] of occupants) assert.deepEqual(custody(done, id as string), expected);
|
||||
assert.deepEqual(forceStock(done), f.forces);
|
||||
await restored(f, done);
|
||||
await rejected(f, done, await authenticate(f, f.actor), { type: 'endMovement' }, before.version);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void test('SQLite Basic newly allied native movement retains the same-turn fighter group without an Advanced warning', async t => {
|
||||
const f = await fixture(t, { advanced: false, alliance: 'newThisTurn' });
|
||||
const actor = player(f.initial, f.actor);
|
||||
assert.equal(actor.allySinceTurn, f.initial.turn);
|
||||
assert.ok(lossCounts(f.initial, f.actor, f.lossTerritories).total > 0);
|
||||
assert.ok(f.ally && lossCounts(f.initial, f.ally, f.lossTerritories).total > 0);
|
||||
const before = plain(custody(f.initial, f.actor));
|
||||
for (const view of await restored(f, f.initial)) assert.equal(view.advancedAllySeparation, null);
|
||||
const done = await act(f, f.initial, f.actor, { type: 'endMovement' });
|
||||
assert.deepEqual(custody(done, f.actor), before);
|
||||
await restored(f, done);
|
||||
});
|
||||
|
||||
void test('SQLite Basic old alliance loses only the later ending player after the native earlier ally has retained its group', async t => {
|
||||
const f = await fixture(t, { advanced: false, alliance: 'laterEnding' });
|
||||
const before = f.initial, actor = player(before, f.actor);
|
||||
assert.ok(f.ally);
|
||||
assert.ok((actor.allySinceTurn ?? before.turn) < before.turn);
|
||||
assert.equal(before.movementRemaining?.includes(f.ally), false);
|
||||
const losses = lossCounts(before, actor.id, f.lossTerritories);
|
||||
assert.ok(losses.total > 0);
|
||||
assert.ok(lossCounts(before, f.ally, f.lossTerritories).total > 0, 'the earlier ending ally genuinely retained its shared group');
|
||||
const earlierCustody = plain(custody(before, f.ally));
|
||||
await restored(f, before);
|
||||
const done = await race(f, before, actor.id, { type: 'endMovement' });
|
||||
assert.equal(player(done, actor.id).tanks, actor.tanks + losses.total);
|
||||
assert.deepEqual(lossCounts(done, actor.id, f.lossTerritories), { total: 0, elite: 0 });
|
||||
assert.deepEqual(custody(done, f.ally), earlierCustody);
|
||||
assert.deepEqual(forceStock(done), f.forces);
|
||||
await restored(f, done);
|
||||
await rejected(f, done, await authenticate(f, actor.id), { type: 'endMovement' });
|
||||
});
|
||||
|
||||
void test('SQLite saved Advanced source actions reject foreign, forged-seat and stale credentials without changing unrelated rows', async t => {
|
||||
const f = await fixture(t, { storm: 'next' });
|
||||
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
|
||||
const before = row(f), changes = totalChanges(f);
|
||||
await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken));
|
||||
await assert.rejects(f.restart().readSeatView(f.code, foreign));
|
||||
const forged = { playerId: f.auths[0].playerId, tokenHash: foreign.tokenHash };
|
||||
await assert.rejects(f.restart().readSeatView(f.code, forged));
|
||||
assert.equal(totalChanges(f), changes);
|
||||
assert.deepEqual(row(f), before);
|
||||
await rejected(f, f.initial, foreign, { type: 'ready' });
|
||||
await rejected(f, f.initial, forged, { type: 'ready' });
|
||||
await rejected(f, f.initial, f.auths[0], { type: 'ready' }, f.initial.version - 1);
|
||||
await restored(f, f.initial);
|
||||
});
|
||||
|
||||
for (const corrupt of [
|
||||
{ name: 'altered source distance', change: (g: Game) => { g.stormMovementSource!.distance++; } },
|
||||
{ name: 're-signed source from another turn', change: (g: Game) => {
|
||||
g.stormMovementSource = createStormSource(g.turn - 1, 'card', g.stormPending!);
|
||||
} },
|
||||
{ name: 'orphaned pending distance', change: (g: Game) => { g.stormPending = g.stormPending === 6 ? 5 : 6; } },
|
||||
{ name: 'lost original source', change: (g: Game) => { delete g.stormMovementSource; } },
|
||||
{ name: 're-signed noncanonical card face', change: (g: Game) => {
|
||||
g.stormPending = 7;
|
||||
g.stormMovementSource = createStormSource(g.turn, 'card', 7);
|
||||
} },
|
||||
] as const) {
|
||||
void test(`SQLite Advanced ${corrupt.name} rejects projection, normalization and action immutably with no durable writes`, async t => {
|
||||
const f = await fixture(t, { storm: 'next' });
|
||||
const broken = plain(f.initial);
|
||||
corrupt.change(broken);
|
||||
f.save(broken);
|
||||
const original = plain(broken), before = row(f), changes = totalChanges(f);
|
||||
assert.deepEqual(await f.restart().readRoom(f.code), original, 'raw restart preserves the corrupted record for rejection, not repair');
|
||||
for (const auth of f.auths) {
|
||||
assert.throws(() => viewGame(broken, auth.playerId));
|
||||
await assert.rejects(f.restart().readSeatView(f.code, auth));
|
||||
}
|
||||
assert.throws(() => normalizeAutomaticGame(broken));
|
||||
assert.throws(() => applyAction(broken, f.actor, { type: 'ready' }));
|
||||
await rejected(f, broken, await authenticate(f, f.actor), { type: 'ready' });
|
||||
assert.deepEqual(plain(broken), original);
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(totalChanges(f), changes);
|
||||
protectedRows(f);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
quoteAlliedSeparation,
|
||||
type AlliedSeparationQuoteContext,
|
||||
} from '../game/allied-separation';
|
||||
|
||||
type TerritoryFacts = AlliedSeparationQuoteContext['territories'][number];
|
||||
|
||||
function group(
|
||||
territory: string,
|
||||
facts: Partial<Omit<TerritoryFacts, 'territory'>> = {},
|
||||
): TerritoryFacts {
|
||||
return {
|
||||
territory,
|
||||
polar: false,
|
||||
ownPresent: true,
|
||||
allyPresent: true,
|
||||
ownAdvisors: false,
|
||||
allyAdvisors: false,
|
||||
ecazCoexist: false,
|
||||
...facts,
|
||||
};
|
||||
}
|
||||
|
||||
function context(
|
||||
overrides: Partial<AlliedSeparationQuoteContext> = {},
|
||||
): AlliedSeparationQuoteContext {
|
||||
return {
|
||||
advanced: true,
|
||||
turn: 4,
|
||||
player: 'ending',
|
||||
ally: 'ally',
|
||||
playerAllySinceTurn: 2,
|
||||
allySinceTurn: 2,
|
||||
remaining: ['ending', 'ally', 'other'],
|
||||
territories: [group('arrakeen')],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
void test('Advanced separates on the first, intermediate and last allied ending regardless of alliance age', () => {
|
||||
for (const remaining of [
|
||||
['ending', 'ally', 'other'],
|
||||
['ending', 'other'],
|
||||
['ending'],
|
||||
]) {
|
||||
for (const timestamps of [
|
||||
{},
|
||||
{ playerAllySinceTurn: 2, allySinceTurn: 2 },
|
||||
{ playerAllySinceTurn: 4, allySinceTurn: 4 },
|
||||
{ playerAllySinceTurn: 4, allySinceTurn: 2 },
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
quoteAlliedSeparation(context({
|
||||
playerAllySinceTurn: undefined,
|
||||
allySinceTurn: undefined,
|
||||
remaining,
|
||||
...timestamps,
|
||||
})),
|
||||
['arrakeen'],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic waits for the ally to finish, then exempts only an alliance recorded new for both players', () => {
|
||||
const basic = context({ advanced: false });
|
||||
assert.deepEqual(quoteAlliedSeparation(basic), []);
|
||||
|
||||
const afterAlly = { ...basic, remaining: ['ending', 'other'] };
|
||||
assert.deepEqual(quoteAlliedSeparation(afterAlly), ['arrakeen']);
|
||||
assert.deepEqual(quoteAlliedSeparation({
|
||||
...afterAlly,
|
||||
playerAllySinceTurn: 4,
|
||||
allySinceTurn: 4,
|
||||
}), []);
|
||||
|
||||
for (const timestamps of [
|
||||
{ playerAllySinceTurn: 4, allySinceTurn: 2 },
|
||||
{ playerAllySinceTurn: 2, allySinceTurn: 4 },
|
||||
{ playerAllySinceTurn: undefined, allySinceTurn: undefined },
|
||||
{ playerAllySinceTurn: 4, allySinceTurn: undefined },
|
||||
{ playerAllySinceTurn: undefined, allySinceTurn: 4 },
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
quoteAlliedSeparation({ ...afterAlly, ...timestamps }),
|
||||
['arrakeen'],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a Basic same-turn alliance loses its exemption next turn while Advanced separates on both turns', () => {
|
||||
const newAlliance = context({
|
||||
advanced: false,
|
||||
playerAllySinceTurn: 4,
|
||||
allySinceTurn: 4,
|
||||
remaining: ['ending'],
|
||||
});
|
||||
assert.deepEqual(quoteAlliedSeparation(newAlliance), []);
|
||||
assert.deepEqual(quoteAlliedSeparation({ ...newAlliance, turn: 5 }), ['arrakeen']);
|
||||
assert.deepEqual(quoteAlliedSeparation({ ...newAlliance, advanced: true }), ['arrakeen']);
|
||||
assert.deepEqual(quoteAlliedSeparation({ ...newAlliance, advanced: true, turn: 5 }), ['arrakeen']);
|
||||
});
|
||||
|
||||
void test('only shared non-exempt territories are quoted, preserving each territory independently', () => {
|
||||
const territories = [
|
||||
group('arrakeen'),
|
||||
group('carthag', { ownPresent: false }),
|
||||
group('broken_land', { allyPresent: false }),
|
||||
group('polar_sink', { polar: true }),
|
||||
group('sietch_tabr', { ecazCoexist: true }),
|
||||
group('hagga_basin'),
|
||||
];
|
||||
for (const advanced of [false, true]) {
|
||||
assert.deepEqual(quoteAlliedSeparation(context({
|
||||
advanced,
|
||||
remaining: ['ending'],
|
||||
territories,
|
||||
})), ['arrakeen', 'hagga_basin']);
|
||||
}
|
||||
});
|
||||
|
||||
void test('ending without an ally never loses forces even when territory facts show shared presence', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
assert.deepEqual(quoteAlliedSeparation(context({
|
||||
advanced,
|
||||
ally: null,
|
||||
remaining: ['ending'],
|
||||
territories: [group('arrakeen'), group('hagga_basin')],
|
||||
})), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced advisors on either side coexist, but conversion to fighters restores separation', () => {
|
||||
for (const stance of ['ownAdvisors', 'allyAdvisors'] as const) {
|
||||
const advisors = context({
|
||||
territories: [group('arrakeen', { [stance]: true }), group('hagga_basin')],
|
||||
});
|
||||
assert.deepEqual(quoteAlliedSeparation(advisors), ['hagga_basin']);
|
||||
assert.deepEqual(quoteAlliedSeparation({
|
||||
...advisors,
|
||||
territories: [group('arrakeen'), group('hagga_basin')],
|
||||
}), ['arrakeen', 'hagga_basin']);
|
||||
}
|
||||
assert.deepEqual(quoteAlliedSeparation(context({
|
||||
territories: [group('arrakeen', { ownAdvisors: true, allyAdvisors: true })],
|
||||
})), []);
|
||||
});
|
||||
|
||||
void test('advisor facts do not introduce a new exemption into Basic legacy separation', () => {
|
||||
for (const stances of [
|
||||
{ ownAdvisors: true },
|
||||
{ allyAdvisors: true },
|
||||
{ ownAdvisors: true, allyAdvisors: true },
|
||||
]) {
|
||||
assert.deepEqual(quoteAlliedSeparation(context({
|
||||
advanced: false,
|
||||
remaining: ['ending'],
|
||||
territories: [group('arrakeen', stances)],
|
||||
})), ['arrakeen']);
|
||||
}
|
||||
});
|
||||
|
||||
void test('loss follows current Ecaz coexistence and presence rather than a previous exemption or quote', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const occupation = context({
|
||||
advanced,
|
||||
remaining: ['ending'],
|
||||
territories: [group('arrakeen', { ecazCoexist: true })],
|
||||
});
|
||||
assert.deepEqual(quoteAlliedSeparation(occupation), []);
|
||||
const suppressed = { ...occupation, territories: [group('arrakeen')] };
|
||||
assert.deepEqual(quoteAlliedSeparation(suppressed), ['arrakeen']);
|
||||
assert.deepEqual(quoteAlliedSeparation({
|
||||
...suppressed,
|
||||
territories: [group('arrakeen', { allyPresent: false })],
|
||||
}), []);
|
||||
assert.deepEqual(quoteAlliedSeparation(occupation), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('quoting frozen canonical facts does not change them or retain a mutable result', () => {
|
||||
const input = Object.freeze(context({
|
||||
remaining: Object.freeze(['ending', 'ally']),
|
||||
territories: Object.freeze([
|
||||
Object.freeze(group('arrakeen')),
|
||||
Object.freeze(group('carthag', { ownAdvisors: true })),
|
||||
Object.freeze(group('polar_sink', { polar: true })),
|
||||
]),
|
||||
}));
|
||||
const before = structuredClone(input);
|
||||
const result = quoteAlliedSeparation(input);
|
||||
assert.deepEqual(result, ['arrakeen']);
|
||||
result.splice(0, 1, 'carthag');
|
||||
assert.deepEqual(quoteAlliedSeparation(input), ['arrakeen']);
|
||||
assert.deepEqual(input, before);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { advanceToForecast, createAdvancedSourceFixture, settleAdvancedSourceResponses } from './fixture-advanced-source';
|
||||
|
||||
void test('all four policies dial the actual first Storm then confirm the recorded result exactly once', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const fixture = createAdvancedSourceFixture({ storm: 'first', fremen: false });
|
||||
let game = fixture.game;
|
||||
for (const dialer of fixture.game.stormDialers) {
|
||||
const own = viewGame(game, dialer);
|
||||
own.players.find(player => player.id === dialer)!.bot = profile;
|
||||
const actions = botActions(own);
|
||||
assert.equal(actions[0]?.type, 'stormDial');
|
||||
assert.ok(Number.isInteger(actions[0].amount));
|
||||
assert.ok(Number(actions[0].amount) >= 0 && Number(actions[0].amount) <= 20);
|
||||
game = applyAction(game, dialer, actions[0]);
|
||||
const submitted = viewGame(game, dialer);
|
||||
submitted.players.find(player => player.id === dialer)!.bot = profile;
|
||||
if (game.stormPending === null) assert.deepEqual(botActions(submitted), []);
|
||||
}
|
||||
assert.notEqual(game.stormPending, null);
|
||||
const actor = game.players[0].id;
|
||||
const own = viewGame(game, actor);
|
||||
own.players.find(player => player.id === actor)!.bot = profile;
|
||||
assert.deepEqual(botActions(own), [{ type: 'ready' }]);
|
||||
game = applyAction(game, actor, botActions(own)[0]);
|
||||
const confirmed = viewGame(game, actor);
|
||||
confirmed.players.find(player => player.id === actor)!.bot = profile;
|
||||
assert.deepEqual(botActions(confirmed), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four policies confirm subsequent Advanced public cards without a Fremen seat or phantom forecast', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const fixture = createAdvancedSourceFixture({ storm: 'next', fremen: false });
|
||||
let game = fixture.game;
|
||||
assert.deepEqual(game.stormDialers, []);
|
||||
assert.ok(Number(game.stormPending) >= 1 && Number(game.stormPending) <= 6);
|
||||
const distance = game.stormPending;
|
||||
for (const player of fixture.game.players) {
|
||||
const own = viewGame(game, player.id);
|
||||
own.players.find(seat => seat.id === player.id)!.bot = profile;
|
||||
assert.equal(own.stormForecast, null);
|
||||
assert.deepEqual(botActions(own), [{ type: 'ready' }]);
|
||||
game = applyAction(game, player.id, botActions(own)[0]);
|
||||
}
|
||||
game = settleAdvancedSourceResponses(game);
|
||||
assert.notEqual(game.phase, 0);
|
||||
assert.equal(game.storm, ((fixture.game.storm - 1 + Number(distance)) % 18) + 1);
|
||||
assert.equal(game.stormCard, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four policies finish actual later Basic dial protocols without drawing a card', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const fixture = createAdvancedSourceFixture({ advanced: false, storm: 'next', fremen: false });
|
||||
let game = fixture.game;
|
||||
const amounts: number[] = [];
|
||||
for (const dialer of fixture.game.stormDialers) {
|
||||
const own = viewGame(game, dialer);
|
||||
own.players.find(player => player.id === dialer)!.bot = profile;
|
||||
const actions = botActions(own);
|
||||
assert.equal(actions[0]?.type, 'stormDial');
|
||||
const amount = Number(actions[0].amount);
|
||||
assert.ok(Number.isInteger(amount) && amount >= 1 && amount <= 3);
|
||||
amounts.push(amount);
|
||||
game = applyAction(game, dialer, actions[0]);
|
||||
}
|
||||
assert.equal(game.stormPending, amounts.reduce((sum, amount) => sum + amount, 0));
|
||||
assert.equal(game.stormMovementSource?.kind, 'dials');
|
||||
assert.equal(game.stormCard, null);
|
||||
const own = viewGame(game, game.players[0].id);
|
||||
own.players[0].bot = profile;
|
||||
assert.deepEqual(botActions(own), [{ type: 'ready' }]);
|
||||
game = applyAction(game, own.me, botActions(own)[0]);
|
||||
assert.ok(game.ready.includes(own.me));
|
||||
}
|
||||
});
|
||||
|
||||
void test('every legal policy retains a real end action when finishing destroys its own allied group', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const fixture = createAdvancedSourceFixture({ alliance: 'newThisTurn' });
|
||||
const own = viewGame(fixture.game, fixture.actor);
|
||||
own.players.find(player => player.id === fixture.actor)!.bot = profile;
|
||||
assert.ok(own.advancedAllySeparation?.territories.length);
|
||||
const end = botActions(own).find(action => action.type === 'endMovement');
|
||||
assert.ok(end, `${profile} must not deadlock on a mandatory consequence`);
|
||||
const before = fixture.game.players.find(player => player.id === fixture.actor)!;
|
||||
const allyBefore = fixture.game.players.find(player => player.id === fixture.ally)!;
|
||||
const losses = Object.entries(before.forces).filter(([key]) => fixture.lossTerritories.includes(splitLocation(key).territory))
|
||||
.reduce((sum, [, amount]) => sum + amount, 0);
|
||||
const eliteLosses = Object.entries(before.elites?.forces ?? {}).filter(([key]) => fixture.lossTerritories.includes(splitLocation(key).territory))
|
||||
.reduce((sum, [, amount]) => sum + amount, 0);
|
||||
const result = applyAction(fixture.game, fixture.actor, end);
|
||||
const after = result.players.find(player => player.id === fixture.actor)!;
|
||||
assert.equal(after.tanks, before.tanks + losses);
|
||||
assert.equal(after.elites?.tanks ?? 0, (before.elites?.tanks ?? 0) + eliteLosses);
|
||||
assert.equal(after.reserves, before.reserves);
|
||||
assert.equal(after.elites?.reserves, before.elites?.reserves);
|
||||
assert.deepEqual(after.hand, before.hand);
|
||||
for (const id of fixture.lossTerritories) {
|
||||
assert.equal(Object.entries(after.forces).filter(([key]) => key.startsWith(`${id}:`)).reduce((sum, [, amount]) => sum + amount, 0), 0);
|
||||
}
|
||||
assert.deepEqual(result.players.find(player => player.id === fixture.ally)!.forces, allyBefore.forces);
|
||||
const allyAfter = result.players.find(player => player.id === fixture.ally)!;
|
||||
assert.equal(allyAfter.reserves, allyBefore.reserves);
|
||||
assert.equal(allyAfter.tanks, allyBefore.tanks);
|
||||
assert.deepEqual(allyAfter.elites, allyBefore.elites);
|
||||
assert.deepEqual(allyAfter.advisors, allyBefore.advisors);
|
||||
assert.deepEqual(allyAfter.hand, allyBefore.hand);
|
||||
assert.notEqual(result.active, fixture.actor);
|
||||
assert.equal(viewGame(result, fixture.actor).advancedAllySeparation, null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four policies can finish with mandatory losses after their actual ordinary movement is exhausted', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const fixture = createAdvancedSourceFixture({ alliance: 'newThisTurn' });
|
||||
const own = viewGame(fixture.game, fixture.actor);
|
||||
const actor = own.players.find(player => player.id === fixture.actor)!;
|
||||
actor.bot = profile;
|
||||
const move = botActions(own).find(action => action.type === 'move' &&
|
||||
typeof action.from === 'string' && fixture.lossTerritories.includes(splitLocation(action.from).territory) &&
|
||||
(actor.forces[String(action.from)] ?? 0) > 1);
|
||||
assert.ok(move, `${profile} has an actual legal movement from its shared group`);
|
||||
const game = settleAdvancedSourceResponses(applyAction(fixture.game, fixture.actor,
|
||||
{ ...move, amount: 1, elite: 0 }));
|
||||
const exhausted = viewGame(game, fixture.actor);
|
||||
const afterMove = exhausted.players.find(player => player.id === fixture.actor)!;
|
||||
afterMove.bot = profile;
|
||||
assert.equal(afterMove.moved, afterMove.movesAllowed);
|
||||
assert.ok(exhausted.advancedAllySeparation?.territories.length);
|
||||
const end = botActions(exhausted).find(action => action.type === 'endMovement');
|
||||
assert.ok(end, `${profile} retains forced legal end instead of an invalid-movement loop`);
|
||||
const finished = applyAction(game, fixture.actor, end);
|
||||
assert.ok(finished.players.find(player => player.id === fixture.actor)!.tanks > afterMove.tanks);
|
||||
assert.notEqual(finished.active, fixture.actor);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native higher-priority forecast response cannot fall through to Storm confirmation or movement for any profile', () => {
|
||||
for (const profile of DIFFICULTIES) {
|
||||
const fixture = createAdvancedSourceFixture({ storm: 'first', fremen: true, karama: true });
|
||||
const pending = advanceToForecast(fixture.game);
|
||||
assert.equal(pending.response?.kind, 'stormPeek');
|
||||
const counter = pending.players.find(player => player.faction !== 'fremen' && player.hand.some(card => card.effect === 'karama'));
|
||||
assert.ok(counter, 'the native deal supplies a genuinely eligible forecast counter');
|
||||
const card = counter.hand.find(card => card.effect === 'karama')!;
|
||||
assert.ok(viewGame(pending, counter.id).responseControls?.cancelCards.includes(card.id));
|
||||
const forces = pending.players.map(player => ({id: player.id, forces: player.forces,
|
||||
reserves: player.reserves, tanks: player.tanks, elites: player.elites ?? null, advisors: player.advisors ?? null}));
|
||||
for (const player of pending.players) {
|
||||
const own = viewGame(pending, player.id);
|
||||
own.players.find(seat => seat.id === player.id)!.bot = profile;
|
||||
assert.equal(own.stormForecast, null);
|
||||
for (const action of botActions(own)) {
|
||||
assert.ok(action.type === 'passResponse' || (action.type === 'card' && action.mode === 'cancel'));
|
||||
const result = applyAction(pending, player.id, action);
|
||||
assert.equal(result.phase, pending.phase);
|
||||
assert.equal(result.storm, pending.storm);
|
||||
assert.deepEqual(result.players.map(player => ({id: player.id, forces: player.forces,
|
||||
reserves: player.reserves, tanks: player.tanks, elites: player.elites ?? null, advisors: player.advisors ?? null})), forces);
|
||||
for (const seat of result.players) {
|
||||
const before = pending.players.find(before => before.id === seat.id)!;
|
||||
assert.deepEqual(seat.hand, before.hand.filter(held =>
|
||||
!(action.type === 'card' && seat.id === player.id && held.id === action.card)));
|
||||
}
|
||||
if (action.type === 'card') assert.equal(result.discard.filter(discarded => discarded.id === action.card).length, 1);
|
||||
else assert.deepEqual(result.discard, pending.discard);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { createStormSource } from '../game/discovery-storm';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
||||
const send = (g: Game, id: string, action: Action) =>
|
||||
@@ -44,6 +45,8 @@ function fixture(advanced = false) {
|
||||
g.turn = 2;
|
||||
g.storm = 5;
|
||||
g.stormPending = 3;
|
||||
if (advanced)
|
||||
g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending);
|
||||
g.order = ['c', 'e', 'b'];
|
||||
for (const p of g.players) {
|
||||
p.hand = [];
|
||||
|
||||
@@ -2,11 +2,12 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, newPlayer, applyAction, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createStormSource } from '../game/discovery-storm';
|
||||
import {
|
||||
quoteChoamStormOffer,
|
||||
ChoamStormQuoteError,
|
||||
} from '../game/choam-storm-quote';
|
||||
function actual() {
|
||||
function actual(firstStorm = false) {
|
||||
let g = createGame('STORMQUOTE', newPlayer('c', 'CHOAM', 'choam'), true, [
|
||||
'choam',
|
||||
]);
|
||||
@@ -17,15 +18,19 @@ function actual() {
|
||||
Object.assign(g, {
|
||||
status: 'playing',
|
||||
phase: 0,
|
||||
turn: 2,
|
||||
storm: 5,
|
||||
stormPending: 3,
|
||||
turn: firstStorm ? 1 : 2,
|
||||
storm: firstStorm ? 0 : 5,
|
||||
stormPending: firstStorm ? null : 3,
|
||||
order: ['c', 'e', 'b'],
|
||||
deck: baseDeck(),
|
||||
});
|
||||
if (firstStorm) g.stormDialers = ['c', 'e'];
|
||||
else g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending!);
|
||||
for (const p of g.players)
|
||||
Object.assign(p, { forces: {}, reserves: 20, hand: [], spice: 10 });
|
||||
g.players[0].forces = { 'red_chasm:7': 4 };
|
||||
g.players[0].forces = firstStorm
|
||||
? { 'meridian:1': 2, 'cielago_north:3': 2 }
|
||||
: { 'red_chasm:7': 4 };
|
||||
g.players[0].reserves = 16;
|
||||
g.players[1].forces = { 'red_chasm:7': 3 };
|
||||
g.players[1].reserves = 17;
|
||||
@@ -39,13 +44,17 @@ function actual() {
|
||||
const jubba = hold(0, 'Jubba Cloak'),
|
||||
printed = hold(1, 'Karama'),
|
||||
bg = hold(2, 'Baliset');
|
||||
if (firstStorm) {
|
||||
g = applyAction(g, 'c', { type: 'stormDial', amount: 20 });
|
||||
g = applyAction(g, 'e', { type: 'stormDial', amount: 20 });
|
||||
}
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'choamStorm');
|
||||
g = applyAction(g, 'c', {
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: jubba.id,
|
||||
territory: 'red_chasm',
|
||||
territory: firstStorm ? 'meridian' : 'red_chasm',
|
||||
});
|
||||
assert.equal(g.response?.kind, 'choamWorthless');
|
||||
return { g, jubba, printed, bg };
|
||||
@@ -93,10 +102,7 @@ void test('lost current threat and prior protection both stop at storm protectio
|
||||
assert.equal(g.players[1].tanks, 0);
|
||||
});
|
||||
void test('initial storm origin zero and full forty-sector traversal remain finite and preserve sector identities', () => {
|
||||
const { g } = actual();
|
||||
g.storm = 0;
|
||||
Object.assign(g.stormResolution!, { from: 0, distance: 40 });
|
||||
g.players[0].forces = { 'meridian:1': 2, 'cielago_north:3': 2 };
|
||||
const { g } = actual(true);
|
||||
const q = quoteChoamStormOffer(g);
|
||||
assert.equal(q.kind, 'decision');
|
||||
if (q.kind !== 'decision') throw Error('No decision');
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as bots from '../game/bots';
|
||||
import * as seatAiDelegation from '../lib/seat-ai-delegation';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { newRevivalRules } from '../game/revival';
|
||||
import { createStormSource } from '../game/discovery-storm';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
|
||||
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
|
||||
@@ -153,6 +154,7 @@ async function fixture(source: Source, converted = true) {
|
||||
Object.assign(g.players[2], { reserves: 14, tanks: 6 });
|
||||
else {
|
||||
g.stormPending = 3;
|
||||
g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending);
|
||||
Object.assign(g.players[1], { forces: { 'red_chasm:7': 4 }, reserves: 16 });
|
||||
Object.assign(g.players[2], { forces: { 'red_chasm:7': 3 }, reserves: 17 });
|
||||
g.spice = { 'red_chasm:7': 8, 'basin:9': 4 };
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { newRevivalRules } from '../game/revival';
|
||||
import { createStormSource } from '../game/discovery-storm';
|
||||
import {
|
||||
quoteChoamWorthlessCancellation,
|
||||
ChoamWorthlessCancellationError,
|
||||
@@ -136,6 +137,7 @@ function source(kind: Case, advanced = true) {
|
||||
g.phase = 0;
|
||||
g.storm = 5;
|
||||
g.stormPending = 3;
|
||||
g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending);
|
||||
player(g, 'c').forces = { 'red_chasm:7': 4 };
|
||||
player(g, 'c').reserves = 16;
|
||||
g = ready(g);
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {applyAction, createGame, initializeBaseGameForAudit, initializeFactionExpansionsGameForAudit,
|
||||
initializeHomeworldGameForAudit, joinGame, newPlayer, viewGame, type Game, type Action} from '../game/engine';
|
||||
import {botActions} from '../game/bots';
|
||||
import {TERRITORIES, splitLocation} from '../game/board';
|
||||
import {fremenReserveEntry} from '../game/bot-mobility';
|
||||
import type {FactionId} from '../game/catalog';
|
||||
|
||||
export type AdvancedSourceFixtureOptions = {
|
||||
initial?: Game;
|
||||
advanced?: boolean;
|
||||
fremen?: boolean;
|
||||
seatIds?: string[];
|
||||
alliance?: 'firstEnding'|'laterEnding'|'newThisTurn'|'ownAdvisors'|'allyAdvisors'|'ecaz'|'polar';
|
||||
storm?: 'first'|'next';
|
||||
modules?: boolean;
|
||||
/** Reorder original dealt counters; false guarantees a counter-free native starting deal. */
|
||||
karama?: boolean;
|
||||
hajr?: boolean;
|
||||
};
|
||||
export type AdvancedSourceFixture = {
|
||||
game: Game;
|
||||
actor: string;
|
||||
ally: string | null;
|
||||
other: string | null;
|
||||
turn: number;
|
||||
lossTerritories: string[];
|
||||
};
|
||||
|
||||
function ordinaryAlliance(mode: AdvancedSourceFixtureOptions['alliance']): mode is 'firstEnding'|'laterEnding'|'newThisTurn' {
|
||||
return mode === 'firstEnding' || mode === 'laterEnding' || mode === 'newThisTurn';
|
||||
}
|
||||
|
||||
/** Accept a captured native historical Advanced opening without changing its
|
||||
* rules, source, dialers, setup inventories, saved identity or continuation. */
|
||||
export function createRecordedAdvancedSourceFixture(state: Game): AdvancedSourceFixture {
|
||||
const game = structuredClone(state);
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.advanced, true);
|
||||
assert.equal(game.phase, 0);
|
||||
assert.ok(game.turn > 1 && game.stormDialers.length === 2);
|
||||
assert.ok(game.stormPending === null || game.stormMovementSource?.kind === 'dials');
|
||||
for (const p of game.players) viewGame(game, p.id);
|
||||
return {game, actor: game.stormDialers[0], ally: null, other: game.stormDialers[1],
|
||||
turn: game.turn, lossTerritories: []};
|
||||
}
|
||||
|
||||
/** Only consumes a native pending window; never redeclares its parent action. */
|
||||
export function settleAdvancedSourceResponses(state: Game): Game {
|
||||
let game = state;
|
||||
for (let step = 0; step < 100; step++) {
|
||||
if (game.pendingTreacheryDiscard) game = applyAction(game, game.players[0].id, {type: 'advanceBots'});
|
||||
else if (game.response) game = applyAction(game,
|
||||
game.players.find(p => !game.response!.passed.includes(p.id))!.id, {type: 'passResponse'});
|
||||
else if (game.decision?.kind === 'guildShipment')
|
||||
game = applyAction(game, game.decision.player, {type: 'decision', allow: true});
|
||||
else return game;
|
||||
}
|
||||
throw new Error('Native Advanced source response did not settle.');
|
||||
}
|
||||
|
||||
function nativeStep(game: Game): Game {
|
||||
if (game.pendingTreacheryDiscard) return applyAction(game, game.players[0].id, {type: 'advanceBots'});
|
||||
if (game.response) return applyAction(game,
|
||||
game.players.find(p => !game.response!.passed.includes(p.id))!.id, {type: 'passResponse'});
|
||||
if (game.phaseOpening) return applyAction(game,
|
||||
game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!.id, {type: 'ready'});
|
||||
if (game.status === 'playing' && !game.decision) {
|
||||
if (game.phase === 0 && game.stormPending === null) {
|
||||
const dialer = game.stormDialers.find(id => game.stormDials[id] === undefined)!;
|
||||
return applyAction(game, dialer, {type: 'stormDial', amount: game.turn === 1 ? 0 : 1});
|
||||
}
|
||||
if (game.phase === 3 && game.auction) return applyAction(game, game.auction.active, {type: 'passBid'});
|
||||
if (game.phase === 5) return applyAction(game, game.active!, {type: 'endMovement'});
|
||||
if (game.phase !== 6 || !game.active) {
|
||||
const seat = game.players.find(p => !game.ready.includes(p.id));
|
||||
if (seat) return applyAction(game, seat.id, {type: 'ready'});
|
||||
}
|
||||
}
|
||||
for (const p of game.players) {
|
||||
const view = viewGame(game, p.id);
|
||||
view.players.find(seat => seat.id === p.id)!.bot = 'Easy';
|
||||
const actions = botActions(view);
|
||||
const action: Action | undefined = actions.find(a => a.type === 'ready') ?? actions[0];
|
||||
if (action) return applyAction(game, p.id, action);
|
||||
}
|
||||
throw new Error(`No native source action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
}
|
||||
|
||||
/** Stop at the genuine held counter window, or its automatic known-card
|
||||
* endpoint. Callers requiring pass/cancel must prepare karama: true. */
|
||||
export function advanceToForecast(state: Game): Game {
|
||||
let game = state;
|
||||
for (let step = 0; step < 800; step++) {
|
||||
assert.ok(game.players.some(p => p.faction === 'fremen') && game.advanced,
|
||||
'Only actual native Advanced Fremen receive a forecast.');
|
||||
if (game.response?.kind === 'stormPeek' ||
|
||||
(game.phase === 1 && game.stormCard !== null && game.stormCardKnown && !game.response)) return game;
|
||||
assert.equal(game.status, 'playing', 'The native forecast must open before the game ends.');
|
||||
game = nativeStep(game);
|
||||
}
|
||||
throw new Error('The native forecast did not open.');
|
||||
}
|
||||
export function advanceToNextStorm(state: Game): Game {
|
||||
let game = state;
|
||||
const turn = state.turn;
|
||||
for (let step = 0; step < 1200; step++) {
|
||||
if (game.status === 'playing' && game.turn > turn && game.phase === 0 &&
|
||||
!game.phaseOpening && !game.response && !game.decision) return game;
|
||||
game = nativeStep(game);
|
||||
}
|
||||
throw new Error('The next native Storm turn did not open.');
|
||||
}
|
||||
export function finishToMovement(state: Game): Game {
|
||||
let game = state;
|
||||
for (let step = 0; step < 800; step++) {
|
||||
if (game.status === 'playing' && game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) return game;
|
||||
game = nativeStep(game);
|
||||
}
|
||||
throw new Error('Native Shipment and Movement did not open.');
|
||||
}
|
||||
|
||||
/** Reorders conserved, unplayed physical Spice Cards; does not manufacture a
|
||||
* worm, territory, phase, force, source frame or private receipt. */
|
||||
function orderSpice(game: Game, territory: string, worm = false, position = 0): void {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && (worm ?
|
||||
'worm' in card && !card.greatMaker && !card.suppressed :
|
||||
'territory' in card && (!territory || card.territory === territory)));
|
||||
assert.ok(index >= position, 'The required actual Spice Card remains in the deck.');
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
function completeSetup(state: Game, mode: AdvancedSourceFixtureOptions['alliance']): Game {
|
||||
let game = state;
|
||||
const advisors = mode === 'ownAdvisors' || mode === 'allyAdvisors';
|
||||
for (let step = 0; step < 100 && game.status === 'setup'; step++) {
|
||||
if (advisors && game.setupStage === 'forces') {
|
||||
const bg = game.players.find(p => p.faction === 'beneGesserit');
|
||||
if (bg && viewGame(game, bg.id).setupPending.includes(bg.id) && game.players.every(p => p.faction !== 'fremen' || p.reserves === 10)) {
|
||||
game = applyAction(game, bg.id, {type: 'advisorSetup', territory: 'arrakeen', sector: 10});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
game = nativeStep(game);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
return game;
|
||||
}
|
||||
|
||||
function arrangeStartingCards(game: Game, options: AdvancedSourceFixtureOptions): void {
|
||||
if (options.karama !== undefined || options.hajr)
|
||||
assert.ok(game.players.every(p => p.hand.length === 0),
|
||||
'Arrange only unplayed faces before the native starting deal.');
|
||||
if (options.karama === false) {
|
||||
let position = 0;
|
||||
for (const p of game.players) {
|
||||
for (let count = 0; count < (p.faction === 'harkonnen' ? 2 : 1); count++) {
|
||||
const index = game.deck.findIndex((c, i) => i >= position && c.effect !== 'karama' &&
|
||||
(p.faction !== 'beneGesserit' || c.kind !== 'worthless'));
|
||||
assert.ok(index >= position, 'A conserved non-counter starting face must remain.');
|
||||
game.deck.splice(position++, 0, game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [effect, owner] of [
|
||||
['karama', options.karama ? game.players.find(p => p.faction !== 'fremen') : undefined],
|
||||
['hajr', options.hajr ? options.alliance ?
|
||||
game.players.find(p => p.faction === (ordinaryAlliance(options.alliance) ? 'emperor' : 'fremen')) :
|
||||
game.players[0] : undefined],
|
||||
] as const) {
|
||||
if (!owner) continue;
|
||||
const index = game.deck.findIndex(c => c.effect === effect);
|
||||
assert.ok(index >= 0, 'The required original unplayed starting face must remain.');
|
||||
const drawPosition = game.players.slice(0, game.players.indexOf(owner))
|
||||
.reduce((sum, p) => sum + (p.faction === 'harkonnen' ? 2 : 1), 0);
|
||||
game.deck.splice(drawPosition, 0, game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function freshSetup(options: AdvancedSourceFixtureOptions): Game {
|
||||
if (options.initial) {
|
||||
const game = structuredClone(options.initial);
|
||||
assert.equal(game.status, 'setup', 'Only a fresh admitted setup may be prepared.');
|
||||
assert.equal(game.turn, 1);
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
if (options.seatIds) assert.deepEqual(game.players.map(p => p.id), options.seatIds);
|
||||
arrangeStartingCards(game, options);
|
||||
return completeSetup(game, options.alliance);
|
||||
}
|
||||
const mode = options.alliance;
|
||||
const ids = options.seatIds ?? ['source-a', 'source-b', 'source-c'];
|
||||
const roster: FactionId[] = mode === 'ecaz' ? ['ecaz', 'fremen', 'atreides'] :
|
||||
mode === 'ownAdvisors' || mode === 'allyAdvisors' ? ['atreides', 'beneGesserit', 'fremen'] :
|
||||
ordinaryAlliance(mode) ? ['atreides', 'emperor', 'guild'] :
|
||||
mode ? ['atreides', 'fremen', 'guild'] : options.fremen ? ['atreides', 'emperor', 'fremen'] : ['atreides', 'emperor'];
|
||||
assert.ok(ids.length >= roster.length, 'Supply final seat IDs before setup.');
|
||||
let game = createGame('ADVANCEDSOURCE', newPlayer(ids[0], roster[0], roster[0]), options.advanced ?? true,
|
||||
mode === 'ecaz' ? ['ecaz'] : []);
|
||||
for (let i = 1; i < roster.length; i++) joinGame(game, newPlayer(ids[i], roster[i], roster[i]));
|
||||
if (mode) {
|
||||
// Actual printed circles select a native first allied ending. The later
|
||||
// case has a nonallied seat ahead of it; no movement order is forged.
|
||||
const fremen = game.players.find(p => p.faction === (ordinaryAlliance(mode) ? 'emperor' : 'fremen'))!;
|
||||
const native = game.players.find(p => p.faction === (mode === 'ecaz' ? 'ecaz' : 'atreides'))!;
|
||||
const remaining = game.players.find(p => p.id !== fremen.id && p.id !== native.id)!;
|
||||
if (ordinaryAlliance(mode))
|
||||
for (const [index, player] of game.players.entries())
|
||||
game = applyAction(game, player.id, {type: 'seatPosition', position: index + 4});
|
||||
const positions = ordinaryAlliance(mode) ? mode === 'laterEnding'
|
||||
? [[remaining.id, 1], [fremen.id, 2], [native.id, 3]] as const
|
||||
: [[fremen.id, 1], [native.id, 2], [remaining.id, 3]] as const :
|
||||
[[remaining.id, 6], [fremen.id, 3], [native.id, 4]] as const;
|
||||
for (const [id, position] of positions)
|
||||
if (game.playerPositions?.[id] !== position)
|
||||
game = applyAction(game, id, {type: 'seatPosition', position});
|
||||
}
|
||||
if (options.modules) game = applyAction(game, ids[0], {type: 'homeworlds', enabled: true});
|
||||
for (const p of game.players) game = applyAction(game, p.id, {type: 'ready'});
|
||||
game = mode === 'ecaz' ? initializeFactionExpansionsGameForAudit(game) : options.modules ?
|
||||
initializeHomeworldGameForAudit(game) : initializeBaseGameForAudit(game);
|
||||
arrangeStartingCards(game, options);
|
||||
return completeSetup(game, mode);
|
||||
}
|
||||
|
||||
/** Protected Basin groups can legally share a territory before allying: the
|
||||
* first Storm separates their sectors, so no battle joins them. The next
|
||||
* natural Nexus allies them without violating allied entry restrictions. */
|
||||
function createOrdinaryAlliance(state: Game, mode: NonNullable<AdvancedSourceFixtureOptions['alliance']>): AdvancedSourceFixture {
|
||||
let game = state;
|
||||
const emperor = game.players.find(p => p.faction === 'emperor')!;
|
||||
const atreides = game.players.find(p => p.faction === 'atreides')!;
|
||||
assert.ok(emperor && atreides);
|
||||
const seed = TERRITORIES.find(t => game.spiceDeck.some(c => 'territory' in c && c.territory === t.id) &&
|
||||
game.players.every(p => Object.keys(p.forces).every(key => splitLocation(key).territory !== t.id)))!;
|
||||
orderSpice(game, seed.id);
|
||||
if (game.advanced) orderSpice(game, '', false, 1);
|
||||
while (game.phaseOpening || game.response || game.decision) game = nativeStep(game);
|
||||
assert.equal(game.phase, 0);
|
||||
for (const [index, id] of game.stormDialers.entries())
|
||||
game = applyAction(game, id, {type: 'stormDial', amount: index === 0 ? 4 : 5});
|
||||
game = finishToMovement(game);
|
||||
assert.equal(game.storm, 10);
|
||||
while (game.phase === 5) {
|
||||
const actor = game.active!;
|
||||
if (actor === emperor.id || actor === atreides.id)
|
||||
game = settleAdvancedSourceResponses(applyAction(game, actor, {type: 'ship',
|
||||
territory: 'imperial_basin', sector: actor === emperor.id ? 9 : 11, amount: 2,
|
||||
...(game.advanced && actor === emperor.id ? {elite: 1} : {})}));
|
||||
game = applyAction(game, actor, {type: 'endMovement'});
|
||||
}
|
||||
game = advanceToNextStorm(game);
|
||||
orderSpice(game, '', true);
|
||||
orderSpice(game, '', false, 1);
|
||||
if (game.advanced) orderSpice(game, '', false, 2);
|
||||
let allied = false;
|
||||
for (let step = 0; step < 500; step++) {
|
||||
if (!allied && game.phase === 1 && game.nexus && !game.spiceWindow &&
|
||||
!game.spiceResolution && !game.response && !game.decision && !game.phaseOpening) {
|
||||
game = applyAction(game, emperor.id, {type: 'alliance', target: atreides.id});
|
||||
game = applyAction(game, atreides.id, {type: 'alliance', target: emperor.id});
|
||||
allied = true;
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) break;
|
||||
game = nativeStep(game);
|
||||
}
|
||||
assert.ok(allied && game.phase === 5);
|
||||
let actor = emperor.id;
|
||||
if (mode === 'laterEnding' && !game.advanced) {
|
||||
game = advanceToNextStorm(game);
|
||||
orderSpice(game, '');
|
||||
game = finishToMovement(game);
|
||||
actor = atreides.id;
|
||||
}
|
||||
while (game.active !== actor) game = applyAction(game, game.active!, {type: 'endMovement'});
|
||||
const ally = game.players.find(p => p.id === actor)!.ally!;
|
||||
assert.ok(ally && game.players.find(p => p.id === ally)!.ally === actor);
|
||||
for (const id of [actor, ally])
|
||||
assert.equal(Object.entries(game.players.find(p => p.id === id)!.forces)
|
||||
.filter(([key]) => splitLocation(key).territory === 'imperial_basin')
|
||||
.reduce((total, [, count]) => total + count, 0), 2);
|
||||
return {game, actor, ally, other: game.players.find(p => p.id !== actor && p.id !== ally)!.id,
|
||||
turn: game.turn, lossTerritories: ['imperial_basin']};
|
||||
}
|
||||
|
||||
export function createAdvancedSourceFixture(options: AdvancedSourceFixtureOptions = {}): AdvancedSourceFixture {
|
||||
let game = freshSetup(options);
|
||||
const mode = options.alliance;
|
||||
const advisors = mode === 'ownAdvisors' || mode === 'allyAdvisors';
|
||||
const host = game.players[0].id;
|
||||
if (!mode) {
|
||||
if (options.storm === 'next') game = advanceToNextStorm(game);
|
||||
return {game, actor: host, ally: null, other: game.players[1]?.id ?? null, turn: game.turn, lossTerritories: []};
|
||||
}
|
||||
if (ordinaryAlliance(mode)) return createOrdinaryAlliance(game, mode);
|
||||
const fremen = game.players.find(p => p.faction === 'fremen');
|
||||
const native = game.players.find(p => p.faction === (mode === 'ecaz' ? 'ecaz' : 'atreides'));
|
||||
assert.ok(fremen && native, 'An actual native Fremen and destination owner must be admitted.');
|
||||
const bg = game.players.find(p => p.faction === 'beneGesserit');
|
||||
if (advisors) assert.ok(bg && game.advanced, 'Advisor cases require genuine Advanced BG setup.');
|
||||
const sand = TERRITORIES.find(t => fremenReserveEntry(t.id) && t.sectors.some(s => s > 7) &&
|
||||
game.players.every(p => Object.keys(p.forces).every(key => splitLocation(key).territory !== t.id)) &&
|
||||
game.spiceDeck.some(c => 'territory' in c && c.territory === t.id));
|
||||
assert.ok(sand);
|
||||
const sandSector = sand.sectors.find(s => s > 7)!;
|
||||
// The actual first blow seeds the native worm occurrence on the next turn.
|
||||
orderSpice(game, sand.id);
|
||||
if (game.advanced) orderSpice(game, '', false, 1);
|
||||
game = finishToMovement(game);
|
||||
assert.equal(game.spiceDiscard[0].findLast(c => 'territory' in c)?.territory, sand.id,
|
||||
'The first physical pile must seed the later native worm source.');
|
||||
while (game.phase === 5) {
|
||||
const actor = game.active!;
|
||||
if (!advisors && actor === fremen.id) game = settleAdvancedSourceResponses(applyAction(game, actor,
|
||||
{type: 'ship', territory: sand.id, sector: sandSector, amount: 2, ...(game.advanced ? {elite: 1} : {})}));
|
||||
if (mode === 'polar' && actor === native.id) game = settleAdvancedSourceResponses(applyAction(game, actor,
|
||||
{type: 'ship', territory: 'polar_sink', sector: 0, amount: 2}));
|
||||
game = applyAction(game, actor, {type: 'endMovement'});
|
||||
}
|
||||
game = advanceToNextStorm(game);
|
||||
orderSpice(game, '', true);
|
||||
// Finish the worm's replacement and second physical pile with conserved
|
||||
// territory faces, not an incidental extra worm or Sandtrout.
|
||||
orderSpice(game, '', false, 1);
|
||||
if (game.advanced) orderSpice(game, '', false, 2);
|
||||
let rode = advisors;
|
||||
const destination = mode === 'polar' ? 'polar_sink' : mode === 'ecaz' ? 'imperial_basin' : 'arrakeen';
|
||||
const destinationSector = mode === 'polar' ? 0 : mode === 'ecaz' ?
|
||||
splitLocation(Object.keys(game.players.find(p => p.id === native.id)!.forces)[0]).sector : 10;
|
||||
const left = advisors ? native.id : fremen.id;
|
||||
const right = advisors ? bg!.id : native.id;
|
||||
let allied = false;
|
||||
for (let step = 0; step < 500; step++) {
|
||||
// Natural Nexus precedes its ride. Completing that ride clears nexus,
|
||||
// so reciprocal offers must occur at this actual legal boundary.
|
||||
if (!allied && game.phase === 1 && game.nexus && !game.spiceWindow &&
|
||||
!game.spiceResolution && !game.response && !game.decision && !game.phaseOpening) {
|
||||
game = applyAction(game, left, {type: 'alliance', target: right});
|
||||
game = applyAction(game, right, {type: 'alliance', target: left});
|
||||
allied = true;
|
||||
continue;
|
||||
}
|
||||
if (!rode && game.decision?.kind === 'wormRide' && game.decision.player === fremen.id) {
|
||||
assert.equal(game.decision.territory, sand.id);
|
||||
const forces = Object.fromEntries(Object.entries(game.players.find(p => p.id === fremen.id)!.forces)
|
||||
.filter(([key]) => splitLocation(key).territory === sand.id));
|
||||
game = applyAction(game, fremen.id, {type: 'decision', accept: true, territory: destination,
|
||||
sector: destinationSector, forces});
|
||||
rode = true;
|
||||
continue;
|
||||
}
|
||||
if (game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) break;
|
||||
game = nativeStep(game);
|
||||
}
|
||||
assert.ok(allied && rode && game.phase === 5 && !game.decision && !game.response,
|
||||
'The genuine reciprocal Nexus alliance and requested worm ride must reach native movement.');
|
||||
const pair = [left, right].sort((a, b) => game.movementRemaining!.indexOf(a) - game.movementRemaining!.indexOf(b));
|
||||
const actor = mode === 'ownAdvisors' ? bg!.id : mode === 'allyAdvisors' ? native.id : pair[0];
|
||||
while (game.active !== actor) game = applyAction(game, game.active!, {type: 'endMovement'});
|
||||
const ally = game.players.find(p => p.id === actor)!.ally;
|
||||
assert.ok(ally);
|
||||
assert.equal(game.players.find(p => p.id === ally)!.ally, actor);
|
||||
for (const id of [actor, ally])
|
||||
assert.ok(Object.entries(game.players.find(p => p.id === id)!.forces)
|
||||
.some(([key, amount]) => amount > 0 && splitLocation(key).territory === destination),
|
||||
'Both genuine allied force groups must occupy the requested edge territory.');
|
||||
// The exception scenarios have overlap but correctly no consequence quote.
|
||||
const lossTerritories = advisors || mode === 'ecaz' || mode === 'polar' ? [] : [destination];
|
||||
return {game, actor, ally, other: game.players.find(p => p.id !== actor && p.id !== ally)?.id ?? null,
|
||||
turn: game.turn, lossTerritories};
|
||||
}
|
||||
+602
@@ -0,0 +1,602 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"code": "OLDSTORM",
|
||||
"version": 0,
|
||||
"host": "00000000-0000-4000-8000-0000000000a1",
|
||||
"status": "playing",
|
||||
"advanced": true,
|
||||
"expansions": [],
|
||||
"players": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-0000000000a1",
|
||||
"name": "Recorded QA Atreides",
|
||||
"faction": "atreides",
|
||||
"ready": true,
|
||||
"spice": 11,
|
||||
"reserves": 10,
|
||||
"tanks": 0,
|
||||
"forces": {
|
||||
"arrakeen:10": 10
|
||||
},
|
||||
"hand": [
|
||||
{
|
||||
"name": "Shield",
|
||||
"kind": "shield",
|
||||
"id": "treachery-9"
|
||||
},
|
||||
{
|
||||
"name": "Maula Pistol",
|
||||
"kind": "projectile",
|
||||
"id": "treachery-1"
|
||||
}
|
||||
],
|
||||
"leaders": [
|
||||
{
|
||||
"id": "atreides-0",
|
||||
"name": "Thufir Hawat",
|
||||
"strength": 5,
|
||||
"faction": "atreides",
|
||||
"dead": false,
|
||||
"deaths": 0
|
||||
},
|
||||
{
|
||||
"id": "atreides-1",
|
||||
"name": "Lady Jessica",
|
||||
"strength": 5,
|
||||
"faction": "atreides",
|
||||
"dead": false,
|
||||
"deaths": 0
|
||||
},
|
||||
{
|
||||
"id": "atreides-2",
|
||||
"name": "Gurney Halleck",
|
||||
"strength": 4,
|
||||
"faction": "atreides",
|
||||
"dead": false,
|
||||
"deaths": 0
|
||||
},
|
||||
{
|
||||
"id": "atreides-3",
|
||||
"name": "Duncan Idaho",
|
||||
"strength": 2,
|
||||
"faction": "atreides",
|
||||
"dead": false,
|
||||
"deaths": 0
|
||||
},
|
||||
{
|
||||
"id": "atreides-4",
|
||||
"name": "Dr. Yueh",
|
||||
"strength": 1,
|
||||
"faction": "atreides",
|
||||
"dead": false,
|
||||
"deaths": 0
|
||||
}
|
||||
],
|
||||
"traitors": [
|
||||
"emperor-3"
|
||||
],
|
||||
"traitorChoices": [],
|
||||
"ally": null,
|
||||
"bribes": 0,
|
||||
"revived": 0,
|
||||
"freeForcesRevived": 0,
|
||||
"leaderRevived": false,
|
||||
"revivalCycle": 0,
|
||||
"shipped": false,
|
||||
"moved": 0,
|
||||
"battleLosses": 0
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-0000000000e1",
|
||||
"name": "Recorded QA Emperor",
|
||||
"faction": "emperor",
|
||||
"ready": true,
|
||||
"spice": 10,
|
||||
"reserves": 20,
|
||||
"tanks": 0,
|
||||
"forces": {},
|
||||
"hand": [
|
||||
{
|
||||
"name": "Lasgun",
|
||||
"kind": "lasgun",
|
||||
"id": "treachery-8"
|
||||
},
|
||||
{
|
||||
"name": "Shield",
|
||||
"kind": "shield",
|
||||
"id": "treachery-12"
|
||||
}
|
||||
],
|
||||
"leaders": [
|
||||
{
|
||||
"id": "emperor-0",
|
||||
"name": "Hasimir Fenring",
|
||||
"strength": 6,
|
||||
"faction": "emperor",
|
||||
"dead": false,
|
||||
"deaths": 0
|
||||
},
|
||||
{
|
||||
"id": "emperor-1",
|
||||
"name": "Captain Aramsham",
|
||||
"strength": 5,
|
||||
"faction": "emperor",
|
||||
"dead": false,
|
||||
"deaths": 0
|
||||
},
|
||||
{
|
||||
"id": "emperor-2",
|
||||
"name": "Caid",
|
||||
"strength": 3,
|
||||
"faction": "emperor",
|
||||
"dead": false,
|
||||
"deaths": 0
|
||||
},
|
||||
{
|
||||
"id": "emperor-3",
|
||||
"name": "Burseg",
|
||||
"strength": 3,
|
||||
"faction": "emperor",
|
||||
"dead": false,
|
||||
"deaths": 0
|
||||
},
|
||||
{
|
||||
"id": "emperor-4",
|
||||
"name": "Bashar",
|
||||
"strength": 2,
|
||||
"faction": "emperor",
|
||||
"dead": false,
|
||||
"deaths": 0
|
||||
}
|
||||
],
|
||||
"traitors": [
|
||||
"emperor-0"
|
||||
],
|
||||
"traitorChoices": [],
|
||||
"ally": null,
|
||||
"bribes": 0,
|
||||
"revived": 0,
|
||||
"freeForcesRevived": 0,
|
||||
"leaderRevived": false,
|
||||
"revivalCycle": 0,
|
||||
"shipped": false,
|
||||
"moved": 0,
|
||||
"battleLosses": 0,
|
||||
"elites": {
|
||||
"reserves": 5,
|
||||
"tanks": 0,
|
||||
"forces": {},
|
||||
"revived": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"playerPositions": {
|
||||
"00000000-0000-4000-8000-0000000000a1": 1,
|
||||
"00000000-0000-4000-8000-0000000000e1": 2
|
||||
},
|
||||
"turn": 2,
|
||||
"phase": 0,
|
||||
"storm": 16,
|
||||
"order": [
|
||||
"00000000-0000-4000-8000-0000000000a1",
|
||||
"00000000-0000-4000-8000-0000000000e1"
|
||||
],
|
||||
"active": null,
|
||||
"movementRemaining": [],
|
||||
"guildTimingGranted": false,
|
||||
"guildTimingLocked": false,
|
||||
"ready": [],
|
||||
"deck": [
|
||||
{
|
||||
"name": "Tleilaxu Ghola",
|
||||
"kind": "special",
|
||||
"effect": "ghola",
|
||||
"id": "treachery-27"
|
||||
},
|
||||
{
|
||||
"name": "Crysknife",
|
||||
"kind": "projectile",
|
||||
"id": "treachery-0"
|
||||
},
|
||||
{
|
||||
"name": "Cheap Heroine",
|
||||
"kind": "hero",
|
||||
"id": "treachery-23"
|
||||
},
|
||||
{
|
||||
"name": "Shield",
|
||||
"kind": "shield",
|
||||
"id": "treachery-11"
|
||||
},
|
||||
{
|
||||
"name": "Harvester",
|
||||
"kind": "special",
|
||||
"effect": "harvester",
|
||||
"id": "treachery-28"
|
||||
},
|
||||
{
|
||||
"name": "Snooper",
|
||||
"kind": "snooper",
|
||||
"id": "treachery-14"
|
||||
},
|
||||
{
|
||||
"name": "Jubba Cloak",
|
||||
"kind": "worthless",
|
||||
"id": "treachery-18"
|
||||
},
|
||||
{
|
||||
"name": "Truthtrance",
|
||||
"kind": "special",
|
||||
"effect": "truthtrance",
|
||||
"id": "treachery-32"
|
||||
},
|
||||
{
|
||||
"name": "Weather Control",
|
||||
"kind": "special",
|
||||
"effect": "weather",
|
||||
"id": "treachery-25"
|
||||
},
|
||||
{
|
||||
"name": "Gom Jabbar",
|
||||
"kind": "poison",
|
||||
"id": "treachery-6"
|
||||
},
|
||||
{
|
||||
"name": "Ellaca Drug",
|
||||
"kind": "poison",
|
||||
"id": "treachery-7"
|
||||
},
|
||||
{
|
||||
"name": "Stunner",
|
||||
"kind": "projectile",
|
||||
"id": "treachery-3"
|
||||
},
|
||||
{
|
||||
"name": "Chaumas",
|
||||
"kind": "poison",
|
||||
"id": "treachery-4"
|
||||
},
|
||||
{
|
||||
"name": "Chaumurky",
|
||||
"kind": "poison",
|
||||
"id": "treachery-5"
|
||||
},
|
||||
{
|
||||
"name": "Family Atomics",
|
||||
"kind": "special",
|
||||
"effect": "atomics",
|
||||
"id": "treachery-24"
|
||||
},
|
||||
{
|
||||
"name": "Trip to Gamont",
|
||||
"kind": "worthless",
|
||||
"id": "treachery-21"
|
||||
},
|
||||
{
|
||||
"name": "Kulon",
|
||||
"kind": "worthless",
|
||||
"id": "treachery-19"
|
||||
},
|
||||
{
|
||||
"name": "Baliset",
|
||||
"kind": "worthless",
|
||||
"id": "treachery-17"
|
||||
},
|
||||
{
|
||||
"name": "Shield",
|
||||
"kind": "shield",
|
||||
"id": "treachery-10"
|
||||
},
|
||||
{
|
||||
"name": "Truthtrance",
|
||||
"kind": "special",
|
||||
"effect": "truthtrance",
|
||||
"id": "treachery-31"
|
||||
},
|
||||
{
|
||||
"name": "La La La",
|
||||
"kind": "worthless",
|
||||
"id": "treachery-20"
|
||||
},
|
||||
{
|
||||
"name": "Snooper",
|
||||
"kind": "snooper",
|
||||
"id": "treachery-15"
|
||||
},
|
||||
{
|
||||
"name": "Snooper",
|
||||
"kind": "snooper",
|
||||
"id": "treachery-16"
|
||||
},
|
||||
{
|
||||
"name": "Karama",
|
||||
"kind": "special",
|
||||
"effect": "karama",
|
||||
"id": "treachery-29"
|
||||
},
|
||||
{
|
||||
"name": "Slip Tip",
|
||||
"kind": "projectile",
|
||||
"id": "treachery-2"
|
||||
},
|
||||
{
|
||||
"name": "Snooper",
|
||||
"kind": "snooper",
|
||||
"id": "treachery-13"
|
||||
},
|
||||
{
|
||||
"name": "Karama",
|
||||
"kind": "special",
|
||||
"effect": "karama",
|
||||
"id": "treachery-30"
|
||||
},
|
||||
{
|
||||
"name": "Cheap Hero",
|
||||
"kind": "hero",
|
||||
"id": "treachery-22"
|
||||
},
|
||||
{
|
||||
"name": "Hajr",
|
||||
"kind": "special",
|
||||
"effect": "hajr",
|
||||
"id": "treachery-26"
|
||||
}
|
||||
],
|
||||
"discard": [],
|
||||
"spiceDeck": [
|
||||
{
|
||||
"territory": "rock_outcroppings",
|
||||
"amount": 6,
|
||||
"sector": 13
|
||||
},
|
||||
{
|
||||
"territory": "habbanya_erg",
|
||||
"amount": 8,
|
||||
"sector": 16
|
||||
},
|
||||
{
|
||||
"territory": "south_mesa",
|
||||
"amount": 10,
|
||||
"sector": 5
|
||||
},
|
||||
{
|
||||
"worm": true
|
||||
},
|
||||
{
|
||||
"worm": true
|
||||
},
|
||||
{
|
||||
"territory": "habbanya_ridge_flat",
|
||||
"amount": 10,
|
||||
"sector": 18
|
||||
},
|
||||
{
|
||||
"territory": "sihaya_ridge",
|
||||
"amount": 6,
|
||||
"sector": 9
|
||||
},
|
||||
{
|
||||
"territory": "the_minor_erg",
|
||||
"amount": 8,
|
||||
"sector": 8
|
||||
},
|
||||
{
|
||||
"territory": "cielago_north",
|
||||
"amount": 8,
|
||||
"sector": 3
|
||||
},
|
||||
{
|
||||
"territory": "the_great_flat",
|
||||
"amount": 10,
|
||||
"sector": 15
|
||||
},
|
||||
{
|
||||
"worm": true
|
||||
},
|
||||
{
|
||||
"worm": true
|
||||
},
|
||||
{
|
||||
"worm": true
|
||||
},
|
||||
{
|
||||
"territory": "broken_land",
|
||||
"amount": 8,
|
||||
"sector": 12
|
||||
},
|
||||
{
|
||||
"territory": "red_chasm",
|
||||
"amount": 8,
|
||||
"sector": 7
|
||||
},
|
||||
{
|
||||
"territory": "funeral_plain",
|
||||
"amount": 6,
|
||||
"sector": 15
|
||||
},
|
||||
{
|
||||
"worm": true
|
||||
},
|
||||
{
|
||||
"territory": "cielago_south",
|
||||
"amount": 12,
|
||||
"sector": 2
|
||||
},
|
||||
{
|
||||
"territory": "wind_pass_north",
|
||||
"amount": 6,
|
||||
"sector": 17
|
||||
}
|
||||
],
|
||||
"spicePeekKnown": true,
|
||||
"spiceDiscard": [
|
||||
[
|
||||
{
|
||||
"territory": "oh_gap",
|
||||
"amount": 6,
|
||||
"sector": 10
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"territory": "hagga_basin",
|
||||
"amount": 6,
|
||||
"sector": 12
|
||||
}
|
||||
]
|
||||
],
|
||||
"spice": {
|
||||
"oh_gap:10": 6,
|
||||
"hagga_basin:12": 6,
|
||||
"arrakeen:10": 0
|
||||
},
|
||||
"log": [
|
||||
{
|
||||
"seq": 1,
|
||||
"text": "Recorded QA Atreides opened the table."
|
||||
},
|
||||
{
|
||||
"seq": 2,
|
||||
"text": "Recorded QA Emperor joined as Emperor."
|
||||
},
|
||||
{
|
||||
"seq": 3,
|
||||
"text": "Player positions are fixed. Private traitor selection comes before force placement and starting Treachery Cards."
|
||||
},
|
||||
{
|
||||
"seq": 4,
|
||||
"text": "Traitor Cards were dealt privately. Choose traitors before starting force placement; Treachery Cards remain undealt."
|
||||
},
|
||||
{
|
||||
"seq": 5,
|
||||
"text": "Traitor choices are complete. Starting spice and fixed forces are placed; complete faction force placement before receiving Treachery Cards."
|
||||
},
|
||||
{
|
||||
"seq": 6,
|
||||
"text": "Recorded QA Atreides received 1 starting Treachery Card after force placement.",
|
||||
"automatic": {
|
||||
"faction": "atreides",
|
||||
"name": "Starting cards"
|
||||
}
|
||||
},
|
||||
{
|
||||
"seq": 7,
|
||||
"text": "Recorded QA Emperor received 1 starting Treachery Card after force placement.",
|
||||
"automatic": {
|
||||
"faction": "emperor",
|
||||
"name": "Starting cards"
|
||||
}
|
||||
},
|
||||
{
|
||||
"seq": 8,
|
||||
"text": "Setup complete. The first storm awaits two secret dials."
|
||||
},
|
||||
{
|
||||
"seq": 9,
|
||||
"text": "Storm distance revealed: 15 sectors. Resolve storm cards before moving."
|
||||
},
|
||||
{
|
||||
"seq": 10,
|
||||
"text": "Storm moved 15 sectors to sector 16."
|
||||
},
|
||||
{
|
||||
"seq": 11,
|
||||
"text": "6 spice appeared in Oh Gap."
|
||||
},
|
||||
{
|
||||
"seq": 12,
|
||||
"text": "6 spice appeared in Hagga Basin."
|
||||
},
|
||||
{
|
||||
"seq": 13,
|
||||
"text": "Atreides won a treachery card for 1 spice. The declared spice payment was the only available payment method.",
|
||||
"automatic": {
|
||||
"faction": "atreides",
|
||||
"name": "Auction payment"
|
||||
}
|
||||
},
|
||||
{
|
||||
"seq": 14,
|
||||
"text": "Recorded QA Emperor received 1 spice from another faction’s 1-spice paid auction bid.",
|
||||
"automatic": {
|
||||
"faction": "emperor",
|
||||
"name": "Auction income"
|
||||
}
|
||||
},
|
||||
{
|
||||
"seq": 15,
|
||||
"text": "Emperor won a treachery card for 1 spice. The declared spice payment was the only available payment method.",
|
||||
"automatic": {
|
||||
"faction": "emperor",
|
||||
"name": "Auction payment"
|
||||
}
|
||||
},
|
||||
{
|
||||
"seq": 16,
|
||||
"text": "Atreides received 2 spice from strongholds."
|
||||
},
|
||||
{
|
||||
"seq": 17,
|
||||
"text": "Turn 2 begins."
|
||||
}
|
||||
],
|
||||
"stormDials": {},
|
||||
"stormPending": null,
|
||||
"stormCard": null,
|
||||
"stormCardKnown": false,
|
||||
"stormResolution": null,
|
||||
"karamaShipping": null,
|
||||
"stormDialers": [
|
||||
"00000000-0000-4000-8000-0000000000a1",
|
||||
"00000000-0000-4000-8000-0000000000e1"
|
||||
],
|
||||
"lastBattle": [
|
||||
"00000000-0000-4000-8000-0000000000a1",
|
||||
"00000000-0000-4000-8000-0000000000e1"
|
||||
],
|
||||
"auction": null,
|
||||
"battle": null,
|
||||
"nexus": false,
|
||||
"allianceOffers": {},
|
||||
"winner": [],
|
||||
"shieldWallDestroyed": false,
|
||||
"spiceWindow": null,
|
||||
"hajr": [],
|
||||
"decision": null,
|
||||
"pendingCapture": null,
|
||||
"response": null,
|
||||
"wormRides": [],
|
||||
"wormPlacementCanceledTurn": 0,
|
||||
"spiceResolution": null,
|
||||
"spiceSequence": null,
|
||||
"freeRevival": [],
|
||||
"emperorExtra": {},
|
||||
"aid": {},
|
||||
"sandtrout": false,
|
||||
"advancedPreview": true,
|
||||
"traitorReserve": [
|
||||
"emperor-2",
|
||||
"atreides-1",
|
||||
"emperor-1",
|
||||
"atreides-0",
|
||||
"atreides-4",
|
||||
"atreides-2",
|
||||
"emperor-4",
|
||||
"atreides-3"
|
||||
],
|
||||
"summonedBeforeBlow": false,
|
||||
"currentAuctionSale": null,
|
||||
"revivalRules": {
|
||||
"expanded": [],
|
||||
"limitBlocked": false,
|
||||
"fullPrice": false,
|
||||
"allyDiscount": null,
|
||||
"discountBlocked": false,
|
||||
"earlyBlocked": []
|
||||
},
|
||||
"revivalRequests": {},
|
||||
"saphoMovementLast": null,
|
||||
"recruits": null,
|
||||
"auditorInsight": null,
|
||||
"nexusChoamInsight": null
|
||||
}
|
||||
@@ -293,7 +293,21 @@ for (const bg of [false, true])
|
||||
} else {
|
||||
assert.equal(done.pendingMobileMove, null);
|
||||
assert.deepEqual(done.mobileStronghold, g.mobileStronghold);
|
||||
assert.deepEqual(done.stormDialers, g.lastBattle);
|
||||
assert.equal(done.turn, g.turn);
|
||||
assert.equal(done.phase, 0);
|
||||
assert.ok(
|
||||
Number.isInteger(done.stormPending) &&
|
||||
done.stormPending! >= 1 &&
|
||||
done.stormPending! <= 6,
|
||||
);
|
||||
assert.equal(done.stormMovementSource?.kind, 'card');
|
||||
assert.equal(done.stormMovementSource?.turn, done.turn);
|
||||
assert.equal(done.stormMovementSource?.distance, done.stormPending);
|
||||
for (const dialer of g.lastBattle)
|
||||
assert.throws(
|
||||
() => applyAction(done, dialer, { type: 'stormDial', amount: 1 }),
|
||||
/not dialing/,
|
||||
);
|
||||
assert.equal(player(done, 'p').elites!.revived, 0);
|
||||
assert.equal(
|
||||
done.log.filter((l) => l.text === `Turn ${g.turn} begins.`).length,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { baseDeck, treacheryDeck } from '../game/cards';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createStormSource } from '../game/discovery-storm';
|
||||
import {
|
||||
createDukeVidal,
|
||||
acquireDuke,
|
||||
@@ -300,6 +301,7 @@ void test('Family Atomics can recover after killing its own qualifying Wall figh
|
||||
revived: 0,
|
||||
};
|
||||
g.stormPending = 1;
|
||||
g.stormMovementSource = createStormSource(g.turn, 'card', g.stormPending);
|
||||
g.ready = ['t'];
|
||||
const { pending, done } = play(g, 'p', { type: 'card', card });
|
||||
assert.equal(pending.shieldWallDestroyed, true);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type StrongholdId,
|
||||
} from '../game/stronghold-cards';
|
||||
import { MOBILE_STRONGHOLD, MOBILE_LOCATION, territory } from '../game/board';
|
||||
import { finishToMovement } from './fixture-advanced-source';
|
||||
const key = (t: string) => `${t}:${territory(t).sectors[0]}`;
|
||||
const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
||||
function fixture(t: StrongholdId = 'arrakeen', owner = 'p', choam = false) {
|
||||
@@ -199,11 +200,14 @@ void test('Stronghold Cards are unowned at the start and transfer only at the fi
|
||||
assert.equal(g.strongholdCards!.owners.arrakeen, 'p');
|
||||
const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(g)));
|
||||
assert.deepEqual(restored.strongholdCards, g.strongholdCards);
|
||||
g.phase = 8;
|
||||
g.active = null;
|
||||
g.ready = [];
|
||||
g.decision = null;
|
||||
g.response = null;
|
||||
g = finishToMovement(g);
|
||||
while (g.phase === 5)
|
||||
g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
assert.equal(g.phase, 7);
|
||||
assert.equal(g.strongholdCards!.owners.arrakeen, 'p');
|
||||
g = readyAll(g);
|
||||
assert.equal(g.phase, 8);
|
||||
assert.equal(g.strongholdCards!.claimedTurn, 1);
|
||||
seat(g, 'p').forces = {};
|
||||
seat(g, 'p').reserves = 20;
|
||||
seat(g, 'q').forces = { 'arrakeen:10': 1 };
|
||||
|
||||
@@ -139,9 +139,13 @@ function opportunity(kind: Case, advanced = true) {
|
||||
return applyAction(g, 'p', { type: 'choamInflation', side: 'double' });
|
||||
}
|
||||
if (kind === 'stormPeek') {
|
||||
g.phase = 0;
|
||||
g.stormPending = 1;
|
||||
g.stormDialers = [];
|
||||
g.phase = 8;
|
||||
g.turn = 1;
|
||||
g.active = null;
|
||||
g = openPhase(g);
|
||||
assert.equal(g.turn, 2);
|
||||
assert.equal(g.phase, 0);
|
||||
assert.equal(g.stormMovementSource?.kind, 'card');
|
||||
return openPhase(g);
|
||||
}
|
||||
if (kind === 'atreidesSpice') {
|
||||
|
||||
Reference in new issue
Block a user