Prototype Discovery free entry, Cistern income and carried flight
Connect next-turn optional entry before storm/HMS movement, with typed multi-sector groups, normal source-storm/occupancy checks, legal bot and human choices, signed saved arrivals and Bene Gesserit reaction continuation. Cistern pays its sole occupant two bank spice through canonical Collection. A carried Ornithopter grants fixed range three on a later-turn actual movement, preserves pending responses and independent Treachery/No-Field custody, and is removed only after the selected group moves. Independent reviews bind pending entry destination baselines, typed custody and exact reaction continuations. Browser QA caught and fixed an incorrect decision heading. Orgiz remains disabled pending the stacked-Blow ruling; Jacurutu battle income, Ecological Testing Station and Shrine remain missing. Mode and publication gates stay closed; this checkpoint does not certify full Discovery compliance. Verification: types/lint and 4,313 offline tests passed (49 added), production build passed, and 40 HTTP tests passed. Final stable run durations: check 139145ms, build 6530ms, integration 24421ms. An earlier run found one stale Discovery notice assertion, now updated; the next run was interrupted by a user-confirmed power outage. No interrupted result is claimed as success. Targeted browser room CA782FQE used genuine human Atreides and two-AI setup, then physical QA positioning for partial free entry and a three-territory Ornithopter move. Refresh preserved its private hand, forces, spice and token removal. The power outage stopped both server and checks; the absent server was restarted after a private backup. The same browser seat restored correctly and all 157 pre-restart rooms were unchanged. Final preservation: all 156 opening rooms unchanged, 31 added QA/integration rooms, total 187. No database reset. Source fingerprint: 7b639d7f6559a88b00f3cbf913cfa7e6c400bfabb8c392e7b732e8ea77f6dcbc Private report: /home/quintine/.local/share/dune/checkpoints/20260913-discovery-entry-flight/verification-recovered/report.json Aggregate goal counter 61,819,586 -> 63,110,032 at completed verification: 1,290,446 tokens before final Git overhead for three connected functions, including helper coordination, reviews, browser QA and interrupted checks.
This commit is contained in:
1 parent
f0256c1b38
commit
ab0ab52849
28 files changed
+3873
-59
No files matched your search
@@ -0,0 +1,418 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import {
|
||||
createDiscoveryState,
|
||||
validateDiscoveryState,
|
||||
type DiscoveryLocationId,
|
||||
} from '../game/discoveries';
|
||||
import {
|
||||
CISTERN,
|
||||
ORGIZ_PROCESSING_STATION,
|
||||
quoteDiscoveryCollection,
|
||||
} from '../game/discovery-collection';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
|
||||
type Seat = [id: string, faction: FactionId];
|
||||
|
||||
function fixture(
|
||||
advanced = false,
|
||||
seats: Seat[] = [
|
||||
['a', 'atreides'],
|
||||
['h', 'harkonnen'],
|
||||
['g', 'guild'],
|
||||
],
|
||||
): Game {
|
||||
const [first, ...rest] = seats;
|
||||
const game = createGame(
|
||||
'DISCOVERYCOLLECTION',
|
||||
newPlayer(first[0], first[0], first[1]),
|
||||
advanced,
|
||||
);
|
||||
game.players.push(...rest.map(([id, faction]) => newPlayer(id, id, faction)));
|
||||
Object.assign(game, {
|
||||
status: 'playing',
|
||||
phase: 7,
|
||||
turn: 2,
|
||||
storm: 18,
|
||||
order: seats.map(([id]) => id),
|
||||
spice: {},
|
||||
discoveries: createDiscoveryState(() => 0),
|
||||
});
|
||||
for (const player of game.players) {
|
||||
player.forces = {};
|
||||
player.spice = 0;
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
function reveal(game: Game, id: DiscoveryLocationId) {
|
||||
const token = game.discoveries!.tokens.find((entry) => entry.face === id)!;
|
||||
const smuggler = token.type === 'smuggler';
|
||||
Object.assign(token, {
|
||||
status: 'placed',
|
||||
territory: smuggler ? 'pasty_mesa' : 'meridian',
|
||||
sector: smuggler ? 7 : 1,
|
||||
revealedTurn: 2,
|
||||
});
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
}
|
||||
|
||||
function composed(game: Game, orgizObservableDeposits = false) {
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
return {
|
||||
ordinary,
|
||||
discovery: quoteDiscoveryCollection(
|
||||
game,
|
||||
ordinary,
|
||||
orgizObservableDeposits ? { orgizObservableDeposits: true } : {},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function balance(
|
||||
quote: ReturnType<typeof quoteDiscoveryCollection>,
|
||||
player: string,
|
||||
) {
|
||||
return quote.receipts.find((receipt) => receipt.player === player)!.balance;
|
||||
}
|
||||
|
||||
void test('a sole Cistern occupant receives two bank spice in Basic and Advanced without changing collection facts', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const game = fixture(advanced);
|
||||
reveal(game, CISTERN);
|
||||
game.players[0].forces = { [`${CISTERN}:0`]: 1 };
|
||||
game.players[0].spice = 4;
|
||||
const before = structuredClone(game);
|
||||
const { ordinary, discovery } = composed(game);
|
||||
assert.deepEqual(discovery.effects, [
|
||||
{
|
||||
kind: 'cistern',
|
||||
player: 'a',
|
||||
amount: 2,
|
||||
source: 'bank',
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
balance(discovery, 'a'),
|
||||
balance({ ...discovery, receipts: ordinary.receipts }, 'a') + 2,
|
||||
);
|
||||
assert.deepEqual(
|
||||
discovery.receipts.map(({ player, collected, desert, strongholds }) => ({
|
||||
player,
|
||||
collected,
|
||||
desert,
|
||||
strongholds,
|
||||
})),
|
||||
ordinary.receipts.map(({ player, collected, desert, strongholds }) => ({
|
||||
player,
|
||||
collected,
|
||||
desert,
|
||||
strongholds,
|
||||
})),
|
||||
);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Cistern requires non-advisor occupation and has no effect while unrevealed or empty', () => {
|
||||
const hidden = fixture(true);
|
||||
const hiddenToken = hidden.discoveries!.tokens.find(
|
||||
(entry) => entry.face === CISTERN,
|
||||
)!;
|
||||
Object.assign(hiddenToken, {
|
||||
status: 'placed',
|
||||
territory: 'meridian',
|
||||
sector: 1,
|
||||
});
|
||||
assert.deepEqual(composed(hidden).discovery.effects, []);
|
||||
|
||||
const advisors = fixture(true, [
|
||||
['b', 'beneGesserit'],
|
||||
['h', 'harkonnen'],
|
||||
]);
|
||||
reveal(advisors, CISTERN);
|
||||
advisors.players[0].forces = { [`${CISTERN}:0`]: 1 };
|
||||
advisors.players[0].advisors = { [CISTERN]: {} };
|
||||
assert.deepEqual(composed(advisors).discovery.effects, []);
|
||||
});
|
||||
|
||||
void test('the bounded Orgiz quote transfers one spice from each uniquely collected observable board deposit', () => {
|
||||
const game = fixture();
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].forces = {
|
||||
[`${ORGIZ_PROCESSING_STATION}:0`]: 1,
|
||||
'red_chasm:7': 1,
|
||||
};
|
||||
game.players[1].forces = { 'hagga_basin:12': 1 };
|
||||
game.players[2].forces = { 'cielago_south:2': 1 };
|
||||
game.spice = {
|
||||
'red_chasm:7': 2,
|
||||
'hagga_basin:12': 4,
|
||||
'cielago_south:2': 3,
|
||||
};
|
||||
const before = structuredClone(game);
|
||||
const { ordinary, discovery } = composed(game, true);
|
||||
assert.deepEqual(discovery.effects, [
|
||||
{
|
||||
kind: 'orgiz',
|
||||
player: 'a',
|
||||
from: 'g',
|
||||
location: 'cielago_south:2',
|
||||
amount: 1,
|
||||
source: 'player',
|
||||
},
|
||||
{
|
||||
kind: 'orgiz',
|
||||
player: 'a',
|
||||
from: 'h',
|
||||
location: 'hagga_basin:12',
|
||||
amount: 1,
|
||||
source: 'player',
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
balance(discovery, 'a'),
|
||||
balance({ ...discovery, receipts: ordinary.receipts }, 'a') + 2,
|
||||
);
|
||||
assert.equal(
|
||||
balance(discovery, 'h'),
|
||||
balance({ ...discovery, receipts: ordinary.receipts }, 'h') - 1,
|
||||
);
|
||||
assert.equal(
|
||||
balance(discovery, 'g'),
|
||||
balance({ ...discovery, receipts: ordinary.receipts }, 'g') - 1,
|
||||
);
|
||||
assert.equal(
|
||||
discovery.receipts.reduce((sum, receipt) => sum + receipt.balance, 0),
|
||||
ordinary.receipts.reduce((sum, receipt) => sum + receipt.balance, 0),
|
||||
);
|
||||
assert.deepEqual(game, before);
|
||||
assert.deepEqual(quoteSpiceCollection(game), ordinary);
|
||||
});
|
||||
|
||||
void test('Orgiz does not transfer spice from its occupant to itself and ignores untouched deposits', () => {
|
||||
const game = fixture();
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].forces = {
|
||||
[`${ORGIZ_PROCESSING_STATION}:0`]: 1,
|
||||
'red_chasm:7': 1,
|
||||
};
|
||||
game.players[1].forces = { 'hagga_basin:12': 1 };
|
||||
game.spice = {
|
||||
'red_chasm:7': 2,
|
||||
'hagga_basin:12': 2,
|
||||
'cielago_south:2': 6,
|
||||
};
|
||||
const disabled = quoteDiscoveryCollection(game, quoteSpiceCollection(game));
|
||||
assert.deepEqual(disabled.effects, []);
|
||||
assert.deepEqual(disabled.receipts, quoteSpiceCollection(game).receipts);
|
||||
const { ordinary, discovery } = composed(game, true);
|
||||
assert.deepEqual(discovery.effects, [
|
||||
{
|
||||
kind: 'orgiz',
|
||||
player: 'a',
|
||||
from: 'h',
|
||||
location: 'hagga_basin:12',
|
||||
amount: 1,
|
||||
source: 'player',
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
balance(discovery, 'a'),
|
||||
balance({ ...discovery, receipts: ordinary.receipts }, 'a') + 1,
|
||||
);
|
||||
assert.equal(discovery.receipts[0].collected, ordinary.receipts[0].collected);
|
||||
assert.equal(discovery.receipts[0].desert, ordinary.receipts[0].desert);
|
||||
});
|
||||
|
||||
void test('Orgiz retains unresolved Ecaz shared custody instead of creating or assigning spice', () => {
|
||||
const game = fixture(false, [
|
||||
['e', 'ecaz'],
|
||||
['a', 'atreides'],
|
||||
['g', 'guild'],
|
||||
]);
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].ally = 'a';
|
||||
game.players[1].ally = 'e';
|
||||
game.players[0].forces = { 'hagga_basin:12': 1 };
|
||||
game.players[1].forces = { 'hagga_basin:12': 1 };
|
||||
game.players[2].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.spice = { 'hagga_basin:12': 4 };
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
assert.deepEqual(ordinary.shared, [
|
||||
{ territory: 'hagga_basin', ecaz: 'e', ally: 'a', amount: 4 },
|
||||
]);
|
||||
const before = structuredClone({ game, ordinary });
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteDiscoveryCollection(game, ordinary, {
|
||||
orgizObservableDeposits: true,
|
||||
}),
|
||||
/unresolved Ecaz shared lot/,
|
||||
);
|
||||
assert.deepEqual({ game, ordinary }, before);
|
||||
});
|
||||
|
||||
void test('a contested Cistern retains the unresolved bonus without blocking collection', () => {
|
||||
const game = fixture(false, [
|
||||
['e', 'ecaz'],
|
||||
['a', 'atreides'],
|
||||
['g', 'guild'],
|
||||
]);
|
||||
reveal(game, CISTERN);
|
||||
game.players[0].ally = 'a';
|
||||
game.players[1].ally = 'e';
|
||||
game.players[0].forces = { [`${CISTERN}:0`]: 1 };
|
||||
game.players[1].forces = { [`${CISTERN}:0`]: 1 };
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
const before = structuredClone({ game, ordinary });
|
||||
assert.deepEqual(quoteDiscoveryCollection(game, ordinary), {
|
||||
receipts: ordinary.receipts,
|
||||
effects: [],
|
||||
});
|
||||
assert.deepEqual({ game, ordinary }, before);
|
||||
});
|
||||
|
||||
void test('the real collection phase credits Cistern once and preserves the receipt through JSON recovery', () => {
|
||||
const game = fixture(false);
|
||||
game.discoveryEnabled = true;
|
||||
reveal(game, CISTERN);
|
||||
Object.assign(game, {
|
||||
phase: 5,
|
||||
active: 'g',
|
||||
movementRemaining: ['g'],
|
||||
phaseOpening: null,
|
||||
});
|
||||
for (const player of game.players)
|
||||
Object.assign(player, {
|
||||
hand: [],
|
||||
traitors: [],
|
||||
traitorChoices: [],
|
||||
shipped: true,
|
||||
moved: 1,
|
||||
reserves: 20,
|
||||
});
|
||||
game.players[0].forces = { [`${CISTERN}:0`]: 1 };
|
||||
game.players[0].forces['red_chasm:7'] = 1;
|
||||
game.players[0].reserves = 18;
|
||||
game.players[0].spice = 4;
|
||||
game.spice = { 'red_chasm:7': 2 };
|
||||
|
||||
const before = structuredClone(game);
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
assert.equal(
|
||||
ordinary.receipts.find((receipt) => receipt.player === 'a')!.balance,
|
||||
6,
|
||||
);
|
||||
let collected = applyAction(game, 'g', { type: 'endMovement' });
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(collected.phase, 7);
|
||||
assert.equal(collected.players.find((player) => player.id === 'a')!.spice, 8);
|
||||
assert.equal(collected.spice['red_chasm:7'], 0);
|
||||
assert.equal(
|
||||
collected.log.filter((entry) =>
|
||||
entry.text.includes(
|
||||
'received 2 spice from the bank for occupying Cistern',
|
||||
),
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
|
||||
collected = JSON.parse(JSON.stringify(collected)) as Game;
|
||||
assert.deepEqual(normalizeAutomaticGame(collected), collected);
|
||||
assert.equal(collected.players.find((player) => player.id === 'a')!.spice, 8);
|
||||
assert.equal(
|
||||
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
void test('a saved Ecaz response commits Cistern once only after the collection response resolves', () => {
|
||||
const game = createGame(
|
||||
'DISCOVERYECAZCOLLECTION',
|
||||
newPlayer('ec', 'Ecaz', 'ecaz'),
|
||||
true,
|
||||
['ecaz'],
|
||||
);
|
||||
game.players.push(
|
||||
newPlayer('al', 'Ally', 'atreides'),
|
||||
newPlayer('en', 'Rival', 'emperor'),
|
||||
);
|
||||
Object.assign(game, {
|
||||
status: 'playing',
|
||||
phase: 5,
|
||||
turn: 2,
|
||||
storm: 18,
|
||||
order: ['ec', 'al', 'en'],
|
||||
active: 'en',
|
||||
movementRemaining: ['en'],
|
||||
phaseOpening: null,
|
||||
spice: {},
|
||||
deck: baseDeck(),
|
||||
discard: [],
|
||||
discoveryEnabled: true,
|
||||
discoveries: createDiscoveryState(() => 0),
|
||||
});
|
||||
reveal(game, CISTERN);
|
||||
for (const player of game.players)
|
||||
Object.assign(player, {
|
||||
spice: 10,
|
||||
hand: [],
|
||||
traitors: [],
|
||||
traitorChoices: [],
|
||||
forces: {},
|
||||
reserves: 20,
|
||||
shipped: true,
|
||||
moved: 1,
|
||||
});
|
||||
game.players[0].ally = 'al';
|
||||
game.players[1].ally = 'ec';
|
||||
game.players[0].allySinceTurn = 1;
|
||||
game.players[1].allySinceTurn = 1;
|
||||
game.players[0].forces = { 'arrakeen:10': 1 };
|
||||
game.players[1].forces = { 'arrakeen:10': 1 };
|
||||
game.players[2].forces = { [`${CISTERN}:0`]: 1 };
|
||||
for (const player of game.players) player.reserves = 19;
|
||||
const karama = game.deck.findIndex((card) => card.name === 'Karama');
|
||||
assert.ok(karama >= 0);
|
||||
game.players[2].hand.push(game.deck.splice(karama, 1)[0]);
|
||||
|
||||
let collected = applyAction(game, 'en', { type: 'endMovement' });
|
||||
assert.equal(collected.response?.kind, 'ecazCollection');
|
||||
assert.equal(collected.players[2].spice, 10);
|
||||
assert.equal(
|
||||
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
|
||||
0,
|
||||
);
|
||||
|
||||
collected = JSON.parse(JSON.stringify(collected)) as Game;
|
||||
for (let count = 0; collected.response && count < 10; count++) {
|
||||
const responder = collected.players.find(
|
||||
(player) => !collected.response!.passed.includes(player.id),
|
||||
)!;
|
||||
collected = applyAction(collected, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(collected.response, null);
|
||||
assert.equal(collected.players[2].spice, 12);
|
||||
assert.equal(
|
||||
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
|
||||
1,
|
||||
);
|
||||
|
||||
collected = JSON.parse(JSON.stringify(collected)) as Game;
|
||||
assert.deepEqual(normalizeAutomaticGame(collected), collected);
|
||||
assert.equal(collected.players[2].spice, 12);
|
||||
assert.equal(
|
||||
collected.log.filter((entry) => entry.automatic?.name === 'Cistern').length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
@@ -104,7 +104,8 @@ void test('all eight authorized faces have readable rules while shared location
|
||||
'Receive seven spice', 'On a later turn', 'separate territory inside', 'At most two factions',
|
||||
'protected from storms and sandworms', 'before the storm and the mobile stronghold move']) assert.ok(markup.includes(text), text);
|
||||
assert.doesNotMatch(markup, /href="https?:/);
|
||||
assert.match(markup, /still in development/);
|
||||
assert.ok(markup.includes('Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype.'));
|
||||
assert.ok(markup.includes('Jacurutu battle income, Ecological Testing Station, Shrine and Orgiz abilities remain in development.'));
|
||||
});
|
||||
|
||||
void test('all four AI profiles inspect and then reveal through fresh private views and actual saved actions', () => {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { DiscoveryEntryOffer } from '../game/discovery-entry';
|
||||
import {
|
||||
discoveryEntryBotActions,
|
||||
discoveryEntryDeclineAction,
|
||||
discoveryEntryMoveAction,
|
||||
} from '../game/discovery-entry-options';
|
||||
import { DiscoveryEntryDecision } from '../components/discovery-entry';
|
||||
|
||||
type EntryView = GameView & { discoveryEntry: DiscoveryEntryOffer | null };
|
||||
|
||||
function view(): EntryView {
|
||||
return {
|
||||
status: 'playing',
|
||||
me: 'f',
|
||||
decision: {
|
||||
kind: 'discoveryEntry',
|
||||
player: 'f',
|
||||
event: 'entry-round:0',
|
||||
},
|
||||
discoveryEntry: {
|
||||
event: 'entry-round:0',
|
||||
token: 'discovery-token-1',
|
||||
owner: 'f',
|
||||
parent: 'meridian',
|
||||
destination: 'jacurutu-sietch',
|
||||
sector: 0,
|
||||
sources: [
|
||||
{ source: 'meridian:2', normal: 2, elite: 1 },
|
||||
{ source: 'meridian:3', normal: 1, elite: 1 },
|
||||
],
|
||||
blocked: null,
|
||||
arrival: null,
|
||||
},
|
||||
players: [
|
||||
{
|
||||
id: 'f',
|
||||
name: 'Fremen',
|
||||
faction: 'fremen',
|
||||
autopilot: null,
|
||||
},
|
||||
],
|
||||
} as unknown as EntryView;
|
||||
}
|
||||
|
||||
function html(game: EntryView, busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(DiscoveryEntryDecision, {
|
||||
game,
|
||||
busy,
|
||||
act() {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void test('entry actions bind the projected event and exact positive typed source subsets', () => {
|
||||
const game = view(), before = structuredClone(game);
|
||||
assert.deepEqual(
|
||||
discoveryEntryMoveAction(game, [
|
||||
{ source: 'meridian:3', normal: 1, elite: 0 },
|
||||
{ source: 'meridian:2', normal: 0, elite: 1 },
|
||||
]),
|
||||
{
|
||||
type: 'decision',
|
||||
event: 'entry-round:0',
|
||||
accept: true,
|
||||
groups: [
|
||||
{ source: 'meridian:2', normal: 0, elite: 1 },
|
||||
{ source: 'meridian:3', normal: 1, elite: 0 },
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(discoveryEntryDeclineAction(game), {
|
||||
type: 'decision',
|
||||
event: 'entry-round:0',
|
||||
accept: false,
|
||||
});
|
||||
assert.equal(discoveryEntryMoveAction(game, []), null);
|
||||
assert.equal(
|
||||
discoveryEntryMoveAction(game, [
|
||||
{ source: 'meridian:2', normal: 3, elite: 0 },
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
discoveryEntryMoveAction(game, [
|
||||
{ source: 'meridian:2', normal: 0, elite: 0 },
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
discoveryEntryMoveAction(game, [
|
||||
{ source: 'meridian:2', normal: 1, elite: 0 },
|
||||
{ source: 'meridian:2', normal: 1, elite: 0 },
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('the legal bot path moves every quoted typed force and retains decline second', () => {
|
||||
const game = view(), before = structuredClone(game);
|
||||
const expected = [
|
||||
{
|
||||
type: 'decision',
|
||||
event: 'entry-round:0',
|
||||
accept: true,
|
||||
groups: game.discoveryEntry!.sources,
|
||||
},
|
||||
{
|
||||
type: 'decision',
|
||||
event: 'entry-round:0',
|
||||
accept: false,
|
||||
},
|
||||
];
|
||||
assert.deepEqual(discoveryEntryBotActions(game), expected);
|
||||
assert.deepEqual(botActions(game), expected);
|
||||
assert.deepEqual(game, before);
|
||||
|
||||
const blocked = structuredClone(game);
|
||||
blocked.discoveryEntry!.blocked = 'The location is full.';
|
||||
assert.deepEqual(discoveryEntryBotActions(blocked), []);
|
||||
const observer = structuredClone(game);
|
||||
observer.me = 'a';
|
||||
assert.deepEqual(discoveryEntryBotActions(observer), []);
|
||||
const stale = structuredClone(game);
|
||||
(stale.decision as { event: string }).event = 'entry-round:3';
|
||||
assert.deepEqual(discoveryEntryBotActions(stale), []);
|
||||
});
|
||||
|
||||
void test('the chooser explains free entry and renders all-sector ordinary and elite controls', () => {
|
||||
const markup = html(view());
|
||||
for (const text of [
|
||||
'Enter Jacurutu Sietch',
|
||||
'Meridian',
|
||||
'before the storm',
|
||||
'does not use shipment or movement',
|
||||
'sector 2',
|
||||
'sector 3',
|
||||
'Available: 2 ordinary · 1 Fedaykin',
|
||||
'Select all forces',
|
||||
'Clear selection',
|
||||
'Move 5 forces inside',
|
||||
'Leave forces outside',
|
||||
])
|
||||
assert.ok(markup.includes(text), text);
|
||||
assert.equal((markup.match(/type="number"/g) ?? []).length, 4);
|
||||
assert.equal((markup.match(/max="1"/g) ?? []).length, 3);
|
||||
});
|
||||
|
||||
void test('busy and autopilot views disable every action while mismatched controls render nothing', () => {
|
||||
const busy = html(view(), true);
|
||||
assert.equal(
|
||||
(busy.match(/<button[^>]*disabled=""/g) ?? []).length,
|
||||
4,
|
||||
);
|
||||
const autopilot = view();
|
||||
autopilot.players[0].autopilot = 'Hard';
|
||||
assert.equal(
|
||||
(html(autopilot).match(/<button[^>]*disabled=""/g) ?? []).length,
|
||||
4,
|
||||
);
|
||||
|
||||
const wrongEvent = view();
|
||||
wrongEvent.discoveryEntry!.event = 'entry-round:1';
|
||||
assert.equal(html(wrongEvent), '');
|
||||
const wrongOwner = view();
|
||||
wrongOwner.discoveryEntry!.owner = 'a';
|
||||
assert.equal(html(wrongOwner), '');
|
||||
const noOffer = view();
|
||||
noOffer.discoveryEntry = null;
|
||||
assert.equal(html(noOffer), '');
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { MOBILE_LOCATION } from '../game/board';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { createDiscoveryState } from '../game/discoveries';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeDiscoveryGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { enterDiscoveryCollection } from './fixture-discovery';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
|
||||
function initialized(
|
||||
factions: [FactionId, FactionId, FactionId],
|
||||
expansions: string[] = [],
|
||||
) {
|
||||
let game = createGame(
|
||||
'ENTRYREVIEW',
|
||||
newPlayer('a', 'Actor', factions[0]),
|
||||
true,
|
||||
expansions,
|
||||
);
|
||||
joinGame(game, newPlayer('b', 'Second', factions[1]));
|
||||
joinGame(game, newPlayer('g', 'Third', factions[2]));
|
||||
for (const player of game.players)
|
||||
game = applyAction(game, player.id, { type: 'ready' });
|
||||
game.discoveryEnabled = true;
|
||||
game = initializeDiscoveryGameForAudit(game);
|
||||
for (let step = 0; step < 80 && game.status === 'setup'; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find((seat) => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) {
|
||||
next = applyAction(game, player.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
next,
|
||||
`setup stalled at ${game.setupStage}/${game.decision?.kind}`,
|
||||
);
|
||||
game = next;
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
return game;
|
||||
}
|
||||
|
||||
function revealCistern(game: Game, owner = 'a') {
|
||||
const token = enterDiscoveryCollection(game, 'cistern', owner);
|
||||
if (viewGame(game, owner).discoveries!.canInspect.includes(token.id))
|
||||
game = applyAction(game, owner, {
|
||||
type: 'discovery',
|
||||
token: token.id,
|
||||
reveal: false,
|
||||
});
|
||||
game = applyAction(game, owner, {
|
||||
type: 'discovery',
|
||||
token: token.id,
|
||||
reveal: true,
|
||||
});
|
||||
return { game, parent: `${token.territory}:${token.sector}` };
|
||||
}
|
||||
|
||||
function nextTurn(game: Game) {
|
||||
Object.assign(game, {
|
||||
phase: 8,
|
||||
ready: [],
|
||||
decision: null,
|
||||
response: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
for (const player of game.players)
|
||||
game = applyAction(game, player.id, { type: 'ready' });
|
||||
return game;
|
||||
}
|
||||
|
||||
function entryAction(game: Game): Action {
|
||||
assert.equal(game.decision?.kind, 'discoveryEntry');
|
||||
const offer = viewGame(game, game.decision.player).discoveryEntry!;
|
||||
return {
|
||||
type: 'decision',
|
||||
event: offer.event,
|
||||
accept: true,
|
||||
groups: offer.sources,
|
||||
};
|
||||
}
|
||||
|
||||
function pendingAdvisorEntry(
|
||||
owner: 'atreides' | 'fremen' = 'atreides',
|
||||
existing = 0,
|
||||
) {
|
||||
const revealed = revealCistern(initialized([owner, 'beneGesserit', 'guild']));
|
||||
let game = revealed.game;
|
||||
const parent = revealed.parent;
|
||||
const actor = game.players[0];
|
||||
if (owner === 'fremen') {
|
||||
actor.elites!.reserves--;
|
||||
actor.elites!.forces[parent] = 1;
|
||||
}
|
||||
if (existing) {
|
||||
actor.reserves -= existing;
|
||||
actor.forces['cistern:0'] = existing;
|
||||
}
|
||||
const beneGesserit = game.players[1];
|
||||
beneGesserit.reserves--;
|
||||
beneGesserit.forces['cistern:0'] = 1;
|
||||
const karama = game.deck.find((card) => card.effect === 'karama');
|
||||
assert.ok(karama);
|
||||
game.deck = game.deck.filter((card) => card.id !== karama.id);
|
||||
actor.hand.push(karama);
|
||||
game = nextTurn(game);
|
||||
game = applyAction(game, actor.id, entryAction(game));
|
||||
assert.equal(game.discoveryEntry?.stage, 'arrival');
|
||||
assert.equal(game.decision?.kind, 'intrusion');
|
||||
return { game, parent };
|
||||
}
|
||||
|
||||
void test('saved Discovery arrival binds the transferred force stack and rejects conflicting intrusion continuations', () => {
|
||||
const { game } = pendingAdvisorEntry();
|
||||
assert.equal(game.players[0].forces['cistern:0'], 2);
|
||||
assert.doesNotThrow(() => viewGame(game, 'a'));
|
||||
|
||||
const retargeted = reload(game);
|
||||
retargeted.players[0].forces['cistern:0']--;
|
||||
retargeted.players[0].forces['arrakeen:10'] =
|
||||
(retargeted.players[0].forces['arrakeen:10'] ?? 0) + 1;
|
||||
assert.throws(
|
||||
() => viewGame(retargeted, 'a'),
|
||||
/transferred ordinary or elite force custody/,
|
||||
);
|
||||
|
||||
const conflicting = reload(game);
|
||||
assert.equal(conflicting.decision?.kind, 'intrusion');
|
||||
if (conflicting.decision?.kind === 'intrusion')
|
||||
conflicting.decision.followup = {
|
||||
shipment: 'a',
|
||||
destination: 'polar_sink:0',
|
||||
};
|
||||
assert.throws(() => viewGame(conflicting, 'b'), /Bene Gesserit interaction/);
|
||||
|
||||
const response = applyAction(reload(game), 'b', {
|
||||
type: 'decision',
|
||||
accept: true,
|
||||
});
|
||||
assert.equal(response.response?.kind, 'advisorFlip');
|
||||
assert.equal(response.response?.advisorResume, 'discoveryEntry');
|
||||
const misrouted = reload(response);
|
||||
misrouted.response!.advisorFollowup = {
|
||||
shipment: 'a',
|
||||
destination: 'polar_sink:0',
|
||||
};
|
||||
assert.throws(() => viewGame(misrouted, 'a'), /Bene Gesserit interaction/);
|
||||
});
|
||||
|
||||
void test('saved Fedaykin arrival binds its elite overlay until the BG response finishes', () => {
|
||||
const { game, parent } = pendingAdvisorEntry('fremen');
|
||||
const fremen = game.players[0];
|
||||
assert.equal(fremen.forces['cistern:0'], 2);
|
||||
assert.equal(fremen.elites!.forces['cistern:0'], 1);
|
||||
|
||||
const corrupted = reload(game);
|
||||
delete corrupted.players[0].elites!.forces['cistern:0'];
|
||||
corrupted.players[0].elites!.forces[parent] = 1;
|
||||
assert.throws(
|
||||
() => viewGame(corrupted, 'a'),
|
||||
/transferred ordinary or elite force custody/,
|
||||
);
|
||||
|
||||
let resumed = applyAction(reload(game), 'b', {
|
||||
type: 'decision',
|
||||
accept: true,
|
||||
});
|
||||
while (resumed.response) {
|
||||
const responder = resumed.players.find(
|
||||
(player) => !resumed.response!.passed.includes(player.id),
|
||||
)!;
|
||||
resumed = applyAction(reload(resumed), responder.id, {
|
||||
type: 'passResponse',
|
||||
});
|
||||
}
|
||||
assert.equal(resumed.discoveryEntry, undefined);
|
||||
assert.equal(isAdvisor(resumed.players[1], 'cistern'), true);
|
||||
assert.equal(resumed.players[0].elites!.forces['cistern:0'], 1);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(resumed)), resumed);
|
||||
});
|
||||
|
||||
void test('signed pre-arrival custody permits an existing owner stack beside BG and binds only the added forces', () => {
|
||||
const { game } = pendingAdvisorEntry('atreides', 1);
|
||||
assert.equal(game.discoveryEntry?.arrival?.destinationBefore, 1);
|
||||
assert.equal(game.discoveryEntry?.arrival?.destinationEliteBefore, 0);
|
||||
assert.equal(game.players[0].forces['cistern:0'], 3);
|
||||
assert.doesNotThrow(() => viewGame(reload(game), 'a'));
|
||||
|
||||
let resumed = applyAction(reload(game), 'b', {
|
||||
type: 'decision',
|
||||
accept: false,
|
||||
});
|
||||
assert.equal(resumed.discoveryEntry, undefined);
|
||||
assert.equal(resumed.players[0].forces['cistern:0'], 3);
|
||||
resumed = normalizeAutomaticGame(reload(resumed));
|
||||
assert.equal(resumed.players[0].forces['cistern:0'], 3);
|
||||
});
|
||||
|
||||
void test('automatic no-choice skips retain the Hidden Mobile Stronghold opening before normal storm continuation', () => {
|
||||
let game = createGame(
|
||||
'ENTRYHMSREVIEW',
|
||||
newPlayer('a', 'Ixians', 'ixians'),
|
||||
true,
|
||||
['ix'],
|
||||
);
|
||||
game.players.push(
|
||||
newPlayer('b', 'Guild', 'guild'),
|
||||
newPlayer('g', 'Fremen', 'fremen'),
|
||||
);
|
||||
Object.assign(game, {
|
||||
status: 'playing',
|
||||
turn: 1,
|
||||
phase: 7,
|
||||
storm: 18,
|
||||
order: ['a', 'b', 'g'],
|
||||
ready: [],
|
||||
decision: null,
|
||||
response: null,
|
||||
phaseOpening: null,
|
||||
discoveryEnabled: true,
|
||||
discoveries: createDiscoveryState(() => 0),
|
||||
});
|
||||
for (const player of game.players) {
|
||||
player.forces = {};
|
||||
player.reserves = 20;
|
||||
}
|
||||
({ game } = revealCistern(game));
|
||||
game.mobileStronghold = { location: 'polar_sink:0' };
|
||||
const ixians = game.players[0];
|
||||
ixians.reserves--;
|
||||
ixians.forces[MOBILE_LOCATION] = (ixians.forces[MOBILE_LOCATION] ?? 0) + 1;
|
||||
game = nextTurn(game);
|
||||
assert.ok(game.phaseOpening);
|
||||
for (const player of game.players)
|
||||
game = applyAction(game, player.id, { type: 'ready' });
|
||||
assert.equal(
|
||||
game.decision?.kind,
|
||||
'discoveryEntry',
|
||||
JSON.stringify({
|
||||
turn: game.turn,
|
||||
phase: game.phase,
|
||||
entry: game.discoveryEntry,
|
||||
newlyRevealed: game.discoveries?.newlyRevealed,
|
||||
forces: game.players[0].forces,
|
||||
storm: game.storm,
|
||||
stormPending: game.stormPending,
|
||||
mobileStronghold: game.mobileStronghold,
|
||||
}),
|
||||
);
|
||||
game = applyAction(game, 'a', {
|
||||
type: 'decision',
|
||||
event: game.decision!.event,
|
||||
accept: false,
|
||||
});
|
||||
assert.equal(game.discoveryEntry, undefined);
|
||||
assert.equal(game.decision?.kind, 'mobileStronghold');
|
||||
assert.equal(game.phase, 0);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
|
||||
game = applyAction(game, 'a', { type: 'decision', decline: true });
|
||||
assert.equal(game.phase, 0);
|
||||
assert.equal(game.decision, null);
|
||||
assert.ok(game.log.some((entry) => entry.text === 'Turn 2 begins.'));
|
||||
});
|
||||
|
||||
void test('one-force entry uses singular chronicle wording and resumes the ordinary storm opening', () => {
|
||||
let { game } = revealCistern(initialized(['atreides', 'guild', 'fremen']));
|
||||
game = nextTurn(game);
|
||||
const offer = viewGame(game, 'a').discoveryEntry!;
|
||||
game = applyAction(game, 'a', {
|
||||
type: 'decision',
|
||||
event: offer.event,
|
||||
accept: true,
|
||||
groups: [{ ...offer.sources[0], normal: 1, elite: 0 }],
|
||||
});
|
||||
assert.equal(game.discoveryEntry, undefined);
|
||||
assert.equal(game.players[0].forces['cistern:0'], 1);
|
||||
assert.ok(
|
||||
game.log.some((entry) => entry.text.includes('moved 1 force from')),
|
||||
);
|
||||
assert.ok(game.log.every((entry) => !entry.text.includes('moved 1 forces')));
|
||||
assert.ok(game.log.some((entry) => entry.text === 'Turn 2 begins.'));
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeDiscoveryGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
discoveryFixture,
|
||||
enterDiscoveryCollection,
|
||||
} from './fixture-discovery';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
function reveal(
|
||||
g: Game,
|
||||
face: 'cistern' | 'shrine' = 'cistern',
|
||||
owner = g.players[0].id,
|
||||
) {
|
||||
const token = enterDiscoveryCollection(g, face, owner);
|
||||
if (viewGame(g, owner).discoveries!.canInspect.includes(token.id))
|
||||
g = applyAction(g, owner, {
|
||||
type: 'discovery',
|
||||
token: token.id,
|
||||
reveal: false,
|
||||
});
|
||||
return applyAction(g, owner, {
|
||||
type: 'discovery',
|
||||
token: token.id,
|
||||
reveal: true,
|
||||
});
|
||||
}
|
||||
function nextTurn(g: Game) {
|
||||
Object.assign(g, {
|
||||
phase: 8,
|
||||
ready: [],
|
||||
decision: null,
|
||||
response: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
return g;
|
||||
}
|
||||
function accept(g: Game) {
|
||||
assert.equal(g.decision?.kind, 'discoveryEntry');
|
||||
const offer = viewGame(g, g.decision!.player).discoveryEntry!;
|
||||
return {
|
||||
type: 'decision',
|
||||
accept: true,
|
||||
event: offer.event,
|
||||
groups: offer.sources,
|
||||
};
|
||||
}
|
||||
function custody(g: Game) {
|
||||
for (const p of g.players) {
|
||||
assert.equal(
|
||||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||||
20,
|
||||
);
|
||||
if (p.elites)
|
||||
assert.equal(
|
||||
p.elites.reserves +
|
||||
p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
|
||||
p.faction === 'fremen' ? 3 : 5,
|
||||
);
|
||||
}
|
||||
}
|
||||
function reject(g: Game, id: string, action: Action) {
|
||||
const before = reload(g);
|
||||
assert.throws(() => applyAction(g, id, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
void test('next-turn free entry is a real owned decision before storm; partial movement survives reload and consumes no ordinary move or spice', () => {
|
||||
let g = nextTurn(reveal(discoveryFixture()));
|
||||
assert.equal(g.turn, 2);
|
||||
assert.equal(g.phase, 0);
|
||||
assert.equal(g.decision?.kind, 'discoveryEntry');
|
||||
assert.equal(g.decision?.player, 'a');
|
||||
const before = reload(g),
|
||||
offer = viewGame(g, 'a').discoveryEntry!;
|
||||
assert.equal(viewGame(g, 'g').discoveryEntry, null);
|
||||
const selection = {
|
||||
...accept(g),
|
||||
groups: offer.sources.map((s) => ({ ...s, normal: 1, elite: 0 })),
|
||||
};
|
||||
offer.sources[0].normal = 20;
|
||||
assert.deepEqual(g, before);
|
||||
g = applyAction(reload(g), 'a', selection);
|
||||
assert.equal(g.players[0].forces['cistern:0'], 1);
|
||||
assert.equal(g.players[0].forces['gara_kulon:8'], 1);
|
||||
assert.equal(g.players[0].moved, before.players[0].moved);
|
||||
assert.equal(g.players[0].spice, before.players[0].spice);
|
||||
assert.equal(g.discoveryEntry, undefined);
|
||||
assert.deepEqual(g.discoveries!.newlyRevealed, []);
|
||||
assert.equal(g.phase, 0);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
|
||||
reject(g, 'a', selection);
|
||||
custody(g);
|
||||
});
|
||||
void test('decline, wrong owner, storm source and invalid typed groups cannot spend or retarget free entry', () => {
|
||||
const g = nextTurn(reveal(discoveryFixture()));
|
||||
const action = accept(g);
|
||||
reject(g, 'g', action);
|
||||
reject(g, 'a', { ...action, event: 'old-entry' });
|
||||
reject(g, 'a', {
|
||||
...action,
|
||||
groups: [{ source: 'arrakeen:9', normal: 1, elite: 0 }],
|
||||
});
|
||||
reject(g, 'a', {
|
||||
...action,
|
||||
groups: [{ source: 'gara_kulon:8', normal: 0, elite: 1 }],
|
||||
});
|
||||
reject(g, 'a', { type: 'storm', dial: 1 });
|
||||
const done = applyAction(reload(g), 'a', {
|
||||
type: 'decision',
|
||||
accept: false,
|
||||
event: action.event,
|
||||
});
|
||||
assert.equal(done.players[0].forces['cistern:0'], undefined);
|
||||
assert.equal(done.discoveryEntry, undefined);
|
||||
const storm = reveal(discoveryFixture());
|
||||
storm.storm = 8;
|
||||
const skipped = nextTurn(storm);
|
||||
assert.equal(skipped.discoveryEntry, undefined);
|
||||
custody(skipped);
|
||||
});
|
||||
void test('typed Fedaykin free entry preserves physical forces and all four AI policies select a legal decision', () => {
|
||||
let g = reveal(discoveryFixture(true), 'cistern', 'f');
|
||||
const f = g.players.find((p) => p.id === 'f')!;
|
||||
f.elites!.reserves--;
|
||||
f.elites!.forces['gara_kulon:8'] = 1;
|
||||
g = nextTurn(g);
|
||||
assert.equal(g.decision?.player, 'f');
|
||||
const action = accept(g);
|
||||
assert.equal(action.groups[0].elite, 1);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = viewGame(g, 'f');
|
||||
v.players.find((p) => p.id === 'f')!.bot = difficulty;
|
||||
const choices = botActions(v);
|
||||
assert.ok(choices.length);
|
||||
for (const choice of choices)
|
||||
assert.doesNotThrow(() => applyAction(g, 'f', choice));
|
||||
}
|
||||
g = applyAction(reload(g), 'f', action);
|
||||
assert.equal(
|
||||
g.players.find((p) => p.id === 'f')!.elites!.forces['cistern:0'],
|
||||
1,
|
||||
);
|
||||
custody(g);
|
||||
});
|
||||
function advisorFixture() {
|
||||
let g = createGame('ENTRYBG01', newPlayer('a', 'Atreides', 'atreides'), true);
|
||||
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
||||
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g.discoveryEnabled = true;
|
||||
g = initializeDiscoveryGameForAudit(g);
|
||||
for (let step = 0; step < 80 && g.status === 'setup'; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const v = viewGame(g, p.id);
|
||||
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(v)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next);
|
||||
g = next;
|
||||
}
|
||||
g = reveal(g);
|
||||
const bg = g.players.find((p) => p.id === 'b')!;
|
||||
bg.reserves--;
|
||||
bg.forces['cistern:0'] = 1;
|
||||
// Preserve one physical Karama so the real advisor response remains actionable.
|
||||
const card = g.deck.find((c) => c.effect === 'karama')!;
|
||||
g.deck = g.deck.filter((c) => c.id !== card.id);
|
||||
g.players[0].hand.push(card);
|
||||
return nextTurn(g);
|
||||
}
|
||||
void test('free entry persists through an owned Bene Gesserit intrusion and a canceled advisor flip before storm resumes', () => {
|
||||
let g = advisorFixture();
|
||||
g = applyAction(g, 'a', accept(g));
|
||||
assert.equal(g.discoveryEntry?.stage, 'arrival');
|
||||
assert.equal(g.decision?.kind, 'intrusion');
|
||||
const corrupt = reload(g);
|
||||
assert.equal(corrupt.decision?.kind, 'intrusion');
|
||||
if (corrupt.decision?.kind === 'intrusion')
|
||||
corrupt.decision.territory = 'arrakeen';
|
||||
assert.throws(() => viewGame(corrupt, 'b'));
|
||||
g = applyAction(reload(g), 'b', { type: 'decision', accept: true });
|
||||
assert.equal(g.response?.advisorResume, 'discoveryEntry');
|
||||
const orphan = reload(g);
|
||||
delete orphan.discoveryEntry;
|
||||
assert.throws(() => viewGame(orphan, 'a'));
|
||||
const card = g.players[0].hand.find((c) => c.effect === 'karama')!;
|
||||
g = applyAction(reload(g), 'a', {
|
||||
type: 'card',
|
||||
card: card.id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
assert.equal(g.discoveryEntry, undefined);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(
|
||||
isAdvisor(
|
||||
g.players.find((p) => p.id === 'b')!,
|
||||
'cistern',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(g.players[0].forces['cistern:0'], 2);
|
||||
custody(g);
|
||||
});
|
||||
void test('SQLite saved entry keeps seats and exactly one competing force transfer wins after restart', async () => {
|
||||
const store = unitStore();
|
||||
try {
|
||||
const made = await store.rooms.createRoom(
|
||||
'Entry SQL',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
),
|
||||
code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of ['guild', 'fremen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
const ids = auths.map((a) => a.playerId) as [string, string, string];
|
||||
const g = nextTurn(reveal(discoveryFixture(false, ids)));
|
||||
g.code = code;
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
const action = accept(g),
|
||||
clock = { now: () => 10000, sleep: async () => {} };
|
||||
const results = await Promise.allSettled(
|
||||
[0, 1].map(() =>
|
||||
store.restart().act(code, auths[0], g.version, action, clock),
|
||||
),
|
||||
);
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
const done = await store.restart().readRoom(code);
|
||||
assert.equal(done.players[0].forces['cistern:0'], 2);
|
||||
assert.equal(done.version, g.version + 1);
|
||||
for (const [i, token] of tokens.entries()) {
|
||||
const rooms = store.restart(),
|
||||
auth = await rooms.authenticate(code, token);
|
||||
assert.deepEqual(
|
||||
await rooms.readSeatView(code, auth),
|
||||
viewGame(done, ids[i]),
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
store.restart().act(code, auths[0], done.version, action, clock),
|
||||
);
|
||||
custody(done);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
advanceDiscoveryEntry,
|
||||
beginDiscoveryEntryArrival,
|
||||
createDiscoveryEntryRound,
|
||||
discoveryEntryArrivalSignature,
|
||||
discoveryEntrySignature,
|
||||
finishDiscoveryEntryArrival,
|
||||
quoteDiscoveryEntry,
|
||||
validateDiscoveryEntryArrivalChild,
|
||||
validateDiscoveryEntryRound,
|
||||
type DiscoveryEntryContext,
|
||||
} from '../game/discovery-entry';
|
||||
import {
|
||||
createDiscoveryState,
|
||||
type DiscoveryLocationId,
|
||||
type DiscoveryOpaqueTokenId,
|
||||
} from '../game/discoveries';
|
||||
|
||||
function context(
|
||||
locations: readonly {
|
||||
face: DiscoveryLocationId;
|
||||
territory: string;
|
||||
sector: number;
|
||||
}[] = [
|
||||
{ face: 'jacurutu-sietch', territory: 'meridian', sector: 1 },
|
||||
],
|
||||
): DiscoveryEntryContext {
|
||||
const discoveries = createDiscoveryState(() => 0);
|
||||
const ids: DiscoveryOpaqueTokenId[] = [];
|
||||
for (const location of locations) {
|
||||
const token = discoveries.tokens.find(
|
||||
(candidate) => candidate.face === location.face,
|
||||
)!;
|
||||
Object.assign(token, {
|
||||
status: 'placed',
|
||||
territory: location.territory,
|
||||
sector: location.sector,
|
||||
revealedTurn: 2,
|
||||
known: [],
|
||||
owner: null,
|
||||
acquiredTurn: null,
|
||||
});
|
||||
ids.push(token.id);
|
||||
}
|
||||
discoveries.newlyRevealed = ids;
|
||||
return {
|
||||
turn: 3,
|
||||
storm: 1,
|
||||
order: ['a', 'g', 'f'],
|
||||
discoveries,
|
||||
players: [
|
||||
{
|
||||
id: 'a',
|
||||
faction: 'atreides',
|
||||
ally: null,
|
||||
forces: { 'meridian:1': 2, 'meridian:2': 3 },
|
||||
elites: { forces: { 'meridian:2': 1 } },
|
||||
},
|
||||
{
|
||||
id: 'g',
|
||||
faction: 'guild',
|
||||
ally: null,
|
||||
forces: {},
|
||||
},
|
||||
{
|
||||
id: 'f',
|
||||
faction: 'fremen',
|
||||
ally: null,
|
||||
forces: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
void test('a round freezes last-turn revealed locations in token order and players in storm order', () => {
|
||||
const g = context([
|
||||
{ face: 'jacurutu-sietch', territory: 'meridian', sector: 1 },
|
||||
{ face: 'cistern', territory: 'gara_kulon', sector: 8 },
|
||||
]);
|
||||
const before = structuredClone(g);
|
||||
const frame = createDiscoveryEntryRound(g, 'entry-round');
|
||||
assert.deepEqual(frame.tokens, g.discoveries.newlyRevealed);
|
||||
assert.deepEqual(frame.order, g.order);
|
||||
assert.equal(frame.cursor, 0);
|
||||
assert.equal(frame.stage, 'choose');
|
||||
assert.equal(frame.signature, discoveryEntrySignature(frame));
|
||||
assert.deepEqual(g, before);
|
||||
|
||||
const none = context();
|
||||
none.discoveries.newlyRevealed = [];
|
||||
const complete = createDiscoveryEntryRound(none, 'no-entry');
|
||||
assert.equal(complete.stage, 'complete');
|
||||
assert.equal(complete.arrival, null);
|
||||
});
|
||||
|
||||
void test('a quote offers typed non-advisor source groups outside the storm and accepts any positive subset', () => {
|
||||
const g = context();
|
||||
const frame = createDiscoveryEntryRound(g, 'typed-entry');
|
||||
const before = structuredClone(g);
|
||||
const offer = quoteDiscoveryEntry(g, frame, 'a');
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.deepEqual(offer.sources, [
|
||||
{ source: 'meridian:2', normal: 2, elite: 1 },
|
||||
]);
|
||||
|
||||
const chosen = quoteDiscoveryEntry(g, frame, 'a', {
|
||||
groups: [{ source: 'meridian:2', normal: 1, elite: 1 }],
|
||||
});
|
||||
assert.deepEqual(chosen.arrival?.groups, [
|
||||
{ source: 'meridian:2', normal: 1, elite: 1 },
|
||||
]);
|
||||
assert.equal(chosen.arrival?.amount, 2);
|
||||
assert.equal(chosen.arrival?.elite, 1);
|
||||
assert.deepEqual(g, before);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteDiscoveryEntry(g, frame, 'a', {
|
||||
groups: [{ source: 'meridian:1', normal: 1, elite: 0 }],
|
||||
}),
|
||||
/typed source custody/i,
|
||||
);
|
||||
|
||||
const advisor = context();
|
||||
Object.assign(advisor.players[0], {
|
||||
faction: 'beneGesserit',
|
||||
advisors: { meridian: {} },
|
||||
});
|
||||
const advisorOffer = quoteDiscoveryEntry(
|
||||
advisor,
|
||||
createDiscoveryEntryRound(advisor, 'advisor-entry'),
|
||||
'a',
|
||||
);
|
||||
assert.match(advisorOffer.blocked ?? '', /non-advisor forces/i);
|
||||
assert.deepEqual(advisorOffer.sources, []);
|
||||
});
|
||||
|
||||
void test('full occupancy is a skippable offer and declining walks the token-player cursor to completion', () => {
|
||||
const g = context();
|
||||
g.players[1].forces = { 'jacurutu-sietch:0': 1 };
|
||||
g.players[2].forces = { 'jacurutu-sietch:0': 1 };
|
||||
let frame = createDiscoveryEntryRound(g, 'occupied-entry');
|
||||
const offer = quoteDiscoveryEntry(g, frame, 'a');
|
||||
assert.match(offer.blocked ?? '', /three occupying factions/i);
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteDiscoveryEntry(g, frame, 'a', {
|
||||
groups: [{ source: 'meridian:2', normal: 1, elite: 0 }],
|
||||
}),
|
||||
/three occupying factions/i,
|
||||
);
|
||||
|
||||
const originalFrame = frame;
|
||||
const original = structuredClone(frame);
|
||||
frame = advanceDiscoveryEntry(g, frame, 'a');
|
||||
assert.deepEqual(originalFrame, original);
|
||||
assert.equal(frame.cursor, 1);
|
||||
frame = advanceDiscoveryEntry(g, frame, 'g');
|
||||
assert.equal(frame.cursor, 2);
|
||||
frame = advanceDiscoveryEntry(g, frame, 'f');
|
||||
assert.equal(frame.stage, 'complete');
|
||||
assert.equal(frame.cursor, 3);
|
||||
assert.equal(frame.arrival, null);
|
||||
assert.throws(() => advanceDiscoveryEntry(g, frame, 'a'), /current player/i);
|
||||
});
|
||||
|
||||
void test('an accepted entry remains valid after transfer and binds its exact arrival child', () => {
|
||||
const g = context();
|
||||
const choose = createDiscoveryEntryRound(g, 'signed-entry');
|
||||
const before = structuredClone(choose);
|
||||
const arrival = beginDiscoveryEntryArrival(g, choose, 'a', {
|
||||
groups: [{ source: 'meridian:2', normal: 1, elite: 1 }],
|
||||
});
|
||||
assert.deepEqual(choose, before);
|
||||
assert.equal(arrival.stage, 'arrival');
|
||||
assert.equal(arrival.arrival?.destination, 'jacurutu-sietch');
|
||||
|
||||
const moved = structuredClone(g);
|
||||
moved.players[0].forces['meridian:2'] = 1;
|
||||
moved.players[0].elites!.forces['meridian:2'] = 0;
|
||||
moved.players[0].forces['jacurutu-sietch:0'] = 2;
|
||||
moved.players[0].elites!.forces['jacurutu-sietch:0'] = 1;
|
||||
assert.doesNotThrow(() => validateDiscoveryEntryRound(moved, arrival));
|
||||
|
||||
const child = {
|
||||
owner: 'a',
|
||||
territory: 'jacurutu-sietch',
|
||||
sector: 0,
|
||||
amount: 2,
|
||||
elite: 1,
|
||||
discoveryEntry: arrival.arrival!.signature,
|
||||
};
|
||||
assert.doesNotThrow(() =>
|
||||
validateDiscoveryEntryArrivalChild(arrival, child),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateDiscoveryEntryArrivalChild(arrival, {
|
||||
...child,
|
||||
territory: 'cistern',
|
||||
}),
|
||||
/signed arrival/i,
|
||||
);
|
||||
|
||||
const finished = finishDiscoveryEntryArrival(moved, arrival);
|
||||
assert.equal(finished.cursor, 1);
|
||||
assert.equal(finished.stage, 'choose');
|
||||
assert.equal(finished.arrival, null);
|
||||
});
|
||||
|
||||
void test('resigned turn, token, owner, and arrival mutations are rejected', () => {
|
||||
const g = context();
|
||||
const frame = createDiscoveryEntryRound(g, 'restore-entry');
|
||||
const stale = structuredClone(frame);
|
||||
stale.turn = 2;
|
||||
stale.signature = discoveryEntrySignature(stale);
|
||||
assert.throws(
|
||||
() => validateDiscoveryEntryRound(g, stale),
|
||||
/original token and player order/i,
|
||||
);
|
||||
|
||||
const missing = structuredClone(frame);
|
||||
missing.tokens = [];
|
||||
missing.cursor = 0;
|
||||
missing.stage = 'complete';
|
||||
missing.signature = discoveryEntrySignature(missing);
|
||||
assert.throws(
|
||||
() => validateDiscoveryEntryRound(g, missing),
|
||||
/original token and player order/i,
|
||||
);
|
||||
|
||||
const arrival = beginDiscoveryEntryArrival(g, frame, 'a', {
|
||||
groups: [{ source: 'meridian:2', normal: 1, elite: 0 }],
|
||||
});
|
||||
const wrongOwner = structuredClone(arrival);
|
||||
wrongOwner.arrival!.owner = 'g';
|
||||
wrongOwner.arrival!.signature = JSON.stringify('forged');
|
||||
wrongOwner.signature = discoveryEntrySignature(wrongOwner);
|
||||
assert.throws(
|
||||
() => validateDiscoveryEntryRound(g, wrongOwner),
|
||||
/exact signed arrival/i,
|
||||
);
|
||||
|
||||
const wrongSource = structuredClone(arrival);
|
||||
wrongSource.arrival!.groups[0].source = 'arrakeen:9';
|
||||
wrongSource.arrival!.signature = discoveryEntryArrivalSignature(
|
||||
wrongSource.arrival!,
|
||||
);
|
||||
wrongSource.signature = discoveryEntrySignature(wrongSource);
|
||||
assert.throws(
|
||||
() => validateDiscoveryEntryRound(g, wrongSource),
|
||||
/revealed destination/i,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { gameDistance, gameTerritories, splitLocation } from '../game/board';
|
||||
import { discoveryFlightMove, discoveryFlightBotActions } from '../game/discovery-flight-options';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { createRicheseNoField, deployRicheseNoField } from '../game/richese-no-field';
|
||||
import { discoveryFlightFixture, flightDestination, flightOrigin } from './fixture-discovery-flight';
|
||||
|
||||
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const held = (game: Game) => game.discoveries!.tokens.find(token => token.face === 'ornithopter')!;
|
||||
const declaration = (game: Game, extra: Partial<Action> = {}): Action => ({
|
||||
type: 'move', from: flightOrigin, amount: 2, ...splitLocation(flightDestination(game)),
|
||||
discoveryOrnithopter: held(game).id, ...extra,
|
||||
});
|
||||
function rejectsUnchanged(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function choamFixture() {
|
||||
const fixture = discoveryFlightFixture(true, 'f');
|
||||
const choam = newPlayer('c', 'CHOAM', 'choam');
|
||||
fixture.game.players.push(choam);
|
||||
fixture.game.order.push(choam.id);
|
||||
const destination = flightDestination(fixture.game);
|
||||
choam.forces = { [destination]: 1 };
|
||||
choam.reserves--;
|
||||
const baliset = fixture.game.deck.findIndex(card => card.name === 'Baliset');
|
||||
assert.ok(baliset >= 0);
|
||||
choam.hand.push(fixture.game.deck.splice(baliset, 1)[0]);
|
||||
const pilot = fixture.game.players.find(player => player.id === 'f')!;
|
||||
pilot.elites!.reserves -= 2;
|
||||
pilot.elites!.forces[flightOrigin] = 2;
|
||||
return { ...fixture, destination };
|
||||
}
|
||||
|
||||
void test('real Collection acquisition grants one later-turn three-territory movement in Basic and Advanced', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const { game, owner, acquiredTurn } = discoveryFlightFixture(advanced);
|
||||
const action = declaration(game), destination = `${String(action.territory)}:${Number(action.sector)}`;
|
||||
const before = structuredClone(game), ordinary = { ...action };
|
||||
delete ordinary.discoveryOrnithopter;
|
||||
rejectsUnchanged(game, owner, ordinary);
|
||||
const view = viewGame(game, owner);
|
||||
assert.equal(view.discoveryOrnithopter!.blocked, null);
|
||||
assert.equal(viewGame(game, 'g').discoveryOrnithopter, null);
|
||||
assert.ok(discoveryFlightMove(view, ordinary).action);
|
||||
const done = applyAction(game, owner, action);
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(done.players[0].forces[destination], 2);
|
||||
assert.equal(done.players[0].moved, 1);
|
||||
assert.equal(done.players[0].reserves, game.players[0].reserves);
|
||||
assert.equal(done.players[0].spice, game.players[0].spice);
|
||||
assert.equal(held(done).status, 'removed');
|
||||
assert.equal(held(done).owner, null);
|
||||
assert.equal(held(done).acquiredTurn, acquiredTurn);
|
||||
assert.equal(viewGame(done, owner).discoveryOrnithopter, null);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(done)), reload(done));
|
||||
}
|
||||
});
|
||||
|
||||
void test('same-turn use, nonowner, false token, invalid group, storm, overrange and receipt injection reject immutably', () => {
|
||||
const { game, owner } = discoveryFlightFixture();
|
||||
const sameTurn = reload(game); sameTurn.turn = held(game).acquiredTurn!;
|
||||
assert.match(viewGame(sameTurn, owner).discoveryOrnithopter!.blocked!, /after/);
|
||||
rejectsUnchanged(sameTurn, owner, declaration(sameTurn));
|
||||
for (const extra of [
|
||||
{ discoveryOrnithopter: 'discovery-token-99' },
|
||||
{ discoveryOrnithopter: game.discoveries!.tokens.find(token => token.face === 'cistern')!.id },
|
||||
{ amount: 6 }, { amount: 0 }, { elite: 1 },
|
||||
{ ...splitLocation(flightDestination(game, 4)) },
|
||||
{ discoveryFlight: { token: held(game).id } },
|
||||
]) rejectsUnchanged(game, owner, declaration(game, extra));
|
||||
const storm = reload(game), action = declaration(storm);
|
||||
storm.storm = Number(action.sector);
|
||||
rejectsUnchanged(storm, owner, action);
|
||||
const foreign = reload(game); foreign.active = 'g';
|
||||
foreign.players[1].forces[flightOrigin] = 2; foreign.players[1].reserves -= 2;
|
||||
rejectsUnchanged(foreign, 'g', declaration(foreign));
|
||||
const used = applyAction(game, owner, declaration(game));
|
||||
used.hajr = [owner];
|
||||
rejectsUnchanged(used, owner, declaration(used));
|
||||
});
|
||||
|
||||
void test('one selected multi-sector typed group moves at fixed range without a native Fremen speed response', () => {
|
||||
const { game, owner } = discoveryFlightFixture(true, 'f');
|
||||
const pilot = game.players.find(player => player.id === owner)!;
|
||||
const source = gameTerritories(game).find(t => t.sectors.length >= 2 && !t.sectors.includes(game.storm))!;
|
||||
const [first, second] = source.sectors.map(sector => `${source.id}:${sector}`);
|
||||
pilot.forces = { [first]: 3, [second]: 2 };
|
||||
pilot.elites!.reserves -= 2; pilot.elites!.forces = { [first]: 1, [second]: 1 };
|
||||
const to = gameTerritories(game).flatMap(t => t.sectors.map(sector => `${t.id}:${sector}`))
|
||||
.find(key => [first, second].every(from => gameDistance(game, from, key,
|
||||
key => splitLocation(key).sector === game.storm) === 3))!;
|
||||
assert.ok(to);
|
||||
const move: Action = { type: 'move', forces: { [first]: 2, [second]: 1 },
|
||||
eliteForces: { [first]: 1, [second]: 1 }, ...splitLocation(to) };
|
||||
const quote = discoveryFlightMove(viewGame(game, owner), move);
|
||||
assert.equal(quote.blocked, null);
|
||||
const done = applyAction(game, owner, quote.action!);
|
||||
assert.equal(done.response, null);
|
||||
const next = done.players.find(player => player.id === owner)!;
|
||||
assert.equal(next.forces[to], 3); assert.equal(next.elites!.forces[to], 2);
|
||||
assert.equal(next.moved, 1); assert.equal(held(done).status, 'removed');
|
||||
});
|
||||
|
||||
void test('pending CHOAM decision preserves the exact owned token and typed group across JSON; allowing resumes once', () => {
|
||||
const { game, owner, destination } = choamFixture();
|
||||
const pending = applyAction(game, owner, declaration(game, { elite: 1 }));
|
||||
assert.equal(pending.decision?.kind, 'choamMovement');
|
||||
assert.equal(held(pending).status, 'carried');
|
||||
assert.equal(pending.players.find(p => p.id === owner)!.moved, 0);
|
||||
assert.ok(pending.pendingChoamMove!.discoveryFlight);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
|
||||
assert.match(viewGame(pending, owner).discoveryOrnithopter!.blocked!, /response or decision/);
|
||||
assert.equal(JSON.stringify(viewGame(pending, 'c')).includes('discoveryFlight'), false);
|
||||
const done = applyAction(reload(pending), 'c', { type: 'decision', decline: true });
|
||||
const pilot = done.players.find(p => p.id === owner)!;
|
||||
assert.equal(pilot.forces[destination], 2); assert.equal(pilot.elites!.forces[destination], 1);
|
||||
assert.equal(pilot.moved, 1); assert.equal(held(done).status, 'removed');
|
||||
assert.equal(done.pendingChoamMove, null);
|
||||
rejectsUnchanged(done, 'c', { type: 'decision', decline: true });
|
||||
});
|
||||
|
||||
void test('altered pending movement receipt fails closed on normalize, view and answer', () => {
|
||||
const { game, owner } = choamFixture();
|
||||
const pending = applyAction(game, owner, declaration(game, { elite: 1 }));
|
||||
for (const alter of [
|
||||
(g: Game) => { g.pendingChoamMove!.to = 'hagga_basin'; },
|
||||
(g: Game) => { g.pendingChoamMove!.eliteGroup[flightOrigin] = 0; },
|
||||
(g: Game) => { g.pendingChoamMove!.discoveryFlight!.move++; },
|
||||
(g: Game) => { g.turn++; },
|
||||
(g: Game) => { held(g).owner = 'g'; },
|
||||
(g: Game) => { g.pendingIxMove = reload(g.pendingChoamMove); },
|
||||
]) {
|
||||
const changed = reload(pending); alter(changed);
|
||||
const before = structuredClone(changed);
|
||||
assert.throws(() => normalizeAutomaticGame(changed));
|
||||
assert.throws(() => viewGame(changed, owner));
|
||||
rejectsUnchanged(changed, 'c', { type: 'decision', decline: true });
|
||||
assert.deepEqual(changed, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Baliset prevents the pending move without spending its token; a different route can still use it', () => {
|
||||
const { game, owner, destination } = choamFixture();
|
||||
let pending = applyAction(game, owner, declaration(game, { elite: 1 }));
|
||||
const choam = pending.players.find(player => player.id === 'c')!;
|
||||
pending = applyAction(reload(pending), 'c', { type: 'card', mode: 'choam',
|
||||
card: choam.hand.find(card => card.name === 'Baliset')!.id, target: owner,
|
||||
territory: splitLocation(destination).territory });
|
||||
assert.equal(pending.pendingChoamMove, null);
|
||||
assert.equal(held(pending).status, 'carried');
|
||||
assert.equal(pending.players.find(player => player.id === owner)!.moved, 0);
|
||||
const alternate = discoveryFlightBotActions(viewGame(pending, owner))[0];
|
||||
assert.ok(alternate);
|
||||
assert.equal(held(applyAction(pending, owner, alternate)).status, 'removed');
|
||||
});
|
||||
|
||||
void test('actual Baliset cancellation response restores the same flight and either allows or abandons its one movement', () => {
|
||||
for (const cancel of [false, true]) {
|
||||
const { game, owner, destination } = choamFixture();
|
||||
const cardIndex = game.deck.findIndex(card => card.effect === 'karama');
|
||||
const karama = game.deck.splice(cardIndex, 1)[0];
|
||||
game.players.find(player => player.id === owner)!.hand.push(karama);
|
||||
let pending = applyAction(game, owner, declaration(game, { elite: 1 }));
|
||||
const choam = pending.players.find(player => player.id === 'c')!;
|
||||
pending = applyAction(pending, 'c', { type: 'card', mode: 'choam',
|
||||
card: choam.hand.find(card => card.name === 'Baliset')!.id, target: owner,
|
||||
territory: splitLocation(destination).territory });
|
||||
assert.equal(pending.response?.kind, 'choamWorthless');
|
||||
assert.equal(held(pending).status, 'carried');
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
|
||||
const done = applyAction(reload(pending), owner, cancel
|
||||
? { type: 'card', card: karama.id, mode: 'cancel' } : { type: 'passResponse' });
|
||||
assert.equal(done.pendingChoamMove, null);
|
||||
assert.equal(done.players.find(player => player.id === owner)!.moved, Number(cancel));
|
||||
assert.equal(held(done).status, cancel ? 'removed' : 'carried');
|
||||
}
|
||||
});
|
||||
|
||||
void test('Discovery flight replaces a Kulon-enhanced normal range with exactly three', () => {
|
||||
const { game, owner } = discoveryFlightFixture();
|
||||
game.players[0].faction = 'choam';
|
||||
game.players[0].forces['arrakeen:10'] = 1; game.players[0].reserves--;
|
||||
game.choamMovement = { turn: game.turn, bonus: 1 };
|
||||
const four = declaration(game, splitLocation(flightDestination(game, 4)));
|
||||
rejectsUnchanged(game, owner, four);
|
||||
assert.equal(discoveryFlightMove(viewGame(game, owner), four).action, null);
|
||||
const ordinary = { ...four }; delete ordinary.discoveryOrnithopter;
|
||||
assert.equal(held(applyAction(game, owner, ordinary)).status, 'carried');
|
||||
assert.equal(held(applyAction(game, owner, declaration(game))).status, 'removed');
|
||||
});
|
||||
|
||||
void test('a selected concealed marker flies with physical companions and retains its private denomination', () => {
|
||||
const { game, owner } = discoveryFlightFixture();
|
||||
const pilot = game.players[0]; pilot.faction = 'richese';
|
||||
const ids = ['flight-zero', 'flight-three', 'flight-five'];
|
||||
pilot.noField = deployRicheseNoField(createRicheseNoField(ids),
|
||||
{ tokenId: ids[1], controller: owner, location: splitLocation(flightOrigin) });
|
||||
pilot.noFieldEvent = 'flight-marker';
|
||||
const action: Action = { type: 'move', forces: { [flightOrigin]: 2 }, noField: ids[1],
|
||||
event: pilot.noFieldEvent, ...splitLocation(flightDestination(game)) };
|
||||
const quote = discoveryFlightMove(viewGame(game, owner), action);
|
||||
assert.equal(quote.blocked, null);
|
||||
const done = applyAction(game, owner, quote.action!);
|
||||
assert.deepEqual(done.players[0].noField!.deployed!.location, splitLocation(flightDestination(game)));
|
||||
assert.equal(done.players[0].noField!.deployed!.tokenId, ids[1]);
|
||||
assert.equal(done.players[0].reserves, pilot.reserves);
|
||||
assert.equal(held(done).status, 'removed');
|
||||
assert.equal(JSON.stringify(viewGame(done, 'g')).includes(ids[1]), false);
|
||||
});
|
||||
|
||||
void test('Discovery flight can serve one group of an active Treachery card without spending its second original group', () => {
|
||||
const { game, owner } = discoveryFlightFixture();
|
||||
game.richeseCache = richeseCards();
|
||||
const index = game.richeseCache.findIndex(card => card.id === 'richese-ornithopter');
|
||||
const card = game.richeseCache.splice(index, 1)[0]; game.players[0].hand.push(card);
|
||||
const first = applyAction(game, owner, declaration(game,
|
||||
{ movementCard: card.id, ornithopter: 'twoGroups' }));
|
||||
assert.equal(held(first).status, 'removed');
|
||||
assert.equal(first.ornithopter!.completed, 1);
|
||||
assert.equal(first.ornithopter!.cohort!.forces[flightOrigin], 3);
|
||||
assert.equal(first.discard.some(candidate => candidate.id === card.id), false);
|
||||
const done = applyAction(reload(first), owner, { type: 'move', from: flightOrigin, amount: 2,
|
||||
...splitLocation(flightDestination(first, 1)), ornithopterEvent: first.ornithopter!.event });
|
||||
assert.equal(done.players[0].moved, 2);
|
||||
assert.equal(done.ornithopter, null);
|
||||
assert.equal(done.discard.filter(candidate => candidate.id === card.id).length, 1);
|
||||
});
|
||||
|
||||
void test('all AI profiles quote usable owned flight routes and conserve tokens when normal range already suffices', () => {
|
||||
const { game, owner } = discoveryFlightFixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const g = reload(game), pilot = g.players.find(player => player.id === owner)!;
|
||||
pilot.bot = difficulty;
|
||||
const view = viewGame(g, owner), actions = botActions(view);
|
||||
assert.ok(actions.length);
|
||||
assert.equal(actions[0].discoveryOrnithopter, held(g).id);
|
||||
assert.equal(held(applyAction(g, owner, actions[0])).status, 'removed');
|
||||
pilot.forces['arrakeen:10'] = 1; pilot.reserves--;
|
||||
assert.deepEqual(discoveryFlightBotActions(viewGame(g, owner)), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a physical Treachery Ornithopter remains independently owned after spending the Discovery token', () => {
|
||||
const { game, owner } = discoveryFlightFixture();
|
||||
game.richeseCache = richeseCards();
|
||||
const index = game.richeseCache.findIndex(card => card.id === 'richese-ornithopter');
|
||||
const card = game.richeseCache.splice(index, 1)[0];
|
||||
game.players[0].hand.push(card);
|
||||
const done = applyAction(game, owner, declaration(game));
|
||||
assert.deepEqual(done.players[0].hand, [card]);
|
||||
assert.equal(done.ornithopter, game.ornithopter);
|
||||
assert.equal(done.discard.some(candidate => candidate.id === card.id), false);
|
||||
assert.equal(held(done).status, 'removed');
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { discoveryFlightFixture, flightDestination, flightOrigin } from './fixture-discovery-flight';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { newPlayer, viewGame } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
void test('SQLite restored Discovery flight preserves its owned declaration and competing CHOAM answers spend it once', async () => {
|
||||
const store = unitStore();
|
||||
try {
|
||||
const created = await store.rooms.createRoom('Discovery flight SQL', 'atreides', true, ['choam']);
|
||||
const code = created.view.code, sessions = [created.token];
|
||||
for (const faction of ['guild', 'fremen', 'choam'] as const)
|
||||
sessions.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(sessions.map(token => store.rooms.authenticate(code, token)));
|
||||
const ids = auths.slice(0, 3).map(auth => auth.playerId) as [string, string, string];
|
||||
const { game, token, acquiredTurn } = discoveryFlightFixture(true, ids[2], ids);
|
||||
game.code = code;
|
||||
const destination = flightDestination(game), choam = newPlayer(auths[3].playerId, 'CHOAM', 'choam');
|
||||
game.players.push(choam); game.order.push(choam.id);
|
||||
choam.forces = { [destination]: 1 }; choam.reserves--;
|
||||
const pilot = game.players[2];
|
||||
pilot.elites!.forces[flightOrigin] = 1; pilot.elites!.reserves--;
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), game.version, code);
|
||||
const seats = store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all();
|
||||
await store.restart().act(code, auths[2], game.version, { type: 'move',
|
||||
from: flightOrigin, amount: 2, elite: 1, ...splitLocation(destination),
|
||||
discoveryOrnithopter: token }, clock);
|
||||
const pending = await store.restart().readRoom(code);
|
||||
assert.equal(pending.decision?.kind, 'choamMovement');
|
||||
assert.equal(pending.pendingChoamMove!.discoveryFlight!.token, token);
|
||||
assert.equal(pending.discoveries!.tokens.find(t => t.id === token)!.status, 'carried');
|
||||
assert.equal(pending.players[2].moved, 0);
|
||||
for (const [index, auth] of auths.entries()) {
|
||||
const view = await store.restart().readSeatView(code, auth);
|
||||
assert.deepEqual(view, viewGame(pending, auth.playerId));
|
||||
assert.equal(view.discoveryOrnithopter !== null, index === 2);
|
||||
assert.equal(JSON.stringify(view).includes('discoveryFlight'), false);
|
||||
}
|
||||
let arrivals = 0, release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
store.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
|
||||
const timer = setTimeout(release, 2000);
|
||||
const answer = { type: 'decision', decline: true };
|
||||
try {
|
||||
const results = await Promise.allSettled([0, 1].map(() =>
|
||||
store.restart().act(code, auths[3], pending.version, answer, clock)));
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
} finally { clearTimeout(timer); delete store.hooks.beforeWrite; }
|
||||
const done = await store.restart().readRoom(code), spent = done.discoveries!.tokens.find(t => t.id === token)!;
|
||||
assert.equal(done.version, pending.version + 1);
|
||||
assert.equal(done.players[2].forces[destination], 2);
|
||||
assert.equal(done.players[2].elites!.forces[destination], 1);
|
||||
assert.equal(done.players[2].moved, 1);
|
||||
assert.equal(done.pendingChoamMove, null);
|
||||
assert.equal(spent.status, 'removed'); assert.equal(spent.owner, null);
|
||||
assert.equal(spent.acquiredTurn, acquiredTurn);
|
||||
await assert.rejects(store.restart().act(code, auths[3], done.version, answer, clock));
|
||||
assert.deepEqual(await store.restart().readRoom(code), done);
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), seats);
|
||||
} finally { store.sqlite.close(); }
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createDiscoveryState, placeDiscovery, projectDiscoveryState, revealDiscoveryToken,
|
||||
type DiscoveryOpaqueTokenId,
|
||||
} from '../game/discoveries';
|
||||
import {
|
||||
completeDiscoveryFlight, discoveryFlightOffer, DiscoveryFlightError, quoteDiscoveryFlight,
|
||||
validateDiscoveryFlight,
|
||||
type DiscoveryFlightContext, type DiscoveryFlightMovement, type DiscoveryFlightReceipt,
|
||||
} from '../game/discovery-flight';
|
||||
|
||||
function fixture() {
|
||||
const supplied = createDiscoveryState(() => 0.999);
|
||||
const token = supplied.tokens.find(token => token.face === 'ornithopter')!.id;
|
||||
const placed = placeDiscovery(supplied, 'discovery-funeral-plain', () => 0.999);
|
||||
const discoveries = revealDiscoveryToken(placed, token, 2, 'p').state;
|
||||
const context: DiscoveryFlightContext = {
|
||||
status: 'playing', phase: 5, active: 'p', turn: 3, discoveries,
|
||||
players: [{ id: 'p', moved: 0 }, { id: 'q', moved: 0 }],
|
||||
};
|
||||
const move: DiscoveryFlightMovement = {
|
||||
player: 'p', origin: 'false_wall_west',
|
||||
group: [['false_wall_west:16', 2], ['false_wall_west:17', 1]],
|
||||
eliteGroup: { 'false_wall_west:16': 1, 'false_wall_west:17': 0 },
|
||||
elite: 1, total: 3, to: 'the_great_flat', sector: 15,
|
||||
advisors: false, wantsFighters: false,
|
||||
};
|
||||
return { context, token, move };
|
||||
}
|
||||
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
function rejectedUnchanged<T>(input: T, work: () => unknown) {
|
||||
const before = structuredClone(input);
|
||||
assert.throws(work, DiscoveryFlightError);
|
||||
assert.deepEqual(input, before);
|
||||
}
|
||||
|
||||
void test('Discovery flight quote binds one typed group without spending custody or changing the source state', () => {
|
||||
const { context, token, move } = fixture(), before = structuredClone({ context, move });
|
||||
const receipt = quoteDiscoveryFlight(context, token, move);
|
||||
assert.equal(receipt.token, token);
|
||||
assert.equal(receipt.player, 'p');
|
||||
assert.equal(receipt.turn, 3);
|
||||
assert.equal(receipt.acquiredTurn, 2);
|
||||
assert.equal(receipt.move, 0);
|
||||
assert.doesNotThrow(() => validateDiscoveryFlight(context, receipt, move));
|
||||
assert.equal(context.discoveries!.tokens.find(candidate => candidate.id === token)!.status, 'carried');
|
||||
assert.deepEqual({ context, move }, before);
|
||||
rejectedUnchanged(context, () => completeDiscoveryFlight(context, receipt, move));
|
||||
});
|
||||
|
||||
void test('turn of acquisition, earlier turn, foreign owner and unavailable physical tokens cannot create flight receipts', () => {
|
||||
const { context, token, move } = fixture();
|
||||
for (const turn of [1, 2]) {
|
||||
const sameTurn = { ...context, turn };
|
||||
rejectedUnchanged(sameTurn, () => quoteDiscoveryFlight(sameTurn, token, move));
|
||||
}
|
||||
for (const wrong of [undefined, '', 'discovery-token-99',
|
||||
context.discoveries!.tokens.find(candidate => candidate.face === 'cistern')!.id])
|
||||
rejectedUnchanged(context, () => quoteDiscoveryFlight(context, wrong, move));
|
||||
const foreign = { ...context, active: 'q' };
|
||||
rejectedUnchanged(foreign, () => quoteDiscoveryFlight(foreign, token, { ...move, player: 'q' }));
|
||||
for (const changes of [{ status: 'finished' }, { phase: 7 }, { active: 'q' }, { turn: 0 }]) {
|
||||
const unavailable = { ...context, ...changes };
|
||||
rejectedUnchanged(unavailable, () => quoteDiscoveryFlight(unavailable, token, move));
|
||||
}
|
||||
});
|
||||
|
||||
void test('pending owned movement responses and JSON restore retain the exact group without consuming the token', () => {
|
||||
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
|
||||
const order = { ...move, discoveryFlight: receipt };
|
||||
const pending = { ...context, response: { kind: 'choamPower', owner: 'q' }, pendingChoamMove: order };
|
||||
const saved = reload(pending), before = structuredClone(saved);
|
||||
assert.doesNotThrow(() => validateDiscoveryFlight(saved, saved.pendingChoamMove.discoveryFlight, saved.pendingChoamMove));
|
||||
assert.deepEqual(quoteDiscoveryFlight(saved, token, saved.pendingChoamMove), receipt);
|
||||
assert.deepEqual(saved, before);
|
||||
// Abandoning an invalidated route drops the order, not its unspent token.
|
||||
const abandoned = { ...saved, response: null, pendingChoamMove: null };
|
||||
assert.equal(discoveryFlightOffer(abandoned, 'p', { movesAllowed: 1 })!.blocked, null);
|
||||
assert.deepEqual(abandoned.discoveries, context.discoveries);
|
||||
});
|
||||
|
||||
void test('completed flight removes only the carried token after one committed movement and preserves its acquisition history', () => {
|
||||
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
|
||||
const committed = reload(context);
|
||||
committed.players[0].moved++;
|
||||
const before = structuredClone(committed), next = completeDiscoveryFlight(committed, reload(receipt), reload(move));
|
||||
const originalToken = context.discoveries!.tokens.find(candidate => candidate.id === token)!;
|
||||
assert.deepEqual(next.tokens.find(candidate => candidate.id === token), {
|
||||
...originalToken, status: 'removed', owner: null,
|
||||
});
|
||||
assert.deepEqual(next.tokens.filter(candidate => candidate.id !== token),
|
||||
context.discoveries!.tokens.filter(candidate => candidate.id !== token));
|
||||
assert.deepEqual(next.newlyRevealed, context.discoveries!.newlyRevealed);
|
||||
assert.deepEqual(committed, before);
|
||||
assert.notEqual(next, committed.discoveries);
|
||||
const spent = { ...committed, discoveries: next };
|
||||
rejectedUnchanged(spent, () => completeDiscoveryFlight(spent, receipt, move));
|
||||
rejectedUnchanged(spent, () => quoteDiscoveryFlight(spent, token, move));
|
||||
assert.equal(discoveryFlightOffer(spent, 'p'), null);
|
||||
});
|
||||
|
||||
void test('saved receipts reject replay onto another move, changed groups or destinations, and altered token acquisition', () => {
|
||||
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
|
||||
for (const alter of [
|
||||
(order: DiscoveryFlightMovement) => { order.to = 'hagga_basin'; },
|
||||
(order: DiscoveryFlightMovement) => { order.sector = 14; },
|
||||
(order: DiscoveryFlightMovement) => { order.group = [['false_wall_west:16', 1], ['false_wall_west:17', 2]]; },
|
||||
(order: DiscoveryFlightMovement) => { order.eliteGroup = { 'false_wall_west:16': 0, 'false_wall_west:17': 1 }; },
|
||||
(order: DiscoveryFlightMovement) => { order.advisors = true; },
|
||||
(order: DiscoveryFlightMovement) => { order.lockedTurn = 3; },
|
||||
]) {
|
||||
const changed = reload(move); alter(changed);
|
||||
rejectedUnchanged(context, () => validateDiscoveryFlight(context, receipt, changed));
|
||||
}
|
||||
for (const changes of [{ turn: 4 }, { players: [{ id: 'p', moved: 1 }, { id: 'q', moved: 0 }] }]) {
|
||||
const changed = { ...context, ...changes };
|
||||
rejectedUnchanged(changed, () => validateDiscoveryFlight(changed, receipt, move));
|
||||
}
|
||||
const laterMove = reload(context); laterMove.players[0].moved = 2;
|
||||
rejectedUnchanged(laterMove, () => completeDiscoveryFlight(laterMove, receipt, move));
|
||||
const reacquired = reload(context), held = reacquired.discoveries!.tokens.find(candidate => candidate.id === token)!;
|
||||
held.acquiredTurn = 1; held.revealedTurn = 1;
|
||||
rejectedUnchanged(reacquired, () => validateDiscoveryFlight(reacquired, receipt, move));
|
||||
for (const damage of [
|
||||
{ ...receipt, player: 'q' }, { ...receipt, move: -1 }, { ...receipt, selection: 'changed' },
|
||||
{ ...receipt, turn: 2 }, { ...receipt, extra: true },
|
||||
]) rejectedUnchanged(context, () => validateDiscoveryFlight(context, damage, move));
|
||||
});
|
||||
|
||||
void test('receipt signature ignores property order and independent Treachery flight fields while binding a selected marker', () => {
|
||||
const { context, token, move } = fixture();
|
||||
const marked = { ...move, total: 4, noField: { tokenId: 'field-one', event: 'marker-event', from: 'false_wall_west:17' } };
|
||||
const receipt = quoteDiscoveryFlight(context, token, marked);
|
||||
const equivalent = {
|
||||
...marked, group: [...marked.group].reverse(),
|
||||
eliteGroup: { 'false_wall_west:17': 0, 'false_wall_west:16': 1 },
|
||||
ornithopterEvent: 'physical-treachery-card-event', ornithopterRange: false,
|
||||
discoveryFlight: receipt,
|
||||
};
|
||||
assert.doesNotThrow(() => validateDiscoveryFlight(context, receipt, equivalent));
|
||||
assert.deepEqual(quoteDiscoveryFlight(context, token, equivalent), receipt);
|
||||
for (const changes of [{ tokenId: 'other-field' }, { event: 'other-event' }, { from: 'false_wall_west:16' }])
|
||||
rejectedUnchanged(context, () => validateDiscoveryFlight(context, receipt,
|
||||
{ ...marked, noField: { ...marked.noField, ...changes } }));
|
||||
const withCard = { ...context, ornithopter: { event: 'physical-treachery-card-event', completed: 0, mode: 'twoGroups' } };
|
||||
const before = structuredClone(withCard);
|
||||
assert.deepEqual(quoteDiscoveryFlight(withCard, token, equivalent), receipt);
|
||||
assert.deepEqual(withCard, before);
|
||||
});
|
||||
|
||||
void test('malformed or motionless canonical groups cannot reserve the Discovery token', () => {
|
||||
const { context, token, move } = fixture();
|
||||
const malformed: DiscoveryFlightMovement[] = [
|
||||
{ ...move, source: 'ambassador' },
|
||||
{ ...move, total: 0, elite: 0, group: [], eliteGroup: {} },
|
||||
{ ...move, total: 4 },
|
||||
{ ...move, group: [['false_wall_west:16', 3], ['false_wall_west:16', 1]] },
|
||||
{ ...move, group: [['false_wall_west:16', 0], ['false_wall_west:17', 3]] },
|
||||
{ ...move, group: [['arrakeen:10', 2], ['false_wall_west:17', 1]] },
|
||||
{ ...move, eliteGroup: { 'false_wall_west:16': 3 }, elite: 3 },
|
||||
{ ...move, eliteGroup: { 'false_wall_west:16': 1, 'not-selected:0': 0 } },
|
||||
{ ...move, to: 'false_wall_west', sector: 16 },
|
||||
{ ...move, wantsFighters: true },
|
||||
];
|
||||
for (const invalid of malformed) rejectedUnchanged(context, () => quoteDiscoveryFlight(context, token, invalid));
|
||||
});
|
||||
|
||||
void test('private projected offers show a later-turn fixed range, ordinary allowance and response locks without consuming custody', () => {
|
||||
const { context, token } = fixture();
|
||||
const projected = { ...context, discoveries: projectDiscoveryState(context.discoveries!, 'atreides') };
|
||||
const before = structuredClone(projected);
|
||||
assert.deepEqual(discoveryFlightOffer(projected, 'p', { movesAllowed: 1 }), {
|
||||
token, acquiredTurn: 2, range: 3, blocked: null,
|
||||
});
|
||||
assert.equal(discoveryFlightOffer(projected, 'q'), null);
|
||||
assert.match(discoveryFlightOffer({ ...projected, turn: 2 }, 'p')!.blocked!, /turn after/);
|
||||
assert.match(discoveryFlightOffer({ ...projected, phase: 7 }, 'p')!.blocked!, /own Shipment/);
|
||||
assert.match(discoveryFlightOffer(projected, 'p', { movesAllowed: 0 })!.blocked!, /No ordinary movement/);
|
||||
assert.equal(discoveryFlightOffer(projected, 'p', { blocked: 'Finish the current response.' })!.blocked, 'Finish the current response.');
|
||||
// It replaces one action's movement range; neither Kulon nor a card adds to it.
|
||||
const otherBenefits = { ...projected, choamMovement: { turn: 3, bonus: 1 }, ornithopter: { mode: 'range3' } };
|
||||
assert.equal(discoveryFlightOffer(otherBenefits, 'p')!.range, 3);
|
||||
assert.deepEqual(projected, before);
|
||||
const badView = reload(projected);
|
||||
badView.discoveries.tokens[0].face = null;
|
||||
assert.equal(discoveryFlightOffer(badView, 'p'), null);
|
||||
});
|
||||
|
||||
void test('missing or duplicated physical custody cannot authenticate a saved flight', () => {
|
||||
const { context, token, move } = fixture(), receipt = quoteDiscoveryFlight(context, token, move);
|
||||
const missing = { ...context, discoveries: undefined };
|
||||
rejectedUnchanged(missing, () => quoteDiscoveryFlight(missing, token, move));
|
||||
const duplicated = reload(context);
|
||||
duplicated.discoveries!.tokens[0].id = token as DiscoveryOpaqueTokenId;
|
||||
rejectedUnchanged(duplicated, () => validateDiscoveryFlight(duplicated, receipt, move));
|
||||
const wrongReceipt = { ...receipt, token: 'invalid' } as unknown as DiscoveryFlightReceipt;
|
||||
rejectedUnchanged(context, () => validateDiscoveryFlight(context, wrongReceipt, move));
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { gameDistance, gameTerritories, splitLocation } from '../game/board';
|
||||
import { discoveryFixture, enterDiscoveryCollection } from './fixture-discovery';
|
||||
|
||||
export const flightOrigin = 'red_chasm:7';
|
||||
/** Start from the actual base audit setup and acquire the actual token through
|
||||
* Collection. Reposition existing physical forces for a focused movement turn. */
|
||||
export function discoveryFlightFixture(advanced = false, owner = 'a',
|
||||
ids: [string, string, string] = ['a', 'g', 'f']) {
|
||||
let game = discoveryFixture(advanced, ids);
|
||||
const token = enterDiscoveryCollection(game, 'ornithopter', owner);
|
||||
if (viewGame(game, owner).discoveries?.canInspect.includes(token.id))
|
||||
game = applyAction(game, owner, { type: 'discovery', token: token.id, reveal: false });
|
||||
game = applyAction(game, owner, { type: 'discovery', token: token.id, reveal: true });
|
||||
const acquiredTurn = game.turn;
|
||||
game.turn++;
|
||||
Object.assign(game, { phase: 5, active: owner, phaseOpening: null, response: null,
|
||||
decision: null, stormPending: null, storm: 18, ready: [], movementRemaining: [owner] });
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
game.deck.push(...player.hand);
|
||||
player.hand = [];
|
||||
player.shipped = true;
|
||||
player.moved = 0;
|
||||
}
|
||||
const pilot = game.players.find(player => player.id === owner)!;
|
||||
pilot.reserves -= 5;
|
||||
pilot.forces[flightOrigin] = 5;
|
||||
return { game, token: token.id, owner, acquiredTurn };
|
||||
}
|
||||
|
||||
export function flightDestination(game: Game, steps = 3, from = flightOrigin) {
|
||||
const target = gameTerritories(game).flatMap(t => t.sectors.map(sector => `${t.id}:${sector}`))
|
||||
.find(key => splitLocation(key).territory !== splitLocation(from).territory &&
|
||||
splitLocation(key).sector !== game.storm &&
|
||||
gameDistance(game, from, key, key => splitLocation(key).sector === game.storm) === steps);
|
||||
assert.ok(target, `a ${steps}-territory route exists`);
|
||||
return target;
|
||||
}
|
||||
Reference in new issue
Block a user