Connect native Discovery setup and nested force paths
This commit is contained in:
1 parent
8d17fab3de
commit
67ce690064
17 files changed
+1379
-39
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user