Adopt authorized Advanced Storm and alliance separation defaults

This commit is contained in:
Quintine committed 2026-10-01 14:20:59 +10:00
1 parent 42324b4a99
commit e1056a4db0
30 files changed
+2955 -82

No files matched your search

+47 -14
View File
@@ -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,
);
}
}
});
+44 -8
View File
@@ -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);
});
+135
View File
@@ -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 &amp; 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 &amp; 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();
+251
View File
@@ -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);
});
+600
View File
@@ -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);
});
}
+198
View File
@@ -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);
});
+177
View File
@@ -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);
}
}
}
});
+3
View File
@@ -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 = [];
+16 -10
View File
@@ -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);
+370
View File
@@ -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
View File
@@ -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
}
+15 -1
View File
@@ -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);
+9 -5
View File
@@ -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 };
+7 -3
View File
@@ -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') {