Connect multi-occupant Cistern and Orgiz Collection
This commit is contained in:
1 parent
554f2bf9e9
commit
388c5fa8d5
12 files changed
+536
-119
No files matched your search
@@ -307,56 +307,7 @@ void test('Orgiz withholds uncertain Ecaz shared theft while collecting an indep
|
||||
assert.deepEqual({ game, ordinary }, before);
|
||||
});
|
||||
|
||||
void test('two allied Orgiz occupants leave ordinary collection playable without uncertain theft', () => {
|
||||
const game = fixture(false, [
|
||||
['e', 'ecaz'], ['a', 'atreides'], ['g', 'guild'],
|
||||
]);
|
||||
game.discoveryEnabled = true;
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].ally = 'a';
|
||||
game.players[1].ally = 'e';
|
||||
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.players[1].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.players[2].forces = { 'hagga_basin:12': 1 };
|
||||
game.players.forEach(player => {
|
||||
Object.assign(player, {
|
||||
hand: [], traitors: [], traitorChoices: [], shipped: true,
|
||||
moved: 1, reserves: 19,
|
||||
});
|
||||
});
|
||||
game.spice = { 'hagga_basin:12': 2 };
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
assert.deepEqual(quoteDiscoveryCollection(game, ordinary), {
|
||||
receipts: ordinary.receipts, effects: [],
|
||||
});
|
||||
Object.assign(game, {
|
||||
phase: 5, active: 'g', movementRemaining: ['g'], phaseOpening: null,
|
||||
});
|
||||
const collected = applyAction(game, 'g', { type: 'endMovement' });
|
||||
assert.equal(collected.phase, 7);
|
||||
assert.deepEqual(collected.players.map(player => player.spice), [0, 0, 2]);
|
||||
assert.equal(collected.spice['hagga_basin:12'], 0);
|
||||
});
|
||||
|
||||
void test('a contested Cistern retains the unresolved bonus without blocking collection', () => {
|
||||
const game = fixture(false, [
|
||||
['e', 'ecaz'],
|
||||
['a', 'atreides'],
|
||||
['g', 'guild'],
|
||||
]);
|
||||
reveal(game, CISTERN);
|
||||
game.players[0].ally = 'a';
|
||||
game.players[1].ally = 'e';
|
||||
game.players[0].forces = { [`${CISTERN}:0`]: 1 };
|
||||
game.players[1].forces = { [`${CISTERN}:0`]: 1 };
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
const before = structuredClone({ game, ordinary });
|
||||
assert.deepEqual(quoteDiscoveryCollection(game, ordinary), {
|
||||
receipts: ordinary.receipts,
|
||||
effects: [],
|
||||
});
|
||||
assert.deepEqual({ game, ordinary }, before);
|
||||
});
|
||||
|
||||
void test('the real collection phase credits Cistern once and preserves the receipt through JSON recovery', () => {
|
||||
const game = fixture(false);
|
||||
@@ -394,14 +345,6 @@ void test('the real collection phase credits Cistern once and preserves the rece
|
||||
assert.equal(collected.phase, 7);
|
||||
assert.equal(collected.players.find((player) => player.id === 'a')!.spice, 8);
|
||||
assert.equal(collected.spice['red_chasm:7'], 0);
|
||||
assert.equal(
|
||||
collected.log.filter((entry) =>
|
||||
entry.text.includes(
|
||||
'received 2 spice from the bank for occupying Cistern',
|
||||
),
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
|
||||
collected = JSON.parse(JSON.stringify(collected)) as Game;
|
||||
assert.deepEqual(normalizeAutomaticGame(collected), collected);
|
||||
@@ -654,3 +597,39 @@ void test('Discovery Collection requires an explicit mode rather than treating m
|
||||
missingMode as unknown as DiscoveryCollectionContext, ordinary,
|
||||
), DiscoveryCollectionError);
|
||||
});
|
||||
|
||||
void test('each Cistern faction earns two, while a lone advisor earns none', () => {
|
||||
const game=fixture(true,[['a','atreides'],['b','beneGesserit'],['g','guild']]);
|
||||
reveal(game,CISTERN);
|
||||
game.players[0].forces={[`${CISTERN}:0`]:1};
|
||||
game.players[1].forces={[`${CISTERN}:0`]:1};
|
||||
game.players[1].advisors={[CISTERN]:{}};
|
||||
game.players[2].forces={[`${CISTERN}:0`]:1};
|
||||
const before=structuredClone(game);
|
||||
const {ordinary,discovery}=composed(game);
|
||||
assert.deepEqual(discovery.effects,[
|
||||
{kind:'cistern',player:'a',amount:2,source:'bank'},
|
||||
{kind:'cistern',player:'g',amount:2,source:'bank'},
|
||||
]);
|
||||
assert.deepEqual(discovery.receipts.map(r=>r.balance),[2,0,2]);
|
||||
assert.deepEqual(ordinary.receipts.map(r=>r.balance),[0,0,0]);
|
||||
assert.deepEqual(game,before);
|
||||
});
|
||||
|
||||
void test('two Orgiz owners resolve available collector spice in original storm order without creating debt', () => {
|
||||
for(const first of ['a','g']) {
|
||||
const game=fixture(true);
|
||||
reveal(game,ORGIZ_PROCESSING_STATION);
|
||||
game.order=first==='a'?['a','h','g']:['g','h','a'];
|
||||
game.players[0].forces={[`${ORGIZ_PROCESSING_STATION}:0`]:1};
|
||||
game.players[2].forces={[`${ORGIZ_PROCESSING_STATION}:0`]:1};
|
||||
game.players[1].forces={'hagga_basin:12':1};
|
||||
game.spice={'hagga_basin:12':1};
|
||||
const {discovery}=composed(game);
|
||||
assert.deepEqual(discovery.effects,[{kind:'orgiz',player:first,from:'h',
|
||||
location:'hagga_basin:12',amount:1,source:'player'}]);
|
||||
assert.equal(balance(discovery,first),1);
|
||||
assert.equal(balance(discovery,first==='a'?'g':'a'),0);
|
||||
assert.equal(balance(discovery,'h'),0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {applyAction,normalizeAutomaticGame,type Game} from '../game/engine';
|
||||
import {CISTERN,ORGIZ_PROCESSING_STATION} from '../game/discovery-collection';
|
||||
import {createOccupiedDiscoveryCollectionFixture} from './fixture-discovery-occupied-collection';
|
||||
|
||||
const wallets=(game:Game)=>Object.fromEntries(game.players.map(p=>[p.id,p.spice]));
|
||||
const stock=(game:Game)=>game.players.map(p=>({id:p.id,
|
||||
total:p.reserves+p.tanks+Object.values(p.forces).reduce((sum,n)=>sum+n,0),
|
||||
elites:p.elites? p.elites.reserves+p.elites.tanks+Object.values(p.elites.forces).reduce((sum,n)=>sum+n,0):0}));
|
||||
|
||||
for(const advanced of [false,true]) {
|
||||
void test(`${advanced?'Advanced':'Basic'} original two-occupant Cistern credits each faction once without payer spending`,()=>{
|
||||
const f=createOccupiedDiscoveryCollectionFixture(CISTERN,advanced);
|
||||
const before=structuredClone(f.game);
|
||||
const done=applyAction(f.game,f.finalAction.actor,f.finalAction.action);
|
||||
const expected=Object.fromEntries(f.original.receipts.map(r=>[r.player,r.balance]));
|
||||
for(const owner of f.owners)expected[owner]+=2;
|
||||
assert.equal(done.phase,7);
|
||||
assert.deepEqual(wallets(done),expected);
|
||||
assert.deepEqual(stock(done),stock(before));
|
||||
assert.ok(stock(done).every(p=>p.total===20));
|
||||
assert.deepEqual(f.game,before);
|
||||
const stable=JSON.parse(JSON.stringify(done)) as Game;
|
||||
assert.deepEqual(normalizeAutomaticGame(stable),stable);
|
||||
assert.throws(()=>applyAction(done,f.finalAction.actor,f.finalAction.action));
|
||||
assert.deepEqual(wallets(done),expected);
|
||||
});
|
||||
|
||||
void test(`${advanced?'Advanced':'Basic'} both original Orgiz occupants receive one transfer from the actual third-party desert collector`,()=>{
|
||||
const f=createOccupiedDiscoveryCollectionFixture(ORGIZ_PROCESSING_STATION,advanced);
|
||||
const before=structuredClone(f.game);
|
||||
const done=applyAction(f.game,f.finalAction.actor,f.finalAction.action);
|
||||
const expected=Object.fromEntries(f.original.receipts.map(r=>[r.player,r.balance]));
|
||||
for(const owner of f.owners)expected[owner]+=1;
|
||||
expected[f.collector!]-=2;
|
||||
assert.equal(done.phase,7);
|
||||
assert.deepEqual(wallets(done),expected);
|
||||
assert.equal(Object.values(wallets(done)).reduce((sum,n)=>sum+n,0),
|
||||
f.original.receipts.reduce((sum,r)=>sum+r.balance,0));
|
||||
assert.deepEqual(stock(done),stock(before));
|
||||
assert.deepEqual(done.spice,f.original.spice);
|
||||
assert.deepEqual(f.game,before);
|
||||
const stable=JSON.parse(JSON.stringify(done)) as Game;
|
||||
assert.deepEqual(normalizeAutomaticGame(stable),stable);
|
||||
assert.throws(()=>applyAction(done,f.finalAction.actor,f.finalAction.action));
|
||||
assert.deepEqual(wallets(done),expected);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { ORGIZ_PROCESSING_STATION } from '../game/discovery-collection';
|
||||
import { prepareSharedOrgizCollection, settleSharedOrgiz } from './fixture-discovery-shared-orgiz';
|
||||
|
||||
const wallets = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice]));
|
||||
const transfers = (game: Game) =>
|
||||
game.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station');
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'an equal split charges the first actual recipient', choice: { kind: 'equal' as const }, payer: 'e' },
|
||||
{ name: 'a full Ecaz share charges Ecaz', choice: { kind: 'propose' as const, ecazShare: 4 }, payer: 'e' },
|
||||
{ name: 'a zero Ecaz share charges the ally instead', choice: { kind: 'propose' as const, ecazShare: 0 }, payer: 'g' },
|
||||
]) {
|
||||
void test(`Advanced shared Collection: ${scenario.name}, never an old wallet or an unavailable share`, () => {
|
||||
const position = prepareSharedOrgizCollection();
|
||||
const lot = position.ordinary.shared[0];
|
||||
assert.equal(lot.territory, 'hagga_basin');
|
||||
assert.equal(lot.ecaz, position.ecaz);
|
||||
assert.equal(lot.ally, position.guild);
|
||||
const base = structuredClone(position.game);
|
||||
const before = wallets(base);
|
||||
const settled = settleSharedOrgiz(position, scenario.choice);
|
||||
assert.deepEqual(position.game, base, 'Settling must not mutate the prepared position.');
|
||||
const receipt = settled.receipts.at(-1)!;
|
||||
assert.equal(receipt.territory, lot.territory);
|
||||
assert.equal(receipt.ecazAmount + receipt.allyAmount, lot.amount);
|
||||
assert.ok(receipt[scenario.payer === 'e' ? 'ecazAmount' : 'allyAmount'] > 0);
|
||||
const expected = { ...before };
|
||||
expected[receipt.ecaz] += receipt.ecazAmount;
|
||||
expected[receipt.ally] += receipt.allyAmount;
|
||||
expected[position.fremen] += 1;
|
||||
expected[scenario.payer] -= 1;
|
||||
assert.deepEqual(wallets(settled.game), expected);
|
||||
// Total spice is conserved by the transfer itself, and the credited total
|
||||
// equals the real collected lot.
|
||||
assert.equal(Object.values(wallets(settled.game)).reduce((sum, amount) => sum + amount, 0),
|
||||
Object.values(before).reduce((sum, amount) => sum + amount, 0) + lot.amount);
|
||||
assert.equal(Object.values(settled.game.spice).reduce((sum, amount) => sum + amount, 0),
|
||||
Object.values(base.spice).reduce((sum, amount) => sum + amount, 0) - lot.amount);
|
||||
const owned = transfers(settled.game);
|
||||
assert.equal(owned.length, 1);
|
||||
assert.equal(owned[0].automatic?.faction, 'fremen');
|
||||
assert.match(owned[0].text, /after the original shared territory allocation/);
|
||||
// The occupied location keeps its real garrison and the fremen wallet grows.
|
||||
assert.equal(settled.game.players.find(player => player.id === position.fremen)!.forces[`${ORGIZ_PROCESSING_STATION}:0`], 1);
|
||||
assert.equal(wallets(settled.game)[position.fremen], before[position.fremen] + 1);
|
||||
const stable = JSON.parse(JSON.stringify(settled.game)) as Game;
|
||||
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(stable))), stable);
|
||||
assert.equal(viewGame(settled.game, position.ecaz).players
|
||||
.find(player => player.id === position.ecaz)!.spice, expected[position.ecaz]);
|
||||
assert.equal(viewGame(settled.game, position.ecaz).players
|
||||
.find(player => player.id === position.guild)!.spice, undefined,
|
||||
'Opposing wallets stay private.');
|
||||
});
|
||||
}
|
||||
|
||||
void test('re-settling the same prepaid position is deterministic and never accumulates', () => {
|
||||
const position = prepareSharedOrgizCollection();
|
||||
const first = settleSharedOrgiz(position, { kind: 'equal' });
|
||||
const second = settleSharedOrgiz(position, { kind: 'equal' });
|
||||
assert.equal(first.steps.length, second.steps.length);
|
||||
assert.deepEqual(wallets(second.game), wallets(first.game));
|
||||
assert.deepEqual(transfers(second.game).length, transfers(first.game).length);
|
||||
assert.deepEqual(wallets(position.game), wallets(position.before));
|
||||
assert.deepEqual(normalizeAutomaticGame(structuredClone(second.game)), second.game);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {applyAction,type Action,type Game} from '../game/engine';
|
||||
import {splitLocation} from '../game/board';
|
||||
import {quoteSpiceCollection} from '../game/board-resolution-quote';
|
||||
import {CISTERN,ORGIZ_PROCESSING_STATION} from '../game/discovery-collection';
|
||||
import {
|
||||
createDiscoveryOccupantsFixture,advanceTestingStationContest,
|
||||
type TestingStationContestStep,
|
||||
} from './fixture-testing-station-contest';
|
||||
|
||||
const clean = (game:Game) => !game.decision && !game.response && !game.phaseOpening;
|
||||
|
||||
/** Original location entry and alliance are shared with the Station proof.
|
||||
* Orgiz adds a real opposing reserve shipment to an actual positive deposit. */
|
||||
export function createOccupiedDiscoveryCollectionFixture(
|
||||
location:typeof CISTERN|typeof ORGIZ_PROCESSING_STATION,
|
||||
advanced=false,
|
||||
) {
|
||||
const fixture=createDiscoveryOccupantsFixture({location,advanced});
|
||||
const actions:TestingStationContestStep[]=[...fixture.actions];
|
||||
let game=fixture.game;
|
||||
let collector:string|null=null,deposit:string|null=null;
|
||||
if(location===ORGIZ_PROCESSING_STATION) {
|
||||
game=advanceTestingStationContest(game,g=>g.phase===5&&g.turn<=4&&g.active!==null&&
|
||||
(g.movementRemaining?.length??0)===1&&clean(g)&&
|
||||
Object.entries(g.spice).some(([key,amount])=>amount>0&&
|
||||
key!==`${ORGIZ_PROCESSING_STATION}:0`),actions);
|
||||
const shipActor=game.active;
|
||||
assert.ok(shipActor,'A real movement window must have an active seat.');
|
||||
const candidates=Object.entries(game.spice)
|
||||
.filter(([key,amount])=>amount>0&&key!==`${ORGIZ_PROCESSING_STATION}:0`)
|
||||
.sort((left,right)=>right[1]-left[1]);
|
||||
assert.ok(candidates.length,'A real blow must supply a positive desert deposit.');
|
||||
let shipped:Game|null=null,shippedDeposit:string|null=null;
|
||||
for(const [key] of candidates) {
|
||||
const {territory,sector}=splitLocation(key);
|
||||
const shipment:Action={type:'ship',territory,sector,amount:2,elite:0,allyPayment:0};
|
||||
try {
|
||||
const next=applyAction(game,shipActor,shipment);
|
||||
actions.push({actor:shipActor,action:shipment});
|
||||
shipped=advanceTestingStationContest(next,clean,actions);
|
||||
shippedDeposit=key;
|
||||
break;
|
||||
} catch { continue; }
|
||||
}
|
||||
assert.ok(shipped&&shippedDeposit,'A real paid shipment must reach one actual positive deposit.');
|
||||
collector=shipActor; deposit=shippedDeposit;
|
||||
game=shipped;
|
||||
assert.equal(game.players.find(p=>p.id===collector)!.forces[deposit],2);
|
||||
}
|
||||
game=advanceTestingStationContest(game,g=>g.phase===5&&g.active!==null&&
|
||||
(g.movementRemaining?.length??0)===1&&clean(g),actions);
|
||||
const original=quoteSpiceCollection(game);
|
||||
const finalAction:TestingStationContestStep={actor:game.active!,action:{type:'endMovement'}};
|
||||
return {...fixture,game,actions,collector,deposit,original,finalAction};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
|
||||
normalizeAutomaticGame, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { ORGIZ_PROCESSING_STATION, quoteDiscoveryCollection } from '../game/discovery-collection';
|
||||
import { putDiscovery } from './fixture-discovery';
|
||||
import { nativeTypedClearBoard, nativeTypedPhase, nativeTypedPlace } from './fixture-discovery-native-typed';
|
||||
import { advanceTestingStationContest } from './fixture-testing-station-contest';
|
||||
|
||||
export type SharedOrgizStep = { actor: string; action: Action };
|
||||
|
||||
/** Fresh original Ecaz/Guild/Fremen Advanced Discovery game. Only conserved
|
||||
* unplayed Spice Card order, the printed supply token, reserve-counter positions
|
||||
* and board spice already produced by real blows are controlled; every phase,
|
||||
* reveal, payment, allocation, transfer and Collection is an original action. */
|
||||
export function prepareSharedOrgizCollection() {
|
||||
let game = createGame('SHAREDORGIZ', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
joinGame(game, newPlayer('g', 'Guild', 'guild'));
|
||||
joinGame(game, newPlayer('f', 'Fremen', 'fremen'));
|
||||
for (const player of game.players)
|
||||
if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game.discoveryEnabled = true;
|
||||
const initial = structuredClone(game);
|
||||
game = initializeDiscoveryGameForAudit(game);
|
||||
game = advanceTestingStationContest(game, state => state.status === 'playing');
|
||||
const ecaz = 'e', guild = 'g', fremen = 'f';
|
||||
for (const [position, name] of ['hagga_basin', 'cielago_south', 'red_chasm', 'funeral_plain'].entries()) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position &&
|
||||
'territory' in card && card.territory === name && !card.discovery);
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game = nativeTypedPhase(game, 5);
|
||||
nativeTypedClearBoard(game);
|
||||
const token = putDiscovery(game, ORGIZ_PROCESSING_STATION);
|
||||
const source = `${token.territory}:${token.sector}`;
|
||||
assert.notEqual(token.sector, game.storm);
|
||||
nativeTypedPlace(game, fremen, source, 2);
|
||||
game = nativeTypedPhase(game, 7);
|
||||
assert.ok(viewGame(game, fremen).discoveries!.canInspect.includes(token.id));
|
||||
game = applyAction(game, fremen, { type: 'discovery', token: token.id, reveal: false });
|
||||
assert.ok(viewGame(game, fremen).discoveries!.canReveal.includes(token.id));
|
||||
game = applyAction(game, fremen, { type: 'discovery', token: token.id, reveal: true });
|
||||
game = nativeTypedPhase(game, 8);
|
||||
game = nativeTypedPhase(game, 5, 2);
|
||||
nativeTypedClearBoard(game);
|
||||
// Ecaz and its Guild ally hold the same real desert territory, so the original
|
||||
// quote creates the shared lot whose allocation decides the actual recipient.
|
||||
const haggaKeys = territory('hagga_basin').sectors.filter(sector => sector !== game.storm)
|
||||
.slice(0, 2).map(sector => `hagga_basin:${sector}`);
|
||||
assert.equal(haggaKeys.length, 2);
|
||||
const untouchedKey = 'the_great_flat:15';
|
||||
nativeTypedPlace(game, fremen, `${ORGIZ_PROCESSING_STATION}:0`, 1);
|
||||
nativeTypedPlace(game, ecaz, haggaKeys[0], 1);
|
||||
nativeTypedPlace(game, guild, haggaKeys[1], 1);
|
||||
// Conserved precondition, as in the original Collection tests: the Ecaz/Guild
|
||||
// alliance exists before this Collection. The governed behavior under audit is
|
||||
// the real allocation-then-transfer sequence below, not the pact's own rules.
|
||||
game.players.find(player => player.id === ecaz)!.ally = guild;
|
||||
game.players.find(player => player.id === guild)!.ally = ecaz;
|
||||
const boardSpice = Object.values(game.spice).reduce((sum, amount) => sum + amount, 0);
|
||||
assert.ok(boardSpice >= 6);
|
||||
game.spice = { [haggaKeys[0]]: 4, [haggaKeys[1]]: 2, [untouchedKey]: boardSpice - 6 };
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
assert.equal(ordinary.shared.length, 1);
|
||||
assert.deepEqual({ ...ordinary.shared[0], amount: 0 },
|
||||
{ territory: 'hagga_basin', ecaz, ally: guild, amount: 0 });
|
||||
assert.ok(ordinary.shared[0].amount > 0, 'The real shared lot must hold collected desert spice.');
|
||||
assert.deepEqual(quoteDiscoveryCollection(game, ordinary).effects, []);
|
||||
const before = structuredClone(game);
|
||||
for (const player of game.players)
|
||||
Object.assign(player, { hand: [], traitors: [], traitorChoices: [], shipped: true, moved: 1 });
|
||||
Object.assign(game, { phase: 5, active: fremen, movementRemaining: [fremen], phaseOpening: null });
|
||||
return { initial, game, before, ordinary, boardSpice, token: token.id, source,
|
||||
haggaKeys, untouchedKey, ecaz, guild, fremen };
|
||||
}
|
||||
|
||||
/** Settle the real Collection, then answer each original Ecaz allocation prompt. */
|
||||
export function settleSharedOrgiz(
|
||||
position: ReturnType<typeof prepareSharedOrgizCollection>,
|
||||
choice: { kind: 'propose'; ecazShare: number } | { kind: 'equal' } = { kind: 'equal' },
|
||||
) {
|
||||
const steps: SharedOrgizStep[] = [];
|
||||
let game = applyAction(position.game, position.fremen, { type: 'endMovement' });
|
||||
steps.push({ actor: position.fremen, action: { type: 'endMovement' } });
|
||||
assert.equal(game.phase, 7);
|
||||
const event = game.ecazCollection?.event ?? null;
|
||||
assert.ok(event, 'The real Collection must own its allocation event.');
|
||||
while (game.decision?.kind === 'ecazSpice' && steps.length < 20) {
|
||||
const actor = game.decision.player;
|
||||
const allocation = actor === position.ecaz ? choice : { kind: 'accept' as const };
|
||||
const action = { type: 'decision', event: game.ecazCollection!.event, allocation } as Action;
|
||||
game = applyAction(game, game.decision.player, action);
|
||||
steps.push({ actor: position.ecaz, action });
|
||||
}
|
||||
assert.equal(game.decision, null, 'The original shared allocation must settle without inventing another choice.');
|
||||
const stable = JSON.parse(JSON.stringify(game)) as Game;
|
||||
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(stable))), stable);
|
||||
return { game, steps, event, receipts: game.ecazCollection?.settled ?? [] };
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { fighterCount } from '../game/advisors';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, type DiscoveryTokenFace } from '../game/discoveries';
|
||||
import { gameDistance } from '../game/board';
|
||||
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
|
||||
import { discoveryStormActions } from '../game/discovery-storm-options';
|
||||
import { PLAYER_CIRCLE_SECTORS } from '../game/player-positions';
|
||||
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
||||
@@ -37,6 +38,13 @@ export type TestingStationContestFixture = {
|
||||
event: string;
|
||||
};
|
||||
|
||||
export type OccupiedDiscoveryLocation = 'cistern' | 'ecological-testing-station' | 'orgiz-processing-station';
|
||||
export type DiscoveryOccupantsFixture = Pick<TestingStationContestFixture,
|
||||
'initial' | 'setup' | 'afterSetup' | 'game' | 'actions' | 'staging' | 'token' | 'parent' | 'parentSector'> & {
|
||||
location: OccupiedDiscoveryLocation;
|
||||
owners: [string,string];
|
||||
};
|
||||
|
||||
const STATION = 'ecological-testing-station';
|
||||
|
||||
/** Printed player-circle storm order for the board's current storm position. */
|
||||
@@ -152,11 +160,11 @@ function selectedTokenLottery<T>(
|
||||
): T {
|
||||
const pool = game
|
||||
.discoveries!.tokens.filter(
|
||||
(token) => token.status === 'supply' && token.type === 'hiereg',
|
||||
(token) => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type,
|
||||
)
|
||||
.map((token) => token.face);
|
||||
const index = pool.indexOf(face);
|
||||
assert.ok(index >= 0, 'The station Hiereg token must remain in supply.');
|
||||
assert.ok(index >= 0, 'The original requested Discovery token must remain in its printed supply.');
|
||||
const scalar = Math.floor(((index + 0.5) / pool.length) * 0x100000000);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
crypto,
|
||||
@@ -195,15 +203,16 @@ export function holdTestingStationStormCard(
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Original fresh Ecaz/Guild/Fremen/Emperor Discovery setup. Two distinct
|
||||
* non-advisor occupants reach the revealed station through real controls: the
|
||||
* first free-enters from the printed Hagga Basin destination, a real worm Nexus
|
||||
* forms the reciprocal alliance, and the ally then pays the ordinary stronghold
|
||||
* rate to ship in. No force, phase, source, token or frame is fabricated. */
|
||||
export function createTestingStationContestFixture(
|
||||
options: { advanced?: boolean } = {},
|
||||
): TestingStationContestFixture {
|
||||
const advanced = options.advanced ?? false;
|
||||
/** Original setup, printed Discovery supply and real reciprocal alliance create
|
||||
* two occupants without fabricated counters, wallets, phases or token faces. */
|
||||
export function createDiscoveryOccupantsFixture(
|
||||
options: { location: OccupiedDiscoveryLocation; advanced?: boolean; secondAmount?: number; adjacentBlow?: boolean },
|
||||
): DiscoveryOccupantsFixture {
|
||||
const advanced = options.advanced ?? false, location = options.location;
|
||||
const secondAmount = options.secondAmount ?? 1;
|
||||
const card = DISCOVERY_SPICE_CARDS.find(candidate =>
|
||||
DISCOVERY_CARD_PLACEMENTS[candidate.discovery].type === DISCOVERY_TOKEN_BY_ID[location].type)!;
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS[card.discovery];
|
||||
let game = createGame(
|
||||
'TESTINGSTATIONCONTEST',
|
||||
newPlayer('ecaz', 'Ecaz', 'ecaz'),
|
||||
@@ -229,36 +238,38 @@ export function createTestingStationContestFixture(
|
||||
// Printed Hagga Basin Discovery blow on turn one; a real ordinary worm on turn
|
||||
// two; everything else stays an original unplayed card of the same deck.
|
||||
const wormPosition = advanced ? 2 : 1;
|
||||
frontCard(game, 0, (card) => 'territory' in card && card.discovery === 'discovery-hagga-basin');
|
||||
frontCard(game, 0, (candidate) => 'territory' in candidate && candidate.discovery === card.discovery);
|
||||
if (advanced)
|
||||
frontCard(game, 1, (card) => 'territory' in card && !card.discovery);
|
||||
frontCard(game, 1, (candidate) => 'territory' in candidate && !candidate.discovery && candidate.territory !== placement.territory);
|
||||
frontCard(game, wormPosition, (card) => 'worm' in card && !card.greatMaker);
|
||||
for (let position = wormPosition + 1; position < wormPosition + 3; position++)
|
||||
frontCard(game, position, (card) => 'territory' in card && !card.discovery);
|
||||
frontCard(game, position, (candidate) => 'territory' in candidate && !candidate.discovery && candidate.territory !== placement.territory);
|
||||
if (options.adjacentBlow)
|
||||
frontCard(game,wormPosition+1,candidate => 'territory' in candidate && !candidate.discovery &&
|
||||
gameDistance(game,`${placement.territory}:${placement.sector}`,`${candidate.territory}:${candidate.sector}`) === 1);
|
||||
staging.push(
|
||||
'Reordered original unplayed Spice Cards only: the printed Hagga Basin Discovery first, then an ordinary Shai-Hulud for turn two.',
|
||||
`Reordered original unplayed Spice Cards only: printed ${card.discovery} first, then ordinary Shai-Hulud for turn two.`,
|
||||
);
|
||||
const afterSetup = structuredClone(game);
|
||||
|
||||
game = selectedTokenLottery(game, 'ecological-testing-station', () =>
|
||||
game = selectedTokenLottery(game, location, () =>
|
||||
advanceTestingStationContest(
|
||||
game,
|
||||
(state) =>
|
||||
state.discoveries!.tokens.some(
|
||||
(token) => token.face === 'ecological-testing-station' && token.status === 'placed',
|
||||
(token) => token.face === location && token.status === 'placed',
|
||||
),
|
||||
actions,
|
||||
),
|
||||
);
|
||||
const token = game.discoveries!.tokens.find(
|
||||
(candidate) => candidate.face === 'ecological-testing-station',
|
||||
(candidate) => candidate.face === location,
|
||||
)!;
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
assert.equal(token.status, 'placed');
|
||||
assert.equal(token.territory, placement.territory);
|
||||
assert.equal(token.sector, placement.sector);
|
||||
staging.push(
|
||||
'Selected the actual Hiereg supply lottery; the station token itself was placed by the printed card.',
|
||||
'Selected only the actual printed-type supply lottery; the Discovery card places the original token.',
|
||||
);
|
||||
|
||||
// Turn one: the collector ships to the token's printed destination, then
|
||||
@@ -324,9 +335,9 @@ export function createTestingStationContestFixture(
|
||||
assert.equal(entry.action.accept, true);
|
||||
actions.push(structuredClone(entry));
|
||||
game = applyAction(game, entry.actor, entry.action);
|
||||
assert.equal(fighterCount(game.players.find((p) => p.id === ecaz)!, STATION), 2);
|
||||
assert.equal(fighterCount(game.players.find((p) => p.id === ecaz)!, location), 2);
|
||||
staging.push(
|
||||
'The station occupant entered through the printed next-turn free entry; no force was placed there directly.',
|
||||
'The first occupant entered through real next-turn free entry; no force was placed there directly.',
|
||||
);
|
||||
|
||||
game = advanceTestingStationContest(
|
||||
@@ -357,9 +368,9 @@ export function createTestingStationContestFixture(
|
||||
actor: guild,
|
||||
action: {
|
||||
type: 'ship',
|
||||
territory: STATION,
|
||||
territory: location,
|
||||
sector: 0,
|
||||
amount: 1,
|
||||
amount: secondAmount,
|
||||
elite: 0,
|
||||
allyPayment: 0,
|
||||
},
|
||||
@@ -367,10 +378,21 @@ export function createTestingStationContestFixture(
|
||||
actions.push(structuredClone(secondShipment));
|
||||
game = applyAction(game, secondShipment.actor, secondShipment.action);
|
||||
game = advanceTestingStationContest(game,clean,actions);
|
||||
assert.equal(fighterCount(game.players.find((p) => p.id === guild)!, STATION), 1);
|
||||
assert.equal(fighterCount(game.players.find((p) => p.id === guild)!, location), secondAmount);
|
||||
staging.push(
|
||||
'A real worm Nexus produced the reciprocal Ecaz/Guild alliance; the ally then used its own ordinary paid shipment into the occupied revealed location.',
|
||||
);
|
||||
return {initial,setup,afterSetup,game,actions,staging,location,owners:[ecaz,guild],
|
||||
token:token.id,parent:placement.territory,parentSector:placement.sector};
|
||||
}
|
||||
|
||||
/** The original station-specific tail retains its real natural Storm window. */
|
||||
export function createTestingStationContestFixture(
|
||||
options: {advanced?:boolean} = {},
|
||||
): TestingStationContestFixture {
|
||||
const prefix = createDiscoveryOccupantsFixture({...options,location:STATION});
|
||||
const {initial,setup,afterSetup,actions,staging} = prefix;
|
||||
let game = prefix.game;
|
||||
|
||||
game = advanceTestingStationContest(
|
||||
game,
|
||||
@@ -428,9 +450,9 @@ export function createTestingStationContestFixture(
|
||||
staging,
|
||||
owner: occupants[0],
|
||||
second: occupants[1],
|
||||
token: token.id,
|
||||
parent: placement.territory,
|
||||
parentSector: placement.sector,
|
||||
token: prefix.token,
|
||||
parent: prefix.parent,
|
||||
parentSector: prefix.parentSector,
|
||||
source: {
|
||||
turn: game.stormMovementSource!.turn,
|
||||
kind: game.stormMovementSource!.kind,
|
||||
|
||||
Reference in new issue
Block a user