Resolve concealed No-Field losses in Discovery blows
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user