Resolve concealed No-Field losses in Discovery blows

This commit is contained in:
Quintine committed 2026-10-27 13:00:03 +11:00
1 parent 933a9c1379
commit 11e50e7f49
14 files changed
+587 -10

No files matched your search

@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { DISCOVERY_SPICE_CARDS, DISCOVERY_TOKENS } from '../game/discoveries';
import { actDiscoveryNoFieldBlow as act, createDiscoveryNoFieldBlowFixture as fixture,
discoveryNoFieldBlowInventory as inventory, discoveryNoFieldBlowPlayer as p,
discoveryNoFieldBlowReload as reload, finishDiscoveryNoFieldBlowPhase as finish } from './fixture-discovery-no-field-blow';
function assertBlow(f: ReturnType<typeof fixture>, game: ReturnType<typeof reload>) {
const before = p(f.game, f.owner), after = p(game, f.owner), amount = Math.min(f.value, before.reserves);
assert.equal(game.phase, 1);
assert.equal(game.spiceWindow?.territory, f.target); assert.equal(game.spiceWindow?.amount, f.printed.amount);
assert.equal(after.noField!.deployed, null); assert.notEqual(after.noFieldEvent, f.event);
assert.deepEqual(after.noField!.tokens, f.marker!.tokens); assert.equal(after.noField!.lastShipped, f.token);
assert.equal(after.reserves, before.reserves - amount);
assert.equal(after.tanks, before.tanks + amount + Number(f.companions));
assert.equal(Object.entries(after.forces).some(([key, count]) => count > 0 && key.startsWith(`${f.target}:`)), false);
assert.equal(game.spice[f.location], f.printed.amount);
const placed = game.discoveries!.tokens.filter(token => token.status === 'placed'); assert.equal(placed.length, 1);
assert.equal(placed[0].type, f.placement.type); assert.equal(placed[0].territory, f.placement.territory);
assert.equal(placed[0].sector, f.placement.sector); assert.equal(placed[0].revealedTurn, null);
assert.equal(p(game, f.other).forces['polar_sink:0'], 1); assert.equal(p(game, f.other).tanks, 0);
assert.deepEqual(p(game, f.observer).forces, p(f.game, f.observer).forces);
assert.deepEqual(inventory(game), f.original);
}
void test('public Basic and Advanced Discovery sources earn paid marker custody before the original later draw', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
const optIn = f.sourceActions.findIndex(step => step.action.type === 'moduleOption' && step.action.module === 'discoveries');
const firstReady = f.sourceActions.findIndex(step => step.action.type === 'ready');
const start = f.sourceActions.findIndex(step => step.action.type === 'start');
assert.ok(optIn >= 0 && optIn < firstReady && firstReady < start);
assert.equal(f.setup.discoveries!.tokens.length, DISCOVERY_TOKENS.length);
assert.deepEqual(f.setup.spiceDeck.flatMap(card => 'territory' in card && card.discovery ? [card.discovery] : []).sort(),
DISCOVERY_SPICE_CARDS.map(card => card.discovery).sort());
assert.equal(f.sourceCounts.setup, f.setupActions.length); assert.equal(f.sourceCounts.source, f.sourceActions.length);
assert.equal(f.sourceCounts.markerShipment, f.markerShipment.steps.length); assert.equal(f.sourceCounts.readyPrefix, f.readyPrefix.length);
assert.equal(p(f.markerShipment.before, f.owner).spice - p(f.markerShipment.after, f.owner).spice, 2);
assert.equal(p(f.markerShipment.before, f.owner).reserves, p(f.game, f.owner).reserves);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 1); assert.equal(f.game.spiceWindow ?? null, null);
assert.equal(f.drawStep.actor, f.drawer); assert.deepEqual(f.drawStep.action, { type: 'ready' });
assert.equal(f.game.ready.includes(f.drawer), false); assert.equal(p(f.game, f.owner).noFieldEvent, f.event);
assert.deepEqual(inventory(f.game), f.original);
}
});
void test('Basic actual Discovery blow reveals concealed zero without adding counters before new spice and token placement', () => {
const f = fixture({ advanced: false, value: 0 }), game = act(reload(f.game), f.drawer, f.drawAction);
assertBlow(f, game); assert.equal(p(game, f.owner).tanks, 0);
assert.equal(p(game, f.owner).reserves, p(f.game, f.owner).reserves);
assert.ok(game.log.some(entry => entry.text.includes('Discovery spice blow; provisional destructive reveal')));
});
void test('Advanced original Discovery blow materializes concealed three and five then destroys all returned counters once', () => {
for (const value of [3, 5] as const) {
const f = fixture({ value }); let game = act(reload(f.game), f.drawer, f.drawAction); assertBlow(f, game);
const event = p(game, f.owner).noFieldEvent;
game = finish(game); assert.equal(game.phase, 2); assert.equal(p(game, f.owner).tanks, value);
assert.equal(p(game, f.owner).noFieldEvent, event); assert.equal(p(game, f.owner).noField!.deployed, null);
assert.equal(game.discoveries!.tokens.filter(token => token.status === 'placed').length, 1);
assert.deepEqual(inventory(game), f.original);
}
});
void test('the genuine earlier physical companion dies with its later five marker while the separate Polar force survives', () => {
const f = fixture({ value: 5, companions: true }); assert.ok(f.physicalShipment);
assert.equal(f.physicalShipment.before.turn, 1); assert.equal(f.markerShipment.before.turn, 2); assert.equal(f.game.turn, 3);
assert.equal(p(f.physicalShipment.before, f.owner).spice - p(f.physicalShipment.after, f.owner).spice, 2);
assert.equal(p(f.game, f.owner).forces[f.location], 1); assert.equal(p(f.game, f.other).forces['polar_sink:0'], 1);
const game = act(reload(f.game), f.drawer, f.drawAction); assertBlow(f, game);
assert.equal(p(game, f.owner).tanks, 6); assert.equal(p(game, f.owner).reserves, p(f.game, f.owner).reserves - 5);
assert.equal(game.spice[f.location], 6); assert.equal(p(game, f.other).forces['polar_sink:0'], 1);
});
void test('an actual voluntary reveal before the same draw leaves ordinary counters for the blow without another reveal', () => {
const f = fixture({ value: 3 });
let game = act(reload(f.game), f.owner, { type: 'revealNoField', event: f.event, token: f.token });
const event = p(game, f.owner).noFieldEvent;
assert.notEqual(event, f.event); assert.equal(p(game, f.owner).forces[f.location], 3);
assert.equal(p(game, f.owner).tanks, 0); assert.equal(game.spiceWindow ?? null, null);
game = act(game, f.drawer, f.drawAction);
assert.equal(p(game, f.owner).noFieldEvent, event); assert.equal(p(game, f.owner).tanks, 3);
assert.equal(p(game, f.owner).forces[f.location] ?? 0, 0); assert.equal(game.spice[f.location], 6);
assert.equal(game.discoveries!.tokens.filter(token => token.status === 'placed').length, 1);
assert.deepEqual(inventory(game), f.original);
});
void test('all four minimal policies execute the actual pending first-reveal Ready and continue the same blow legally', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced, value: advanced ? 3 : 0 });
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
const view = viewGame(f.game, f.drawer); view.players.find(player => player.id === f.drawer)!.bot = difficulty;
const ready = botActions(view).find(action => action.type === 'ready'); assert.ok(ready);
let game = act(reload(f.game), f.drawer, ready); assertBlow(f, game);
game = finish(game); assert.equal(game.phase, 2); assert.equal(p(game, f.owner).tanks, f.value);
assert.equal(p(game, f.owner).noField!.deployed, null); assert.equal(p(game, f.other).forces['polar_sink:0'], 1);
assert.deepEqual(inventory(game), f.original);
}
}
});
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer } from '../game/engine';
import { DISCOVERY_SPICE_CARDS } from '../game/discoveries';
import { territory } from '../game/board';
import {
DisasterPreflightError, validateDiscoveryBlow, validateWormDevouring,
} from '../game/disaster-preflight';
import {
createRicheseNoField, deployRicheseNoField, type NoFieldLocation, type NoFieldValue,
} from '../game/richese-no-field';
const printed = DISCOVERY_SPICE_CARDS.find(card => card.territory === 'hagga_basin')!;
const sourceKey = `${printed.territory}:${printed.sector}`;
/** Controlled helper inputs, not a native draw/deployment chronology. The
* marker, armies and reserve/Tanks populations are assigned explicitly so the
* preflight's effect-specific casualty validation can be tested in isolation. */
function fixture({ value = 5, reserves = 20, location = {
territory: printed.territory, sector: printed.sector,
} }: { value?: NoFieldValue; reserves?: number; location?: NoFieldLocation } = {}) {
const game = createGame('DISCOVERYBLOWPREFLIGHT', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
game.players.push(newPlayer('f', 'Fremen', 'fremen'), newPlayer('e', 'Emperor', 'emperor'));
Object.assign(game, { status: 'playing', phase: 1, turn: 2 });
const richese = game.players.find(player => player.id === 'r')!;
const fremen = game.players.find(player => player.id === 'f')!;
const emperor = game.players.find(player => player.id === 'e')!;
const stock = createRicheseNoField(['controlled-zero', 'controlled-three', 'controlled-five']);
const token = stock.tokens.find(candidate => candidate.value === value)!;
richese.noField = deployRicheseNoField(stock, { tokenId: token.id, controller: richese.id, location });
richese.noFieldEvent = 'controlled-before-blow-event';
richese.reserves = reserves;
richese.tanks = 20 - reserves;
return { game, richese, fremen, emperor };
}
void test('Discovery preflight accepts zero three and five markers with scarce or empty reserves without revealing or mutating', () => {
for (const [value, reserves] of [[0, 0], [0, 20], [3, 0], [3, 2], [5, 2], [5, 20]] as const) {
const { game } = fixture({ value, reserves }), before = structuredClone(game);
assert.doesNotThrow(() => validateDiscoveryBlow(game, printed.territory));
assert.deepEqual(game, before);
}
});
void test('Discovery casualty capacity uses the reserve-capped reveal and never counts a zero marker as one counter', () => {
// Numeric capacity-boundary supplements, deliberately not physical game
// populations: the existing guard rejects an unsafe post-loss Tanks total.
const capped = fixture({ value: 5, reserves: 2 });
capped.richese.tanks = Number.MAX_SAFE_INTEGER - 2;
const before = structuredClone(capped.game);
assert.doesNotThrow(() => validateDiscoveryBlow(capped.game, printed.territory));
assert.deepEqual(capped.game, before);
capped.richese.tanks++;
const invalid = structuredClone(capped.game);
assert.throws(() => validateDiscoveryBlow(capped.game, printed.territory), /tank capacity/);
assert.deepEqual(capped.game, invalid);
const zero = fixture({ value: 0, reserves: 20 });
zero.richese.tanks = Number.MAX_SAFE_INTEGER;
assert.doesNotThrow(() => validateDiscoveryBlow(zero.game, printed.territory));
});
void test('mixed same-territory physical and elite groups validate every sector before accepting Discovery casualties', () => {
const { game, richese, fremen, emperor } = fixture({ value: 3, reserves: 2 });
const anotherSector = territory(printed.territory).sectors.find(sector => sector !== printed.sector);
assert.notEqual(anotherSector, undefined);
const otherKey = `${printed.territory}:${anotherSector}`;
richese.forces = { [sourceKey]: 2 }; richese.tanks = 16;
fremen.forces = { [sourceKey]: 2, [otherKey]: 1 }; fremen.reserves = 17;
fremen.elites = { reserves: 2, tanks: 0, forces: { [otherKey]: 1 }, revived: 0 };
emperor.forces = { [otherKey]: 3 }; emperor.reserves = 17;
emperor.elites = { reserves: 1, tanks: 0, forces: { [otherKey]: 2 }, revived: 0 };
const before = structuredClone(game);
assert.doesNotThrow(() => validateDiscoveryBlow(game, printed.territory));
assert.deepEqual(game, before);
const tooManyElites = structuredClone(game);
tooManyElites.players.find(player => player.id === emperor.id)!.elites!.forces[otherKey] = 4;
assert.throws(() => validateDiscoveryBlow(tooManyElites, printed.territory), /ordinary and elite forces/);
const eliteOnly = structuredClone(game);
delete eliteOnly.players.find(player => player.id === emperor.id)!.forces[otherKey];
assert.throws(() => validateDiscoveryBlow(eliteOnly, printed.territory), /ordinary and elite forces/);
});
void test('an unrelated or explicitly nested Discovery marker stays outside the parent blow casualty set', () => {
for (const location of [
{ territory: 'imperial_basin', sector: 10 },
{ territory: 'cistern', sector: 0 },
]) {
const { game, richese } = fixture({ value: 5, reserves: 0, location });
// This explicit nested ID remains a separate location. It is not the
// printed parent territory even if that token is placed inside the parent.
const before = structuredClone(game);
assert.doesNotThrow(() => validateDiscoveryBlow(game, printed.territory));
assert.deepEqual(game, before);
assert.deepEqual(richese.noField?.deployed?.location, location);
}
});
void test('Discovery checks Fremen and protected-allied casualties that an ordinary worm may skip', () => {
for (const actor of ['f', 'e']) {
const { game, fremen, emperor } = fixture();
fremen.ally = emperor.id; emperor.ally = fremen.id;
game.players.find(player => player.id === actor)!.forces[sourceKey] = 0.5;
assert.doesNotThrow(() => validateWormDevouring(game, printed.territory, actor === 'e' ? emperor.id : undefined));
const before = structuredClone(game);
assert.throws(() => validateDiscoveryBlow(game, printed.territory), /ordinary and elite forces/);
assert.deepEqual(game, before);
}
});
void test('only the six printed Discovery blow territories are accepted as targets', () => {
const { game } = fixture();
for (const card of DISCOVERY_SPICE_CARDS)
assert.doesNotThrow(() => validateDiscoveryBlow(game, card.territory));
const before = structuredClone(game);
for (const target of ['arrakeen', 'imperial_basin', 'polar_sink', 'cistern', 'unknown-territory'])
assert.throws(() => validateDiscoveryBlow(game, target), DisasterPreflightError);
assert.deepEqual(game, before);
});
+171
View File
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import type { NoFieldValue } from '../game/richese-no-field';
import { actFamilyAtomicsNoField, familyAtomicsNoFieldPlayer, familyAtomicsNoFieldReload,
familyAtomicsNoFieldInventory, familyAtomicsNoFieldClean, nextFamilyAtomicsNoFieldStep,
type FamilyAtomicsNoFieldStep } from './fixture-family-atomics-no-field';
export type DiscoveryNoFieldBlowStep = FamilyAtomicsNoFieldStep;
export const actDiscoveryNoFieldBlow = actFamilyAtomicsNoField;
export const discoveryNoFieldBlowPlayer = familyAtomicsNoFieldPlayer;
export const discoveryNoFieldBlowReload = familyAtomicsNoFieldReload;
export const discoveryNoFieldBlowClean = familyAtomicsNoFieldClean;
export function discoveryNoFieldBlowInventory(game: Game) {
const original = familyAtomicsNoFieldInventory(game); validateDiscoveryState(game.discoveries!); return original;
}
export function nextDiscoveryNoFieldBlow(game: Game): DiscoveryNoFieldBlowStep | null {
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const d = game.decision;
if (d?.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } };
if (d?.kind === 'richeseAllyOpportunity') return { actor: d.player, action: { type: 'decision', decline: true } };
if (d?.kind === 'discoveryEntry' || d?.kind === 'greatMakerRide')
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
}
// The inherited native phase5 branch explicitly ends the current turn before
// optional policy actions, including newly supported No-Field transport.
return nextFamilyAtomicsNoFieldStep(game);
}
export function advanceDiscoveryNoFieldBlow(game: Game, until: (state: Game) => boolean,
actions: DiscoveryNoFieldBlowStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Discovery chronology ended before its requested boundary');
const next = nextDiscoveryNoFieldBlow(game); assert.ok(next, 'Complete the actual native pending control');
game = actDiscoveryNoFieldBlow(game, next.actor, next.action, actions);
}
throw Error('The original Discovery/No-Field chronology did not reach its finite boundary');
}
export function settleDiscoveryNoFieldBlow(game: Game, actions: DiscoveryNoFieldBlowStep[] = []): Game {
return advanceDiscoveryNoFieldBlow(game, discoveryNoFieldBlowClean, actions);
}
export function finishDiscoveryNoFieldBlowPhase(game: Game, actions: DiscoveryNoFieldBlowStep[] = []): Game {
return advanceDiscoveryNoFieldBlow(game, state => state.phase !== 1 && discoveryNoFieldBlowClean(state), actions);
}
const printed = DISCOVERY_SPICE_CARDS.find(card => card.discovery === 'discovery-wind-pass-north')!;
function selectLand(game: Game, position: number) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
card.territory !== printed.territory && card.sector !== game.storm);
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
}
/** Public opt-in and actual shipments only. Marker-only sources use turn1
* shipment and turn2 original blow; mixed sources first ship physical1, then
* the marker on turn2, and wait for the actual turn3 blow. No audit initializer
* or installed hand, wallet, army, phase, token, event or receipt is used.
*/
export function createDiscoveryNoFieldBlowFixture(options: {
advanced?: boolean; value?: NoFieldValue; companions?: boolean;
} = {}) {
const advanced = options.advanced ?? true, value = options.value ?? 0, companions = options.companions ?? false;
const owner = 'richese', other = 'emperor', observer = 'atreides';
let game = createGame('DISCOVERYNFBLOW', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer(observer, 'Atreides', 'atreides')); joinGame(game, newPlayer(other, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: DiscoveryNoFieldBlowStep[] = [];
const act = (actor: string, action: Action) => { game = actDiscoveryNoFieldBlow(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceDiscoveryNoFieldBlow(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length));
const ordinary = ['projectile', 'shield', 'snooper'].map(kind => game.deck.find(card => card.kind === kind));
const dealOrder = ordinary.map((card, position) => {
assert.ok(card); const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
assert.equal(game.discoveryEnabled, true); assert.ok(game.discoveries);
assert.deepEqual(discoveryNoFieldBlowPlayer(game, owner).forces, {});
const original = discoveryNoFieldBlowInventory(game), originalDiscovery = structuredClone(game.discoveries);
const firstSpiceOrder = [selectLand(game, 0), ...(advanced ? [selectLand(game, 1)] : [])];
reach(state => state.turn === 1 && state.phase === 5 && discoveryNoFieldBlowClean(state));
let markerShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null;
let physicalShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null;
let otherShipment: { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game } | null = null;
let token = '';
const shipMarker = () => {
const own = discoveryNoFieldBlowPlayer(game, owner), selected = own.noField!.tokens.find(token => token.value === value);
assert.ok(selected && own.noFieldEvent); token = selected.id;
markerShipment = transition(() => {
act(owner, { type: 'ship', noField: token, event: own.noFieldEvent, territory: printed.territory, sector: printed.sector });
reach(discoveryNoFieldBlowClean);
});
assert.equal(discoveryNoFieldBlowPlayer(markerShipment.before, owner).spice - discoveryNoFieldBlowPlayer(game, owner).spice, 2);
assert.equal(discoveryNoFieldBlowPlayer(markerShipment.before, owner).reserves, discoveryNoFieldBlowPlayer(game, owner).reserves);
};
for (let count = 0; game.phase === 5 && count < 80; count++) {
if (discoveryNoFieldBlowClean(game) && game.active === owner && !discoveryNoFieldBlowPlayer(game, owner).shipped) {
if (companions) physicalShipment = transition(() => {
act(owner, { type: 'ship', territory: printed.territory, sector: printed.sector, amount: 1, elite: 0, allyPayment: 0 });
reach(discoveryNoFieldBlowClean);
});
else shipMarker();
continue;
}
if (discoveryNoFieldBlowClean(game) && game.active === other && !discoveryNoFieldBlowPlayer(game, other).shipped) {
otherShipment = transition(() => {
act(other, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 });
reach(discoveryNoFieldBlowClean);
});
continue;
}
const next = nextDiscoveryNoFieldBlow(game); assert.ok(next); act(next.actor, next.action);
}
assert.ok(otherShipment); assert.ok(!companions || physicalShipment);
reach(state => state.turn === 2 && state.phase === 0 && discoveryNoFieldBlowClean(state));
let secondSpiceOrder: ReturnType<typeof selectLand>[] = [];
if (companions) {
secondSpiceOrder = [selectLand(game, 0), ...(advanced ? [selectLand(game, 1)] : [])];
reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && discoveryNoFieldBlowClean(state));
shipMarker();
reach(state => state.turn === 3 && state.phase === 0 && discoveryNoFieldBlowClean(state));
}
assert.ok(markerShipment);
const shipment = markerShipment as { before: Game; steps: DiscoveryNoFieldBlowStep[]; after: Game };
const beforeTargetStorm = structuredClone(game);
const from = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed.discovery); assert.ok(from >= 0);
const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.unshift(card);
const blowSelection = { position: 0, from, card: structuredClone(card) };
const followingSpiceOrder = advanced ? [selectLand(game, 1)] : [];
const turn = companions ? 3 : 2;
reach(state => state.turn === turn && state.phase === 1 && discoveryNoFieldBlowClean(state) &&
!state.spiceWindow && !state.spiceSequence && !state.spiceResolution);
const opening = structuredClone(game), readyFrom = actions.length;
assert.equal(game.ready.length, 0);
for (const player of game.players.slice(0, -1)) act(player.id, { type: 'ready' });
const readyPrefix = structuredClone(actions.slice(readyFrom));
const drawer = game.players.at(-1)!.id, drawAction: Action = { type: 'ready' };
assert.equal(game.spiceWindow ?? null, null); assert.equal(game.thumperWindowOpen, true);
assert.deepEqual(game.spiceDeck[0], printed);
assert.equal(game.ready.length, game.players.length - 1); assert.equal(game.ready.includes(drawer), false);
const own = discoveryNoFieldBlowPlayer(game, owner), event = own.noFieldEvent; assert.ok(event);
assert.equal(own.noField!.deployed!.tokenId, token);
assert.equal(event, discoveryNoFieldBlowPlayer(shipment.after, owner).noFieldEvent);
assert.equal(own.forces[`${printed.territory}:${printed.sector}`] ?? 0, companions ? 1 : 0);
assert.equal(own.tanks, 0); assert.equal(discoveryNoFieldBlowPlayer(game, other).forces['polar_sink:0'], 1);
assert.deepEqual(discoveryNoFieldBlowInventory(game), original); assert.deepEqual(game.discoveries, originalDiscovery);
return { game, beforeBlow: structuredClone(game), opening, initial, lobby, setup, afterSetup,
advanced, value, companions, owner, actor: drawer, drawer, other, observer, token, event,
marker: structuredClone(own.noField), printed, target: printed.territory, location: `${printed.territory}:${printed.sector}`,
placement: DISCOVERY_CARD_PLACEMENTS[printed.discovery], drawAction, drawStep: { actor: drawer, action: drawAction },
markerShipment: shipment, physicalShipment, otherShipment, beforeTargetStorm, original, originalDiscovery,
dealOrder, firstSpiceOrder, secondSpiceOrder, blowSelection, followingSpiceOrder, readyPrefix,
setupActions, actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { setup: setupActions.length, markerShipment: shipment.steps.length,
physicalShipment: physicalShipment?.steps.length ?? 0, otherShipment: otherShipment.steps.length,
readyPrefix: readyPrefix.length, source: actions.length },
sourceDisclosure: `Actual public ${advanced ? 'Advanced preview' : 'Basic'} Richese/Atreides/Emperor +CHOAM deck, public Discovery moduleOption before readiness/start, no audit initializer or other optional module. ` +
'Only original unopened Treachery/Spice positions and scalar lotteries are selected. Richese pays two spice for its original concealed0/3/5 token; physical reserves remain untouched. Emperor separately pays2 for one original Polar counter. ' +
(companions ? 'Richese first actually ships physical1 to WindPassNorth17 on turn1, then its marker there on turn2, and reaches the original turn3 Discovery blow. '
: 'Richese actually ships the marker to WindPassNorth17 on turn1 and reaches the original turn2 Discovery blow. ') +
'Native dials/Storm, quiet auctions, ordered Advanced Charity/Revival and actual turn ends preserve the same deployed event. Two actual Ready controls prime the original first-reveal window; the exposed final Ready performs the printed draw. No hand, purse, force, phase, token, marker event, spice, discovery placement or casualty is installed.' };
}
export type DiscoveryNoFieldBlowFixture = ReturnType<typeof createDiscoveryNoFieldBlowFixture>;
@@ -40,6 +40,9 @@ export const homeworldClassicDiscoveryPlayer = (game: Game, actor: string) => {
/** Public owned continuations only. Storm dials use their original legal range;
* no phase clock, casualty, hand, balance or signed Discovery frame is staged. */
export function nextHomeworldClassicDiscoveryStep(game: Game): HomeworldClassicDiscoveryStep {
if (game.status === 'playing' && game.phase === 2 && homeworldClassicDiscoveryClean(game) &&
game.charityOrder?.remaining.length)
return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } };
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'homeworldShipmentGuild')
+1
View File
@@ -194,6 +194,7 @@ void test('every reveal cause returns capped physical materialization and the ac
'allyShipment',
'atomics',
'alliedSeparation',
'discoveryBlow',
];
for (const cause of causes)
for (const value of [0, 3, 5] as const)