Connect native Discovery setup and nested force paths

This commit is contained in:
Quintine committed 2026-10-05 15:28:50 +11:00
1 parent 8d17fab3de
commit 67ce690064
17 files changed
+1379 -39

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
function lobby(roster: FactionId[], expansions: string[], advanced = true): Game {
const game = createGame('DISCINIT', newPlayer(roster[0], roster[0], roster[0]), advanced, expansions);
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
for (const player of game.players) player.ready = true;
game.discoveryEnabled = true;
return game;
}
void test('Discovery requires the selected native family and does not admit paired or mixed E3', () => {
const unsupported = [
lobby(['ixians', 'guild'], []),
lobby(['richese', 'guild'], ['ix']),
lobby(['ecaz', 'moritani', 'guild'], ['ecaz']),
lobby(['ecaz', 'ixians', 'guild'], ['ecaz', 'ix']),
lobby(['moritani', 'harkonnen', 'guild'], ['ecaz']),
];
for (const game of unsupported) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
// The Advanced Moritani/Harkonnen restriction is not a new Basic restriction.
const basic = initializeDiscoveryGameForAudit(lobby(['moritani', 'harkonnen', 'guild'], ['ecaz'], false));
assert.equal(basic.moritaniAssassinatePreview, undefined);
assert.equal(basic.discoveries?.tokens.length, 8);
});
void test('Discovery Tech requires three seats and untouched physical tokens', () => {
const two = lobby(['ixians', 'tleilaxu'], ['ix']); two.techTokens = createTechTokens();
assert.throws(() => initializeDiscoveryGameForAudit(two), RuleError);
const used = lobby(['choam', 'richese', 'guild'], ['choam']); used.techTokens = createTechTokens();
used.techTokens.production.owner = 'choam';
assert.throws(() => initializeDiscoveryGameForAudit(used), RuleError);
const fresh = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']); fresh.techTokens = createTechTokens();
const initialized = initializeDiscoveryGameForAudit(fresh);
assert.equal(initialized.techTokens?.heighliners.owner, 'ixians');
assert.equal(initialized.techTokens?.axlotl.owner, 'tleilaxu');
assert.equal(initialized.discoveryEnabled, true);
});
void test('Discovery cannot redeal started components or silently remove another module', () => {
const initial = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']);
const started = initializeDiscoveryGameForAudit(initial);
assert.throws(() => initializeDiscoveryGameForAudit(started), RuleError);
const overlays: Game[] = [structuredClone(initial), structuredClone(initial), structuredClone(initial)];
overlays[0].homeworlds = { custody: null };
overlays[1].nexusCards = { cards: null, phase: null };
overlays[2].strongholdCards = createStrongholdCards();
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
});
+53
View File
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine';
import { createTechTokens } from '../game/tech-tokens';
import { nativeTypedClearBoard, nativeTypedPhase, nativeTypedPlace, nativeTypedPlayer, nativeTypedReload, nativeTypedStep } from './fixture-discovery-native-typed';
import { putDiscovery } from './fixture-discovery';
import { giveAssassinationTraitor } from './moritani-assassinate-fixture';
import { openSkillsTechBattle, nextSkillsTechBattleStep } from './fixture-skills-tech-battle';
void test('native Moritani assassination retains the actual revealed Discovery battle source and physical traitor cost', () => {
let game = createGame('DISCASSA', newPlayer('m', 'Moritani', 'moritani'), true, ['ecaz']);
joinGame(game, newPlayer('g', 'Guild', 'guild')); joinGame(game, newPlayer('f', 'Fremen', 'fremen'));
for (const player of game.players) player.ready = true;
game.discoveryEnabled = true; game.techTokens = createTechTokens();
game = initializeDiscoveryGameForAudit(game);
for (let n = 0; game.status === 'setup' && n < 100; n++) game = nativeTypedStep(game);
game = nativeTypedPhase(game, 5);
nativeTypedClearBoard(game);
// Conserved original supply token and reserve positions; inspect/reveal,
// clocks, battle, assassination and reward are produced by original controls.
const token = putDiscovery(game, 'cistern');
nativeTypedPlace(game, 'm', `${token.territory}:${token.sector}`, 2);
game = nativeTypedPhase(game, 7);
game = applyAction(game, 'm', { type: 'discovery', token: token.id, reveal: false });
game = applyAction(game, 'm', { type: 'discovery', token: token.id, reveal: true });
game = nativeTypedPhase(game, 8);
game = nativeTypedPhase(game, 5, 2);
nativeTypedClearBoard(game);
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
for (const id of ['m', 'g']) nativeTypedPlace(game, id, 'cistern:0', 3);
giveAssassinationTraitor(game, 'guild-1');
game = nativeTypedPhase(game, 6);
game = openSkillsTechBattle(game, 'm', 'g', 'cistern');
for (const id of [game.battle!.attacker, game.battle!.defender]) {
const leader = id === 'g' ? 'guild-0' : nativeTypedPlayer(game, 'm').leaders[4].id;
game = applyAction(game, id, { type: 'battlePlan', leader, dial: 0, support: 0 });
}
for (const id of ['m', 'g']) game = applyAction(game, id, { type: 'traitorCall', call: false });
for (let n = 0; game.decision?.kind !== 'moritaniAssassinate' && n < 100; n++) {
const next = nextSkillsTechBattleStep(game); game = applyAction(game, next.actor, next.action);
}
assert.ok(game.decision?.kind === 'moritaniAssassinate');
assert.equal(viewGame(game, 'm').moritaniAssassinate!.pending!.territory, 'cistern');
const spice = nativeTypedPlayer(game, 'm').spice;
const event = game.decision.event;
game = applyAction(nativeTypedReload(game), 'm', { type: 'decision', event, card: 'guild-1' });
assert.equal(nativeTypedPlayer(game, 'g').leaders.find(leader => leader.id === 'guild-1')!.dead, true);
assert.equal(nativeTypedPlayer(game, 'm').spice, spice + 3);
assert.equal(game.moritaniAssassinate!.opportunities[0].territory, 'cistern');
assert.equal(game.moritaniAssassinate!.opportunities[0].card, 'guild-1');
assert.equal(game.moritaniAssassinate!.opportunities[0].stage, 'revealed');
assert.equal(viewGame(game, 'm').moritaniAssassinate!.history[0].territory, 'cistern');
});
@@ -0,0 +1,289 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { gameTerritories, validGameLocation } from '../game/board';
import { spiceDeck, treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { DISCOVERY_LOCATION_IDS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import { reserveShipmentCost } from '../game/shipment-price';
import { deployRicheseNoField, moveRicheseNoField, projectRicheseNoField, validateRicheseNoField } from '../game/richese-no-field';
import {
createDiscoveryNativeMarkerFixture, discoveryNativeMarkerShipmentWindow,
nextDiscoveryNativeMarkerStep, revealDiscoveryNativeMarker, settleDiscoveryNativeMarkerShipment,
} from './fixture-discovery-native-marker';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function custody(game: Game) {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
assert.deepEqual(cards.map(card => card.id).sort(),
[...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort());
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
if (player.noField) validateRicheseNoField(player.noField);
}
validateDiscoveryState(game.discoveries!);
assert.equal(game.discoveries!.tokens.length, 8);
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function legalPolicies(game: Game, actor: string) {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, actor);
view.players.find(player => player.id === actor)!.bot = difficulty;
const choices = botActions(view);
assert.ok(choices.length, `${difficulty} must offer an actual legal continuation.`);
for (const action of choices) {
const before = structuredClone(game);
const next = applyAction(reload(game), actor, action);
assert.deepEqual(game, before);
custody(next);
}
}
}
void test('fresh E2 Discovery uses the original 35 ordinary cards, real cache, seven Spice Cards and eight tokens', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const fixture = createDiscoveryNativeMarkerFixture({ advanced, tech });
assert.equal(treacheryDeck(['choam']).length, 35);
const setup = fixture.setup;
const ordinary = [...setup.deck, ...setup.players.flatMap(player => player.hand)];
assert.deepEqual(ordinary.map(card => card.id).sort(), treacheryDeck(['choam']).map(card => card.id).sort());
assert.deepEqual(setup.richeseCache!.map(card => card.id).sort(), richeseCards().map(card => card.id).sort());
assert.equal(setup.spiceDeck.length, spiceDeck().length + 7);
assert.deepEqual(setup.spiceDeck.flatMap(card => 'territory' in card && card.discovery ? [card.discovery] : []).sort(),
DISCOVERY_SPICE_CARDS.map(card => card.discovery).sort());
assert.equal(setup.spiceDeck.filter(card => 'worm' in card && card.greatMaker).length, 1);
assert.equal(setup.discoveries!.tokens.length, 8);
assert.deepEqual(viewGame(setup, fixture.owner).discoveries!.tokens, []);
assert.equal(fixture.game.phase, 7);
assert.equal(fixture.game.turn, 1);
assert.ok(fixture.actions.some(step => step.action.type === 'stormDial'));
assert.ok(fixture.actions.some(step => step.action.type === 'ship'));
assert.ok(fixture.actions.some(step => step.action.type === 'richeseBid'));
assert.equal(Boolean(fixture.game.techTokens), tech);
custody(fixture.game);
const before = structuredClone(fixture.initial);
assert.throws(() => applyAction(fixture.initial, fixture.initial.host, { type: 'start' }));
assert.deepEqual(fixture.initial, before);
}
});
void test('every known Discovery sector-zero location preserves private No-Field custody without authorizing arbitrary locations', () => {
const fixture = createDiscoveryNativeMarkerFixture();
const inventory = fixture.game.players.find(player => player.id === fixture.owner)!.noField!;
const before = structuredClone(inventory);
for (const territory of DISCOVERY_LOCATION_IDS) {
const deployed = deployRicheseNoField(inventory, {
tokenId: inventory.tokens[0].id, controller: fixture.owner, location: { territory, sector: 0 },
});
const moved = moveRicheseNoField(JSON.parse(JSON.stringify(deployed)), inventory.tokens[0].id, { territory, sector: 0 });
assert.deepEqual(moved.tokens, inventory.tokens);
assert.deepEqual(projectRicheseNoField(moved, false).deployed, {
controller: fixture.owner, location: { territory, sector: 0 }, effectiveForces: 1,
});
assert.equal(validGameLocation(fixture.game, territory, 0), false);
}
for (const territory of ['invented-discovery', 'treachery-card-stash', 'arrakeen']) {
assert.throws(() => deployRicheseNoField(inventory, {
tokenId: inventory.tokens[0].id, controller: fixture.owner, location: { territory, sector: 0 },
}));
}
assert.deepEqual(inventory, before);
});
void test('native paid markers ship into actual revealed nested sites, move out, and reveal 0/3/5 exactly once through JSON', () => {
for (const advanced of [false, true]) {
const fixture = createDiscoveryNativeMarkerFixture({ advanced, tech: true, face: 'shrine' });
const window = discoveryNativeMarkerShipmentWindow(fixture);
const owner = window.players.find(player => player.id === fixture.owner)!;
const inventory = structuredClone(owner.noField!.tokens);
assert.equal(validGameLocation(window, fixture.face, 0), true);
assert.ok(gameTerritories(viewGame(window, fixture.owner)).some(territory => territory.id === fixture.face && territory.sectors[0] === 0));
assert.equal(viewGame(window, fixture.owner).richeseNoField!.canShip, true);
legalPolicies(window, fixture.owner);
for (const token of inventory) {
const action: Action = { type: 'ship', territory: fixture.face, sector: 0,
noField: token.id, event: owner.noFieldEvent, allyPayment: 0 };
const quoted = reserveShipmentCost({ faction: owner.faction, halfRate: false },
gameTerritories(window).find(territory => territory.id === fixture.face)!.type, 1);
assert.equal(quoted, 1);
reject(window, fixture.collector, action);
reject(window, fixture.owner, { ...action, event: 'stale-marker' });
reject(window, fixture.owner, { ...action, territory: 'cistern' });
reject(window, fixture.owner, { ...action, territory: 'arbitrary-sector-zero' });
const corrupt = reload(window);
corrupt.players.find(player => player.id === fixture.owner)!.noField!.tokens[1].id = token.id;
if (token.id === inventory[1].id) corrupt.players.find(player => player.id === fixture.owner)!.noField!.tokens[0].id = token.id;
reject(corrupt, fixture.owner, action);
let game = applyAction(reload(window), fixture.owner, action);
assert.equal(game.players.find(player => player.id === fixture.owner)!.reserves, owner.reserves);
game = settleDiscoveryNativeMarkerShipment(reload(game));
const shipped = game.players.find(player => player.id === fixture.owner)!;
assert.equal(shipped.spice, owner.spice - quoted);
assert.equal(shipped.reserves, owner.reserves);
assert.deepEqual(shipped.forces, owner.forces);
assert.deepEqual(shipped.noField!.tokens, inventory);
const other = viewGame(game, fixture.collector);
assert.equal(other.richeseNoField!.private, null);
assert.equal(other.richeseNoField!.public.deployed!.effectiveForces, 1);
for (const physical of inventory) assert.equal(JSON.stringify(other).includes(physical.id), false);
for (const territory of ['cistern', 'unknown-nested-site']) {
const corruptDeployment = reload(game);
corruptDeployment.players.find(player => player.id === fixture.owner)!.noField!.deployed!.location = { territory, sector: 0 };
reject(corruptDeployment, fixture.owner, { type: 'revealNoField', token: token.id, event: shipped.noFieldEvent });
}
const move: Action = { type: 'move', forces: {}, territory: fixture.parent, sector: fixture.parentSector,
noField: token.id, event: shipped.noFieldEvent };
reject(game, fixture.collector, move);
reject(game, fixture.owner, { ...move, event: owner.noFieldEvent });
game = applyAction(reload(game), fixture.owner, move);
game = settleDiscoveryNativeMarkerShipment(game);
const moved = game.players.find(player => player.id === fixture.owner)!;
assert.deepEqual(moved.noField!.deployed!.location, { territory: fixture.parent, sector: fixture.parentSector });
assert.deepEqual(moved.forces, owner.forces);
assert.equal(moved.reserves, owner.reserves);
assert.equal(moved.spice, owner.spice - quoted);
assert.equal(moved.moved, owner.moved + 1);
reject(game, fixture.owner, move);
const reveal: Action = { type: 'revealNoField', token: token.id, event: moved.noFieldEvent };
reject(game, fixture.collector, reveal);
reject(game, fixture.owner, { ...reveal, event: shipped.noFieldEvent });
const materialized = Math.min(token.value, moved.reserves);
const key = `${fixture.parent}:${fixture.parentSector}`;
game = applyAction(reload(game), fixture.owner, reveal);
const revealed = game.players.find(player => player.id === fixture.owner)!;
assert.equal(revealed.reserves, owner.reserves - materialized);
assert.equal(revealed.forces[key] ?? 0, (owner.forces[key] ?? 0) + materialized);
assert.equal(revealed.spice, owner.spice - quoted);
assert.equal(revealed.noField!.deployed, null);
assert.equal(revealed.noField!.lastShipped, token.id);
assert.deepEqual(revealed.noField!.tokens, inventory);
reject(game, fixture.owner, reveal);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
custody(game);
}
}
});
void test('a restored pending marker declaration cannot retarget its final real response into an unknown or unrevealed site', () => {
const fixture = createDiscoveryNativeMarkerFixture({ advanced: true, face: 'shrine' });
const window = discoveryNativeMarkerShipmentWindow(fixture);
const owner = window.players.find(player => player.id === fixture.owner)!;
const token = owner.noField!.tokens.find(candidate => candidate.value === 5)!;
// A real physical Karama keeps the native cancellation producer open.
const karamaIndex = window.deck.findIndex(card => card.effect === 'karama');
assert.ok(karamaIndex >= 0);
window.players.find(player => player.id === fixture.collector)!.hand.push(window.deck.splice(karamaIndex, 1)[0]);
let game = applyAction(window, fixture.owner, { type: 'ship', noField: token.id,
event: owner.noFieldEvent, territory: fixture.face, sector: 0, allyPayment: 0 });
assert.ok(game.response?.kind === 'richeseNoField' && game.pendingShipment);
let last = nextDiscoveryNativeMarkerStep(game);
// Stop before the original pass that closes this response, not at an
// incidental count of auto-passed or privately eligible seats.
for (let n = 0; n < game.players.length; n++) {
assert.equal(last.action.type, 'passResponse');
const next = applyAction(game, last.actor, last.action);
if (next.response?.kind !== 'richeseNoField') break;
game = next;
last = nextDiscoveryNativeMarkerStep(game);
}
for (const territory of ['cistern', 'unknown-nested-site']) {
const corrupt = reload(game);
corrupt.pendingShipment!.territory = territory;
reject(corrupt, last.actor, last.action);
assert.equal(corrupt.players.find(player => player.id === fixture.owner)!.spice, owner.spice);
assert.equal(corrupt.players.find(player => player.id === fixture.owner)!.reserves, owner.reserves);
assert.equal(corrupt.players.find(player => player.id === fixture.owner)!.noField!.deployed, null);
}
const done = settleDiscoveryNativeMarkerShipment(applyAction(reload(game), last.actor, last.action));
const shipped = done.players.find(player => player.id === fixture.owner)!;
assert.equal(shipped.spice, owner.spice - 1);
assert.equal(shipped.reserves, owner.reserves);
assert.deepEqual(shipped.noField!.deployed!.location, { territory: fixture.face, sector: 0 });
custody(done);
});
void test('one paid marker can enter a revealed parent-nested site by real movement and reveal there without merging physical force custody', () => {
const fixture = createDiscoveryNativeMarkerFixture({ face: 'cistern' });
let game = discoveryNativeMarkerShipmentWindow(fixture);
const owner = game.players.find(player => player.id === fixture.owner)!;
const before = structuredClone(owner);
const token = owner.noField!.tokens.find(candidate => candidate.value === 5)!;
game = applyAction(game, fixture.owner, { type: 'ship', noField: token.id, event: owner.noFieldEvent,
territory: fixture.parent, sector: fixture.parentSector, allyPayment: 0 });
game = settleDiscoveryNativeMarkerShipment(game);
const shipped = game.players.find(player => player.id === fixture.owner)!;
const move: Action = { type: 'move', forces: {}, noField: token.id, event: shipped.noFieldEvent,
territory: fixture.face, sector: 0 };
reject(game, fixture.owner, { ...move, territory: 'shrine' });
game = applyAction(reload(game), fixture.owner, move);
game = settleDiscoveryNativeMarkerShipment(game);
const moved = game.players.find(player => player.id === fixture.owner)!;
assert.deepEqual(moved.forces, before.forces);
assert.equal(moved.reserves, before.reserves);
assert.deepEqual(moved.noField!.deployed!.location, { territory: fixture.face, sector: 0 });
game = applyAction(reload(game), fixture.owner, { type: 'revealNoField', token: token.id, event: moved.noFieldEvent });
const revealed = game.players.find(player => player.id === fixture.owner)!;
assert.equal(revealed.forces[`${fixture.face}:0`], 5);
assert.equal(revealed.reserves, before.reserves - 5);
assert.equal(revealed.spice, before.spice - 2);
custody(game);
});
void test('CHOAM opens the actual Treachery Card Stash at five, draws six, owns every discard and returns to five without payment/refund', () => {
for (const advanced of [false, true]) {
const fixture = createDiscoveryNativeMarkerFixture({ advanced, face: 'treachery-card-stash', collector: 'choam' });
const before = fixture.game;
const choam = before.players.find(player => player.id === fixture.collector)!;
assert.equal(handLimit(choam), 5);
assert.equal(viewGame(before, fixture.collector).players.find(player => player.id === fixture.collector)!.handLimit, 5);
// Explicit conserved custody staging, not an invented card or auction receipt.
while (choam.hand.length < 5) {
const physical = before.deck.shift();
assert.ok(physical);
choam.hand.push(physical);
}
fixture.staging.push('Moved undealt original ordinary cards into CHOAM hand to exercise its real five-card boundary.');
const wallet = choam.spice, original = choam.hand.map(card => card.id), drawn = before.deck[0];
assert.ok(drawn);
custody(before);
const game = revealDiscoveryNativeMarker(fixture);
assert.equal(game.decision?.kind, 'discoveryDiscard');
assert.equal(game.decision?.player, fixture.collector);
assert.equal(game.players.find(player => player.id === fixture.collector)!.hand.length, 6);
assert.deepEqual(game.players.find(player => player.id === fixture.collector)!.hand.map(card => card.id), [...original, drawn.id]);
assert.equal(game.players.find(player => player.id === fixture.collector)!.spice, wallet);
assert.equal(game.deck.length, before.deck.length - 1);
const other = JSON.stringify(viewGame(game, fixture.owner));
assert.equal(other.includes(JSON.stringify(drawn.id)), false);
assert.equal(viewGame(game, fixture.collector).players.find(player => player.id === fixture.collector)!.handCount, 6);
legalPolicies(game, fixture.collector);
for (const card of game.players.find(player => player.id === fixture.collector)!.hand) {
const action: Action = { type: 'decision', event: game.discoveryStash!.event, card: card.id };
reject(game, fixture.owner, action);
reject(game, fixture.collector, { ...action, event: 'stale-stash' });
reject(game, fixture.collector, { ...action, card: 'invented-card' });
let done = applyAction(reload(game), fixture.collector, action);
done = settleDiscoveryNativeMarkerShipment(done);
assert.equal(done.discoveryStash!.stage, 'complete');
assert.equal(done.players.find(player => player.id === fixture.collector)!.hand.length, 5);
assert.equal(done.players.find(player => player.id === fixture.collector)!.spice, wallet);
assert.equal(done.deck.length, before.deck.length - 1);
assert.equal(done.discard.filter(physical => physical.id === card.id).length, 1);
assert.equal(done.discoveries!.tokens.find(token => token.id === fixture.token)!.status, 'removed');
reject(done, fixture.collector, action);
reject(done, fixture.collector, { type: 'discovery', token: fixture.token, reveal: true });
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
custody(done);
}
custody(game);
}
});
@@ -0,0 +1,297 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { gameDistance, gameTerritories } from '../game/board';
import { DISCOVERY_SPICE_CARDS } from '../game/discoveries';
import {
MOBILE_LOCATION, MOBILE_STRONGHOLD, nativeTypedCard, nativeTypedCollection,
nativeTypedCustody, nativeTypedEntry, nativeTypedFaceDance, nativeTypedMovementTurn,
nativeTypedPhase, nativeTypedPlace, nativeTypedPlayer, nativeTypedReload,
nativeTypedReveal, nativeTypedSetup, nativeTypedStep,
} from './fixture-discovery-native-typed';
function rejectUnchanged(game: Game, owner: string, action: Action) {
const before = nativeTypedReload(game);
assert.throws(() => applyAction(game, owner, action));
assert.deepEqual(game, before);
}
function legalPolicies(game: Game, owner: string) {
assert.equal(DIFFICULTIES.length, 4);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, owner);
view.players.find(p => p.id === owner)!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length > 0);
for (const action of actions) {
const result = applyAction(nativeTypedReload(game), owner, action);
nativeTypedCustody(result);
}
}
}
function entryAction(game: Game, groups?: unknown): Action {
const owner = game.decision!.player;
const offer = viewGame(game, owner).discoveryEntry!;
return { type: 'decision', accept: true, event: offer.event, groups: groups ?? offer.sources };
}
function settle(state: Game): Game {
let game = state;
for (let step = 0; step < 100 && (game.decision || game.response || game.pendingShipment); step++)
game = nativeTypedStep(game);
assert.ok(!game.decision && !game.response && !game.pendingShipment);
return game;
}
void test('fresh E1 Discovery uses original 47-card setup, Sandtrout, seven cards/eight tokens, HMS and native private deals', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const { game, initialized, lobby } = nativeTypedSetup({ advanced, tech });
assert.equal(game.discoveryEnabled, true);
assert.equal(game.leaderSkills, undefined);
assert.equal(game.strongholdCards, undefined);
assert.equal(game.homeworlds, undefined);
assert.equal(game.nexusCards, undefined);
assert.deepEqual(initialized.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(),
DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort());
assert.equal(viewGame(initialized, 'i').discoveries!.tokens.length, 0);
assert.equal(viewGame(game, 't').players.find(p => p.id === 't')!.faceDancers!.length, 3);
assert.equal(viewGame(game, 'g').players.find(p => p.id === 't')!.faceDancers, undefined);
assert.equal(nativeTypedPlayer(game, 'i').elites!.reserves, 4);
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces[MOBILE_LOCATION], 3);
if (tech) {
assert.equal(game.techTokens!.heighliners.owner, 'i');
assert.equal(game.techTokens!.axlotl.owner, 't');
assert.equal(game.techTokens!.production.owner, 'f');
} else assert.equal(game.techTokens, undefined);
const before = nativeTypedReload(lobby);
assert.throws(() => applyAction(lobby, lobby.host, { type: 'start' }));
assert.deepEqual(lobby, before);
nativeTypedCustody(game);
}
});
void test('original native Cyborg/Suboid and ordinary/starred Fremen free entry transfers only actual typed counters once', () => {
for (const advanced of [false, true]) for (const owner of ['i', 'f']) {
const { game, source } = nativeTypedEntry({ advanced, tech: true }, owner);
const player = nativeTypedPlayer(game, owner);
const expectedElite = owner === 'i' || advanced ? 1 : 0;
const offer = viewGame(game, owner).discoveryEntry!;
assert.deepEqual(offer.sources, [{ source, normal: 2, elite: expectedElite }]);
assert.ok(offer.sources.every(group => group.source !== MOBILE_LOCATION));
const before = nativeTypedReload(game);
const action = entryAction(game, [{ source, normal: 1, elite: expectedElite }]);
legalPolicies(game, owner);
const done = applyAction(nativeTypedReload(game), owner, action);
const arrived = nativeTypedPlayer(done, owner);
assert.equal(arrived.forces['shrine:0'], 1 + expectedElite);
assert.equal(arrived.elites?.forces['shrine:0'] ?? 0, expectedElite);
assert.equal(arrived.forces[source], 1);
assert.equal(arrived.elites?.forces[source] ?? 0, 0);
assert.equal(arrived.reserves, player.reserves);
assert.equal(arrived.elites?.reserves, player.elites?.reserves);
assert.equal(arrived.spice, player.spice);
assert.equal(arrived.moved, player.moved);
assert.equal(arrived.shipped, player.shipped);
assert.deepEqual(done.techTokens, game.techTokens);
const progress = viewGame(done, owner).victoryProgress.find(row => row.player === owner)!;
assert.deepEqual(progress.strongholds, owner === 'i' ? [MOBILE_STRONGHOLD] : []);
assert.equal(progress.techStronghold, false);
assert.equal(progress.qualifies, false);
assert.equal(done.discoveryEntry, undefined);
assert.deepEqual(done.discoveries!.newlyRevealed, []);
assert.deepEqual(game, before);
assert.deepEqual(normalizeAutomaticGame(nativeTypedReload(done)), done);
rejectUnchanged(done, owner, action);
nativeTypedCustody(done);
}
});
void test('duplicate, stale, foreign and overdrawn native typed free entry selections reject atomically across JSON', () => {
const { game, source } = nativeTypedEntry({ advanced: true });
const group = { source, normal: 1, elite: 1 };
const action = entryAction(game, [group]);
rejectUnchanged(game, 't', action);
rejectUnchanged(game, 'i', { ...action, event: 'old-native-entry' });
rejectUnchanged(game, 'i', entryAction(game, [group, group]));
rejectUnchanged(game, 'i', entryAction(game, [{ source, normal: 3, elite: 1 }]));
rejectUnchanged(game, 'i', entryAction(game, [{ source, normal: 0, elite: 2 }]));
rejectUnchanged(game, 'i', entryAction(game, [{ source: MOBILE_LOCATION, normal: 1, elite: 1 }]));
const reloaded = nativeTypedReload(game);
const publicOffer = viewGame(reloaded, 'i').discoveryEntry!;
publicOffer.sources[0].elite = 7;
assert.deepEqual(reloaded, game);
assert.ok(game.decision?.kind === 'discoveryEntry');
const declined = applyAction(reloaded, 'i', { type: 'decision', accept: false, event: game.decision!.event });
assert.equal(nativeTypedPlayer(declined, 'i').forces['shrine:0'], undefined);
assert.equal(nativeTypedPlayer(declined, 'i').forces[source], 3);
nativeTypedCustody(declined);
});
void test('typed native free entry keeps a signed pending child and resumes exactly once after original advisor controls', () => {
let game = nativeTypedEntry({ advanced: true, advisors: true }).game;
nativeTypedPlace(game, 'b', 'shrine:0', 1);
const action = entryAction(game);
game = applyAction(game, 'i', action);
assert.equal(game.discoveryEntry?.stage, 'arrival');
assert.equal(game.decision?.kind, 'intrusion');
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 3);
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces['shrine:0'], 1);
const malformed = nativeTypedReload(game);
if (malformed.decision?.kind === 'intrusion') malformed.decision.discoveryEntry!.elite = 0;
assert.throws(() => viewGame(malformed, 'b'));
rejectUnchanged(malformed, 'b', { type: 'decision', accept: false });
const arrived = nativeTypedReload(game);
nativeTypedPlayer(arrived, 'i').elites!.forces['shrine:0'] = 0;
assert.throws(() => normalizeAutomaticGame(arrived));
rejectUnchanged(arrived, 'b', { type: 'decision', accept: false });
game = applyAction(nativeTypedReload(game), 'b', { type: 'decision', accept: true });
game = settle(nativeTypedReload(game));
assert.equal(game.discoveryEntry, undefined);
assert.equal(game.phase, 0);
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 3);
rejectUnchanged(game, 'i', action);
nativeTypedCustody(game);
});
void test('ordinary paid Cyborg shipment and nested movement preserve physical HMS identity and optional native industry', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const entry = nativeTypedEntry({ advanced, tech });
assert.ok(entry.game.decision?.kind === 'discoveryEntry');
let game = applyAction(entry.game, 'i', { type: 'decision', accept: false, event: entry.game.decision!.event });
game = nativeTypedMovementTurn(nativeTypedPhase(game, 5), 'i');
const before = nativeTypedReload(game);
const hms = nativeTypedPlayer(game, 'i').forces[MOBILE_LOCATION];
game = settle(applyAction(nativeTypedReload(game), 'i', { type: 'ship', territory: 'shrine', sector: 0, amount: 2, elite: 1 }));
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 2);
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces['shrine:0'], 1);
assert.equal(nativeTypedPlayer(game, 'i').reserves, nativeTypedPlayer(before, 'i').reserves - 2);
assert.equal(nativeTypedPlayer(game, 'i').elites!.reserves, nativeTypedPlayer(before, 'i').elites!.reserves - 1);
assert.equal(nativeTypedPlayer(game, 'i').spice, nativeTypedPlayer(before, 'i').spice - 2);
assert.equal(nativeTypedPlayer(game, 'g').spice, nativeTypedPlayer(before, 'g').spice + 2);
assert.equal(nativeTypedPlayer(game, 'i').shipped, true);
assert.equal(nativeTypedPlayer(game, 'i').forces[MOBILE_LOCATION], hms);
assert.notEqual(MOBILE_STRONGHOLD, 'shrine');
assert.equal(gameDistance(game, entry.source, 'shrine:0'), 1);
const movement: Action = { type: 'move', forces: { [entry.source]: 2 }, eliteForces: { [entry.source]: 1 }, territory: 'shrine', sector: 0 };
const moving = nativeTypedReload(game);
game = settle(applyAction(game, 'i', movement));
assert.equal(nativeTypedPlayer(game, 'i').forces['shrine:0'], 4);
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces['shrine:0'], 2);
assert.equal(nativeTypedPlayer(game, 'i').forces[entry.source], 1);
assert.equal(nativeTypedPlayer(game, 'i').moved, 1);
assert.equal(nativeTypedPlayer(game, 'i').spice, nativeTypedPlayer(moving, 'i').spice);
rejectUnchanged(game, 'i', movement);
if (tech) {
assert.equal(game.techTokens!.heighliners.triggeredTurn, game.turn);
assert.equal(game.techTokens!.heighliners.spice, 1);
const wallet = nativeTypedPlayer(game, 'i').spice;
for (let step = 0; step < 100 && game.phase === 5; step++) game = nativeTypedStep(game);
assert.notEqual(game.phase, 5);
assert.equal(game.techTokens!.heighliners.spice, 0);
assert.equal(nativeTypedPlayer(game, 'i').spice, wallet + 1);
}
nativeTypedCustody(game);
}
});
void test('native Tleilaxu Collection stash draws an actual E1 card privately and all four policies recover a full hand', () => {
const position = nativeTypedCollection('treachery-card-stash', 't', { advanced: true });
const player = nativeTypedPlayer(position.game, 't');
while (player.hand.length < 4) nativeTypedCard(position.game, 't', () => true);
const drawn = position.game.deck[0];
const before = player.hand.map(c => c.id);
const game = nativeTypedReveal(position);
assert.equal(game.decision?.kind, 'discoveryDiscard');
assert.deepEqual(nativeTypedPlayer(game, 't').hand.map(c => c.id), [...before, drawn.id]);
assert.equal(viewGame(game, 'g').players.find(p => p.id === 't')!.hand, undefined);
assert.equal(JSON.stringify(viewGame(game, 'g')).includes(JSON.stringify(drawn.id)), false);
legalPolicies(game, 't');
const action = { type: 'decision', event: game.discoveryStash!.event, card: drawn.id };
rejectUnchanged(game, 'g', action);
rejectUnchanged(game, 't', { ...action, event: 'old-native-stash' });
const done = applyAction(nativeTypedReload(game), 't', action);
assert.equal(nativeTypedPlayer(done, 't').hand.length, 4);
assert.ok(done.discard.some(c => c.id === drawn.id));
assert.equal(done.discoveries!.tokens.find(t => t.id === position.token)!.status, 'removed');
rejectUnchanged(done, 't', action);
nativeTypedCustody(done);
});
void test('native nested battle retains original winner cards/Tech rewards before valid Face Dance replaces from actual sources', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const fixture = nativeTypedFaceDance(advanced, tech);
const { game, action } = fixture;
assert.ok(nativeTypedPlayer(game, 'g').hand.some(c => c.id === fixture.weapon.id));
assert.ok(game.discard.some(c => c.id === fixture.defense.id));
assert.ok(game.discard.some(c => c.id === fixture.loserCard.id));
assert.equal(nativeTypedPlayer(game, 't').tanks, 8);
assert.equal(game.lastBattleContext!.winner, 'g');
if (tech) {
assert.equal(fixture.cleanup.techTokens!.axlotl.owner, 't');
assert.equal(game.techTokens!.axlotl.owner, 'g');
assert.equal(game.techTokens!.heighliners.owner, 'i');
assert.equal(game.techTokens!.production.owner, 'f');
}
legalPolicies(game, 't');
rejectUnchanged(game, 'i', action);
rejectUnchanged(game, 't', { ...action, sector: 1 });
rejectUnchanged(game, 't', { ...action, sources: { reserves: 9 } });
rejectUnchanged(game, 't', { ...action, sources: { [MOBILE_LOCATION]: 1 } });
const done = applyAction(nativeTypedReload(game), 't', action);
assert.equal(nativeTypedPlayer(done, 'g').forces['shrine:0'], undefined);
assert.equal(nativeTypedPlayer(done, 'g').reserves, nativeTypedPlayer(game, 'g').reserves + 8);
assert.equal(nativeTypedPlayer(done, 't').forces['shrine:0'], 3);
assert.equal(nativeTypedPlayer(done, 't').forces[fixture.source], 1);
assert.equal(nativeTypedPlayer(done, 't').reserves, nativeTypedPlayer(game, 't').reserves - 1);
assert.equal(nativeTypedPlayer(done, 't').spice, nativeTypedPlayer(game, 't').spice);
assert.equal(nativeTypedPlayer(done, 'g').leaders.find(l => l.id === fixture.winnerLeader)!.dead, true);
assert.deepEqual(done.techTokens, game.techTokens);
assert.equal(done.phase, 6);
assert.deepEqual(viewGame(done, 't').victoryProgress.find(row => row.player === 't')!.strongholds, []);
rejectUnchanged(done, 't', action);
nativeTypedCustody(done);
}
});
void test('native third Face Dance refills only genuine private identities with conserved Traitor/Face Dancer inventory', () => {
const fixture = nativeTypedFaceDance(true);
// Labelled original revealed-card history, not a fabricated card or new identity.
for (const dancer of nativeTypedPlayer(fixture.game, 't').faceDancers!)
dancer.revealed = dancer.leader !== fixture.winnerLeader;
const identities = [...fixture.game.traitorReserve!,
...nativeTypedPlayer(fixture.game, 't').faceDancers!.map(c => c.leader)].sort();
const done = applyAction(nativeTypedReload(fixture.game), 't', fixture.action);
assert.equal(nativeTypedPlayer(done, 't').faceDancers!.length, 3);
assert.ok(nativeTypedPlayer(done, 't').faceDancers!.every(c => !c.revealed));
assert.deepEqual([...done.traitorReserve!, ...nativeTypedPlayer(done, 't').faceDancers!.map(c => c.leader)].sort(), identities);
assert.equal(viewGame(done, 'i').players.find(p => p.id === 't')!.faceDancers, undefined);
nativeTypedCustody(done);
});
void test('original E1 Discovery Spice Blow producer destroys actual ordinary/starred groups before physical placement', () => {
for (const advanced of [false, true]) {
let game = nativeTypedSetup({ advanced }).game;
game = nativeTypedPhase(game, 1);
const printed = DISCOVERY_SPICE_CARDS.find(card => card.sector !== game.storm)!;
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed.discovery);
assert.ok(index >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
const key = `${printed.territory}:${printed.sector}`;
nativeTypedPlace(game, 'i', key, 1, 1);
nativeTypedPlace(game, 'f', key, 1, advanced ? 1 : 0);
const before = nativeTypedReload(game);
for (let step = 0; step < 100 && game.phase === 1; step++) game = nativeTypedStep(game);
assert.ok(game.spiceDiscard.flat().some(card => 'territory' in card && card.discovery === printed.discovery));
assert.equal(game.spice[key], 6);
for (const id of ['i', 'f']) {
const old = nativeTypedPlayer(before, id), player = nativeTypedPlayer(game, id);
assert.equal(player.forces[key], undefined);
assert.equal(player.tanks, old.tanks + old.forces[key]);
assert.equal(player.elites?.forces[key], undefined);
if (player.elites) assert.equal(player.elites.tanks, old.elites!.tanks + (old.elites!.forces[key] ?? 0));
}
assert.equal(game.discoveries!.tokens.filter(token => token.status === 'placed').length, 1);
assert.ok(gameTerritories(game).every(t => t.id !== 'shrine'));
nativeTypedCustody(game);
}
});
+158
View File
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type DiscoveryNativeMarkerStep = { actor: string; action: Action };
export type DiscoveryNativeMarkerFixture = {
initial: Game;
setup: Game;
game: Game;
actions: DiscoveryNativeMarkerStep[];
staging: string[];
collector: string;
owner: string;
token: string;
parent: string;
parentSector: number;
face: DiscoveryTokenFace;
};
/** Use the existing native no-spend policy, with explicit physical cache choices. */
export function nextDiscoveryNativeMarkerStep(game: Game): DiscoveryNativeMarkerStep {
if (!game.response && !game.phaseOpening && game.decision) {
const decision = game.decision, actor = decision.player, event = game.richeseBidding?.event;
if (decision.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } };
if (decision.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } };
if (decision.kind === 'richeseCache') return { actor, action: {
type: 'decision', event, card: game.richeseCache![0].id, method: 'onceAround', direction: 'counterclockwise',
} };
if (decision.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: false } };
if (decision.kind === 'discoveryEntry') return { actor, action: { type: 'decision', event: decision.event, accept: false } };
}
if (!game.response && !game.phaseOpening && !game.decision && game.richeseAuction && !game.richeseAuction.outcome) {
const lot = game.richeseAuction;
const actor = lot.method === 'silent'
? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id))
: lot.active;
assert.ok(actor);
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
}
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return next;
}
export function advanceDiscoveryNativeMarker(
state: Game,
until: (game: Game) => boolean,
actions?: DiscoveryNativeMarkerStep[],
): Game {
let game = state;
for (let n = 0; !until(game) && n < 1600; n++) {
assert.equal(game.status === 'finished', false);
const next = nextDiscoveryNativeMarkerStep(game);
actions?.push(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(until(game), 'Native Discovery marker policy did not reach its requested original window.');
return game;
}
export const discoveryNativeMarkerClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision;
/** Control only the actual placement lottery, never token identities or custody. */
function withPlacement<T>(game: Game, face: DiscoveryTokenFace, operation: () => T): T {
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
const selected = eligible.findIndex(token => token.face === face);
assert.ok(selected >= 0);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = Math.floor((selected + 0.5) / eligible.length * 0x100000000);
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Fresh E2 admission, original setup/Storm and phase producers. The only staging
* is the order of four original unplayed Spice Cards and the placement lottery.
* No clock, wallet, force, marker, Discovery receipt or card is manufactured.
* Stops at Collection BEFORE inspection/revelation for an authenticated recipe. */
export function createDiscoveryNativeMarkerFixture(options: {
initial?: Game;
advanced?: boolean;
tech?: boolean;
face?: DiscoveryTokenFace;
collector?: 'choam' | 'guild';
} = {}): DiscoveryNativeMarkerFixture {
const face = options.face ?? 'cistern';
let game = options.initial ? structuredClone(options.initial)
: createGame('DISCOVERYE2MARKER', newPlayer('richese', 'Richese', 'richese'), options.advanced ?? false, ['choam']);
if (!options.initial) {
joinGame(game, newPlayer('choam', 'CHOAM', 'choam'));
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
}
assert.equal(game.status, 'lobby');
assert.deepEqual(game.expansions, ['choam']);
assert.ok(!game.homeworlds && !game.leaderSkills && !game.nexusCards && !game.strongholdCards);
const owner = game.players.find(player => player.faction === 'richese')!.id;
const collector = game.players.find(player => player.faction === (options.collector ?? 'guild'))!.id;
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
game.discoveryEnabled = true;
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
const initial = structuredClone(game), actions: DiscoveryNativeMarkerStep[] = [];
game = initializeDiscoveryGameForAudit(game);
const setup = structuredClone(game);
game = advanceDiscoveryNativeMarker(game, g => g.status === 'playing', actions);
const printed = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed);
assert.ok(index >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
// Keep both real turns' remaining draws ordinary; never mint or replay a card.
for (let position = 1; position < 4; position++) {
const ordinary = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
assert.ok(ordinary >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
}
const staging = ['Reordered four original unplayed Spice Cards; selected the actual Discovery placement lottery.'];
game = advanceDiscoveryNativeMarker(game, g => g.phase === 1 && discoveryNativeMarkerClean(g), actions);
game = withPlacement(game, face, () => advanceDiscoveryNativeMarker(game,
g => g.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions));
game = advanceDiscoveryNativeMarker(game, g => g.phase === 5 && discoveryNativeMarkerClean(g), actions);
const token = game.discoveries!.tokens.find(candidate => candidate.face === face)!;
assert.equal(token.status, 'placed');
const placement = DISCOVERY_CARD_PLACEMENTS[printed];
assert.equal(token.territory, placement.territory);
game = advanceDiscoveryNativeMarker(game, g => g.phase === 5 && g.active === collector && discoveryNativeMarkerClean(g), actions);
const shipment: DiscoveryNativeMarkerStep = { actor: collector, action: {
type: 'ship', territory: placement.territory, sector: placement.sector, amount: 1, elite: 0, allyPayment: 0,
} };
actions.push(shipment);
game = applyAction(game, shipment.actor, shipment.action);
game = advanceDiscoveryNativeMarker(game, g => g.phase === 7 && discoveryNativeMarkerClean(g), actions);
assert.equal(game.turn, 1);
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
return { initial, setup, game, actions, staging, owner, collector, token: token.id, parent: placement.territory, parentSector: placement.sector, face };
}
export function revealDiscoveryNativeMarker(fixture: DiscoveryNativeMarkerFixture): Game {
const game = applyAction(fixture.game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: false });
return applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: true });
}
export function discoveryNativeMarkerShipmentWindow(fixture: DiscoveryNativeMarkerFixture): Game {
return advanceDiscoveryNativeMarker(revealDiscoveryNativeMarker(fixture), game =>
game.turn === 2 && game.phase === 5 && game.active === fixture.owner && discoveryNativeMarkerClean(game));
}
export function settleDiscoveryNativeMarkerShipment(state: Game): Game {
return advanceDiscoveryNativeMarker(state, game => !game.pendingShipment && discoveryNativeMarkerClean(game));
}
+237
View File
@@ -0,0 +1,237 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, gameTerritories, splitLocation } from '../game/board';
import { spiceDeck, treacheryDeck, type Card } from '../game/cards';
import { traitorDeck } from '../game/traitors';
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState, type DiscoveryTokenFace } from '../game/discoveries';
import { nextSkillsTechBattleStep, openSkillsTechBattle } from './fixture-skills-tech-battle';
import { putDiscovery } from './fixture-discovery';
export type NativeTypedOptions = { advanced?: boolean; tech?: boolean; advisors?: boolean };
export type NativeTypedRevealPosition = { game: Game; owner: string; token: string };
export const nativeTypedReload = (game: Game): Game => JSON.parse(JSON.stringify(game));
export function nativeTypedPlayer(game: Game, id: string): Player {
const player = game.players.find(p => p.id === id);
assert.ok(player);
return player;
}
export function nativeTypedStep(game: Game): Game {
const next = game.decision?.kind === 'discoveryEntry'
? { actor: game.decision.player, action: { type: 'decision', accept: false, event: game.decision.event } }
: game.decision?.kind === 'ixSubstitution'
? { actor: game.decision.player, action: { type: 'decision', decline: true } }
: nextSkillsTechBattleStep(game);
return applyAction(game, next.actor, next.action);
}
/** Original controls only: do not assign phases, turns, wallets or pending frames. */
export function nativeTypedPhase(state: Game, phase: number, turn = state.turn): Game {
let game = state;
for (let step = 0; step < 1200 && (game.turn !== turn || game.phase !== phase ||
game.phaseOpening || game.response || game.decision); step++) {
assert.equal(game.status, 'playing');
assert.ok(game.turn <= turn);
game = nativeTypedStep(game);
}
assert.equal(game.turn, turn);
assert.equal(game.phase, phase);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
return game;
}
export function nativeTypedCustody(game: Game): void {
assert.deepEqual([...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
...game.players.flatMap(p => p.hand)].map(c => c.id).sort(),
treacheryDeck(['ix']).map(c => c.id).sort());
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p => [
...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader),
])].sort(), traitorDeck(game.players, true).sort());
for (const p of game.players) {
const board = Object.values(p.forces).reduce((sum, n) => sum + n, 0);
assert.equal(p.reserves + p.tanks + board, 20);
assert.ok(p.reserves >= (p.elites?.reserves ?? 0));
assert.ok(p.tanks >= (p.elites?.tanks ?? 0));
for (const [key, total] of Object.entries(p.forces)) {
assert.ok(Number.isSafeInteger(total) && total >= 0);
assert.ok(total >= (p.elites?.forces[key] ?? 0));
}
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5);
for (const [key, elite] of Object.entries(p.elites.forces))
assert.ok(Number.isSafeInteger(elite) && elite >= 0 && elite <= (p.forces[key] ?? 0));
}
}
validateDiscoveryState(game.discoveries!);
}
/** Labelled conserved position: physical reserves -> board, including typed subsets. */
export function nativeTypedPlace(game: Game, id: string, key: string, normal: number, elite = 0): void {
const player = nativeTypedPlayer(game, id);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= normal);
assert.ok((player.elites?.reserves ?? 0) >= elite);
player.reserves -= normal + elite;
player.forces[key] = (player.forces[key] ?? 0) + normal + elite;
if (elite) {
player.elites!.reserves -= elite;
player.elites!.forces[key] = (player.elites!.forces[key] ?? 0) + elite;
}
}
/** Keep the actual native HMS garrison; return other original board pieces to reserves. */
export function nativeTypedClearBoard(game: Game): void {
for (const p of game.players) {
for (const [key, total] of Object.entries(p.forces)) {
if (key === MOBILE_LOCATION) continue;
p.reserves += total;
delete p.forces[key];
if (p.elites) {
p.elites.reserves += p.elites.forces[key] ?? 0;
delete p.elites.forces[key];
}
}
if (p.advisors) p.advisors = {};
}
}
export function nativeTypedSetup(options: NativeTypedOptions = {}) {
let lobby = createGame('DISCOVERYNATIVETYPED', newPlayer('i', 'Ixians', 'ixians'),
options.advanced ?? false, ['ix']);
for (const [id, faction] of [['t', 'tleilaxu'], ['f', 'fremen'], ['g', 'guild']] as const)
joinGame(lobby, newPlayer(id, faction, faction));
if (options.advisors) joinGame(lobby, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
if (options.tech) lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true });
for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' });
lobby.discoveryEnabled = true;
let game = initializeDiscoveryGameForAudit(lobby);
const initialized = nativeTypedReload(game);
assert.equal(game.deck.length, 47);
assert.equal(game.spiceDeck.length, spiceDeck(true).length + DISCOVERY_SPICE_CARDS.length + 1);
assert.equal(game.spiceDeck.filter(c => 'sandtrout' in c && c.sandtrout).length, 1);
assert.equal(game.discoveries!.tokens.length, 8);
for (let step = 0; step < 250 && game.status === 'setup'; step++) game = nativeTypedStep(game);
assert.equal(game.status, 'playing');
assert.equal(nativeTypedPlayer(game, 'i').forces[MOBILE_LOCATION], 6);
assert.equal(nativeTypedPlayer(game, 'i').elites!.forces[MOBILE_LOCATION], 3);
assert.equal(nativeTypedPlayer(game, 't').faceDancers!.length, 3);
// Conserved unplayed deck order keeps the first two turns free of unrelated worms/Discoveries.
const blows = game.spiceDeck.filter(c => 'territory' in c && !c.discovery);
assert.ok(blows.length >= 4);
for (const [position, card] of blows.slice(0, 4).entries()) {
const index = game.spiceDeck.indexOf(card);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
nativeTypedCustody(game);
return { lobby, initialized, game };
}
/** Original setup/phase controls, then explicitly labelled supply-token placement
* and reserve-counter position. Collection inspect/reveal is always the real engine producer. */
export function nativeTypedCollection(face: DiscoveryTokenFace = 'shrine', owner = 'i',
options: NativeTypedOptions = {}) {
const setup = nativeTypedSetup(options);
let game = nativeTypedPhase(setup.game, 5);
nativeTypedClearBoard(game);
const token = putDiscovery(game, face);
const source = `${token.territory}:${token.sector}`;
assert.notEqual(token.sector, game.storm);
nativeTypedPlace(game, owner, source, 2, owner === 'i' || (owner === 'f' && game.advanced) ? 1 : 0);
game = nativeTypedPhase(game, 7);
if (viewGame(game, owner).discoveries!.canInspect.includes(token.id))
game = applyAction(game, owner, { type: 'discovery', token: token.id, reveal: false });
assert.ok(viewGame(game, owner).discoveries!.canReveal.includes(token.id));
nativeTypedCustody(game);
return { ...setup, game, token: token.id, source, parent: token.territory!, owner };
}
export function nativeTypedReveal(state: NativeTypedRevealPosition): Game {
return applyAction(state.game, state.owner, { type: 'discovery', token: state.token, reveal: true });
}
export function nativeTypedEntry(options: NativeTypedOptions = {}, owner = 'i') {
const position = nativeTypedCollection('shrine', owner, options);
let game = nativeTypedReveal(position);
game = nativeTypedPhase(game, 8);
for (let step = 0; step < 100 && game.decision?.kind !== 'discoveryEntry'; step++) game = nativeTypedStep(game);
assert.equal(game.turn, 2);
assert.equal(game.phase, 0);
assert.equal(game.decision?.kind, 'discoveryEntry');
assert.equal(game.decision?.player, owner);
nativeTypedCustody(game);
return { ...position, game };
}
export function nativeTypedCard(game: Game, id: string, predicate: (card: Card) => boolean): Card {
const index = game.deck.findIndex(predicate);
assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0];
nativeTypedPlayer(game, id).hand.push(card);
return card;
}
/** Exchange a genuine private Traitor identity with one genuine native Face Dancer. */
export function nativeTypedMatchDancer(game: Game, leader: string): void {
const dancers = nativeTypedPlayer(game, 't').faceDancers!;
if (dancers.some(c => c.leader === leader && !c.revealed)) return;
const dancer = dancers.find(c => !c.revealed)!;
assert.ok(dancer);
const reserve = game.traitorReserve!.indexOf(leader);
if (reserve >= 0) game.traitorReserve![reserve] = dancer.leader;
else {
const holder = game.players.find(p => p.traitors.includes(leader));
assert.ok(holder);
holder.traitors[holder.traitors.indexOf(leader)] = dancer.leader;
}
dancer.leader = leader;
}
/** Native nested-location battle. Winner cleanup and original Tech rewards are
* executed before the original Face Dance choice; no reward/receipt is staged. */
export function nativeTypedFaceDance(advanced = false, tech = false) {
const entry = nativeTypedEntry({ advanced, tech });
assert.ok(entry.game.decision?.kind === 'discoveryEntry');
let game = applyAction(entry.game, 'i', { type: 'decision', accept: false, event: entry.game.decision!.event });
game = nativeTypedPhase(game, 5);
nativeTypedClearBoard(game);
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
nativeTypedPlace(game, 'g', 'shrine:0', 8);
nativeTypedPlace(game, 't', 'shrine:0', 8);
nativeTypedPlace(game, 't', entry.source, 3);
// A second conserved conflict keeps Battle open after this reward/Face Dance.
const other = gameTerritories(game).find(t => t.type === 'sand' &&
t.id !== splitLocation(entry.source).territory && !t.sectors.includes(game.storm));
assert.ok(other);
for (const id of ['g', 't']) nativeTypedPlace(game, id, `${other.id}:${other.sectors[0]}`, 1);
const winnerLeader = nativeTypedPlayer(game, 'g').leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const loserLeader = nativeTypedPlayer(game, 't').leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
nativeTypedMatchDancer(game, winnerLeader.id);
const weapon = nativeTypedCard(game, 'g', c => c.kind === 'projectile');
const defense = nativeTypedCard(game, 'g', c => c.kind === 'shield');
const loserCard = nativeTypedCard(game, 't', c => c.kind === 'worthless');
game = nativeTypedPhase(game, 6);
game = openSkillsTechBattle(game, 'g', 't', 'shrine');
const beforePlans = nativeTypedReload(game);
game = applyAction(game, 'g', { type: 'battlePlan', leader: winnerLeader.id, dial: 0, support: 0,
weapon: weapon.id, defense: defense.id });
game = applyAction(game, 't', { type: 'battlePlan', leader: loserLeader.id, dial: 0, support: 0, weapon: loserCard.id });
let cleanup: Game | undefined;
for (let step = 0; step < 200 && game.decision?.kind !== 'faceDance'; step++) {
if (game.decision?.kind === 'battleCards') {
cleanup = nativeTypedReload(game);
game = applyAction(game, 'g', { type: 'decision', discard: [defense.id] });
} else game = nativeTypedStep(game);
}
assert.ok(cleanup);
assert.equal(game.decision?.kind, 'faceDance');
assert.equal(game.decision?.territory, 'shrine');
assert.equal(game.decision?.player, 't');
nativeTypedCustody(game);
return { game, beforePlans, cleanup, winnerLeader: winnerLeader.id, loserLeader: loserLeader.id,
weapon, defense, loserCard, source: entry.source, action: {
type: 'decision', reveal: true, sources: { reserves: 1, [entry.source]: 2 }, sector: 0,
} as Action };
}
export function nativeTypedMovementTurn(state: Game, owner: string): Game {
let game = state;
for (let step = 0; step < 100 && (game.active !== owner || game.decision || game.response || game.phaseOpening); step++) {
assert.equal(game.phase, 5);
game = nativeTypedStep(game);
}
assert.equal(game.active, owner);
assert.equal(game.phase, 5);
return game;
}
export { MOBILE_LOCATION, MOBILE_STRONGHOLD };