Connect multi-occupant Cistern and Orgiz Collection

This commit is contained in:
Quintine committed 2026-10-14 18:48:40 +11:00
1 parent 554f2bf9e9
commit 388c5fa8d5
12 files changed
+536 -119

No files matched your search

+20
View File
@@ -29,6 +29,26 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Cistern and Orgiz multi-occupant Collection — 14 October.**
Following the same choose-recommended-option priority, the prototype closes two
withheld benefits: each Cistern fighter occupant now collects its two bank spice
after ordinary advisor releases, and each Orgiz fighter occupant takes one spice
from the unique rival collector of a qualifying deposit. Orgiz owners resolve in
original storm order; an empty purse pays nothing and creates no debt, so
ordinary Collection continues. Advanced keeps the supplied territory-Collection
trigger; Basic keeps the provisional positive-deposit mapping. Advanced
shared-Ecaz lots are no longer withheld: after the real bilateral allocation,
each occupant takes one spice from the first actual positive foreign recipient
(Ecaz, then ally), never from a zero share, an old wallet or itself.
Genuine fixtures reach these windows through real stage/phase controls, paid
shipments, Collection inspection/reveal, free entry, alliance and the engine's
own allocation protocol; equal/propose/zero splits, first-eligible recipient,
one available spice for two owners, lone-advisor exclusion and deterministic
re-settlement are exercised. Related17-file union127/127, five repeat runs
8/8, types/lint and app build pass. Contested Cistern/Orgiz ownership and full
acceptance remain open. [Contract](DISCOVERY_PROTOTYPE.md#multi-occupant-and-shared-cisternorgiz-collection--14-october-2026).
**Recommended Jacurutu and Testing Station choices — 14 October.**
Following the choose-recommended-option priority, mixed physical Jacurutu dials
now use a defeated-player exact ordinary/elite/paid selection before battle
+44 -11
View File
@@ -64,6 +64,37 @@ retains existing module-registration, chunk-size and route-classification
warnings. Throwaway native probes are removed; permanent tests exercise
source/actor/transition/payment boundaries, not wording or fixture-copy echoes.
### Multi-occupant and shared Cistern/Orgiz Collection — 14 October 2026
Cistern now pays every fighter occupant two bank spice after ordinary advisor
releases; a lone advisor never qualifies. Orgiz resolves each fighter occupant
in original storm order, taking one spice from the unique rival collector of a
qualifying deposit. An empty purse simply cannot pay: no debt is created and
ordinary Collection continues. Advanced keeps the supplied territory-Collection
trigger and Basic keeps the provisional positive-deposit mapping.
The Advanced shared-Ecaz branch now settles instead of withholding. After the
real bilateral allocation reports the actual split, each occupant takes one
spice from the first actual positive foreign recipient — Ecaz first, then the
ally — never from a zero share, an old wallet or the occupant itself, and never
beyond that recipient's available spice. Total spice is conserved by the
transfer; only the real collected lot is credited.
Genuine evidence: the original Station/Ecaz-Guild/Fremen fixtures reach these
windows through real stage/phase controls, paid shipments, Collection
inspection/reveal, free entry, alliances and the engine's own allocation
protocol; only unplayed Spice Card order, printed supply tokens and conserved
counter positions are controlled. Cases cover equal/propose/zero splits, the
first-eligible recipient, two owners with one available spice, lone-advisor
exclusion, JSON normalization and deterministic re-settlement.
`tests/fixture-discovery-occupied-collection.ts`,
`tests/discovery-occupied-collection-runtime.test.ts`,
`tests/fixture-discovery-shared-orgiz.ts` and
`tests/discovery-shared-orgiz-runtime.test.ts`. Other contested sources
(Cistern/Orgiz ownership contests, Ecaz shared-lot theft ordering variants) and
full acceptance remain open.
## Mixed E1/E2 and paired E3 Nexus — 7 October 2026
Original native Nx/Discovery now admits both selected Ix+CHOAM families
@@ -828,17 +859,19 @@ conversion was fabricated.
territory into sector zero of that location. This spends no spice, shipment or
normal movement. Empty, storm-blocked and occupancy-blocked offers skip without
holding up the turn. Accepted groups and arrival reactions survive restoration.
- **Cistern:** during Collection, a sole fighter occupant receives two bank
spice after ordinary advisor releases. If two fighters occupy Cistern, the
unresolved benefit is withheld without blocking Collection; this boundary
does not decide which occupant should receive it.
- **Orgiz:** during Collection, a sole fighter occupant takes one spice from
the unique rival collector of each positive board deposit after ordinary
advisor releases. One collected pile is one observable blow even if previous
blows stacked there. The transfer occurs after ordinary collection, conserves
player spice, appears in the public log, and resumes from saved state without
another payment. Unresolved Ecaz shared lots and contested Orgiz occupation
withhold their uncertain transfers, not the ordinary Collection phase.
- **Cistern:** during Collection, after ordinary advisor releases, each fighter
occupant of the revealed location receives two bank spice. A lone advisor
never qualifies. Two fighters therefore both collect; the former withheld
two-fighter gap is closed.
- **Orgiz:** during Collection, each fighter occupant takes one spice from the
unique rival collector of each qualifying deposit after ordinary advisor
releases. Owners resolve in original storm order, and an empty purse simply
cannot pay; no debt is created and ordinary Collection continues. Advanced
uses the territory Collection event; Basic keeps the provisional
positive-deposit trigger. For an Ecaz shared lot, the real bilateral
allocation settles first, then each occupant takes one from the first actual
positive foreign recipient (Ecaz, then ally) that still has spice, never from
a zero share, an old wallet or the occupant itself.
- **Jacurutu:** one bank spice per opposing undialed physical counter actually
sent to the Tanks. Unique readings remain automatic. For different possible
+39
View File
@@ -1,5 +1,44 @@
# Dune implementation status
## 14 October 2026 — Cistern/Orgiz multi-occupant and shared-lot Collection
Same recommended-option priority, no further clarification prompt. Cistern now
pays each fighter occupant two bank spice after ordinary advisor releases, and
Orgiz resolves each fighter occupant in original storm order, taking one spice
from the unique rival collector of a qualifying deposit. An empty purse pays
nothing, creates no debt and never blocks ordinary Collection. Advanced keeps
the supplied territory-Collection trigger; Basic keeps the provisional
positive-deposit mapping. A lone advisor still never qualifies.
Advanced shared-Ecaz lots now settle after their real bilateral allocation:
each occupant takes one spice from the first actual positive foreign recipient
(Ecaz first, then ally), never from a zero share, the occupant itself, an old
wallet or beyond that recipient’s available spice. Total spice is conserved by
the transfer and only the real collected lot is credited.
BEFORE the two-occupant cases withheld their benefit; the shared Advanced branch
was explicitly unpaid. AFTER both are connected through the engine’s own
Collection path. Two superseded expectation tests and one wording assertion were
deleted rather than re-pinned; the exhausted Cistern/Orgiz guards and their
instructions were rewritten.
Evidence uses genuine paths only: original Ecaz/Guild/Fremen (or Station)
Advanced lobbies, real stage/phase controls, paid shipments, Collection
inspection and reveal, next-turn free entry, a real worm Nexus alliance and the
engine’s own allocation protocol. Conserved unplayed Spice Card order, printed
supply tokens and counter positions are the only staging. Cases cover equal,
propose and zero splits, first-eligible recipient, two owners with a single
available spice, lone-advisor exclusion, JSON normalization and deterministic
re-settlement.
Final related17-file union127/127, five consecutive randomised runs8/8, types/
lint and app build pass with existing module-registration, chunk-size and
route-classification warnings. Contested Cistern/Orgiz ownership, Ecaz
shared-lot theft ordering variants, complete modules and deployed acceptance
remain open.
## 14 October 2026 — recommended physical Jacurutu dial and cumulative station queue
The choose-recommended-option priority removes another clarification stop.
+57 -20
View File
@@ -2,6 +2,7 @@ import { isAdvisor } from './advisors';
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import type { AdvisorRelease } from './board-resolution-quote';
import type { Player } from './engine';
import type { SpiceAllocationReceipt } from './ecaz-spice-allocation';
import { presenceAt, type ForcePresence } from './force-presence';
export const CISTERN = 'cistern';
@@ -89,7 +90,7 @@ function isCollectingFighter(
released.some((entry) => entry.player === player.id && entry.territory === territory);
}
function occupants(
export function discoveryCollectionOccupants(
context: DiscoveryCollectionContext,
location: string,
released: readonly AdvisorRelease[],
@@ -99,7 +100,7 @@ function occupants(
return context.players.filter(
(player) => isCollectingFighter(player, location, released) &&
presenceAt(player, location) > 0,
);
).sort((left,right) => context.order.indexOf(left.id)-context.order.indexOf(right.id));
}
/** Public effective presence only; an ambiguous source cannot identify a payer. */
@@ -132,9 +133,9 @@ function transferOrgiz(
) {
if (!collector || collector.id === ownerReceipt.player) return;
const collectorReceipt = receipts.find(entry => entry.player === collector.id)!;
if (collectorReceipt.balance === 0) return;
requireCollection(
collectorReceipt.balance >= 1 &&
Number.isSafeInteger(ownerReceipt.balance + 1),
Number.isSafeInteger(ownerReceipt.balance + 1),
'Orgiz theft would invalidate a player spice balance.',
);
collectorReceipt.balance -= 1;
@@ -146,14 +147,14 @@ function transferOrgiz(
}
/**
* Compose sole-occupant Cistern income and a bounded Orgiz transfer over the
* canonical ordinary collection quote. The ordinary `collected` and `desert`
* fields remain factual: Cistern is bank income; Orgiz transfers player spice.
* Compose each eligible occupant's Cistern income and original Orgiz transfers
* over the canonical ordinary quote. Ordinary `collected` and `desert` remain
* factual: Cistern is bank income; Orgiz transfers available player spice.
*
* Advanced uses the authorized revised rulebook's territory Collection event;
* Basic retains the provisional one-positive-deposit interpretation. Contested
* sources, different territory payers and shared lots withhold only their
* uncertain effect; they never block ordinary collection.
* Owners resolve in original storm order. Advanced uses territory Collection;
* Basic retains the provisional positive-deposit trigger. Ambiguous collectors
* and shared lots still withhold only their uncertain effect. Empty purses
* cannot create debt or block ordinary Collection.
*/
export function quoteDiscoveryCollection(
context: DiscoveryCollectionContext,
@@ -250,9 +251,9 @@ export function quoteDiscoveryCollection(
);
const effects: DiscoveryCollectionQuote['effects'] = [];
const cistern = occupants(context, CISTERN, collection.released);
if (cistern.length === 1) {
const receipt = receipts.find((entry) => entry.player === cistern[0].id)!;
const cistern = discoveryCollectionOccupants(context, CISTERN, collection.released);
for (const owner of cistern) {
const receipt = receipts.find((entry) => entry.player === owner.id)!;
requireCollection(
Number.isSafeInteger(receipt.balance + 2),
'Cistern bank income would overflow the spice balance.',
@@ -266,10 +267,9 @@ export function quoteDiscoveryCollection(
});
}
const orgiz = occupants(context, ORGIZ_PROCESSING_STATION, collection.released);
if (orgiz.length === 1) {
const owner = orgiz[0];
const ownerReceipt = receipts.find((entry) => entry.player === owner.id)!;
const orgiz = discoveryCollectionOccupants(context, ORGIZ_PROCESSING_STATION, collection.released);
if (orgiz.length) {
const ownerReceipts = orgiz.map(owner => receipts.find(entry => entry.player === owner.id)!);
const transfers = context.advanced
? new Map<string, { location: string; collector: DiscoveryCollectionSeat | undefined }>()
: undefined;
@@ -283,7 +283,8 @@ export function quoteDiscoveryCollection(
) continue;
const collector = collectorAt(context, key, collection.released);
if (!transfers) {
transferOrgiz(key, collector, ownerReceipt, receipts, effects);
for (const ownerReceipt of ownerReceipts)
transferOrgiz(key, collector, ownerReceipt, receipts, effects);
continue;
}
const pending = transfers.get(territory);
@@ -293,8 +294,44 @@ export function quoteDiscoveryCollection(
}
if (transfers)
for (const pending of transfers.values())
transferOrgiz(pending.location, pending.collector, ownerReceipt, receipts, effects);
for (const ownerReceipt of ownerReceipts)
transferOrgiz(pending.location, pending.collector, ownerReceipt, receipts, effects);
}
return { receipts, effects };
}
const SHARED_COLLECTION_RELEASES: readonly AdvisorRelease[] = [];
/** Advanced's one-territory event waits for the original bilateral allocation.
* Charge the first actual positive foreign recipient (Ecaz, then ally), never
* a guessed share, the owner itself, a city award or unavailable spice. */
export function quoteAdvancedSharedOrgiz(
context: DiscoveryCollectionContext,
allocation: SpiceAllocationReceipt,
balances: Readonly<Record<string,number>>,
): OrgizCollectionReceipt[] {
requireCollection(context.advanced && allocation.ecaz !== allocation.ally &&
context.order.includes(allocation.ecaz) && context.order.includes(allocation.ally) &&
whole(allocation.ecazAmount) && whole(allocation.allyAmount) &&
record(balances) && context.order.every(id => whole(balances[id])),
'Shared Orgiz needs the actual Advanced territory allocation and available balances.');
const owners = discoveryCollectionOccupants(context,ORGIZ_PROCESSING_STATION,SHARED_COLLECTION_RELEASES);
const effects: OrgizCollectionReceipt[] = [];
let ecazDebited=0, allyDebited=0;
for (const owner of owners) {
let payer: string;
if (owner.id !== allocation.ecaz && allocation.ecazAmount > 0 &&
balances[allocation.ecaz] > ecazDebited) {
payer=allocation.ecaz; ecazDebited++;
} else if (owner.id !== allocation.ally && allocation.allyAmount > 0 &&
balances[allocation.ally] > allyDebited) {
payer=allocation.ally; allyDebited++;
} else continue;
requireCollection(Number.isSafeInteger(balances[owner.id]+1),
'Shared Orgiz income would overflow the spice balance.');
effects.push({kind:'orgiz',player:owner.id,from:payer,location:allocation.territory,
amount:1,source:'player'});
}
return effects;
}
+11
View File
@@ -305,6 +305,7 @@ import {
} from './board-resolution-quote';
import {
quoteDiscoveryCollection,
quoteAdvancedSharedOrgiz,
DiscoveryCollectionError,
type DiscoveryCollectionQuote,
} from './discovery-collection';
@@ -19689,6 +19690,16 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
`${territory(receipt.territory).name} shared collection: Ecaz received ${receipt.ecazAmount} spice and ${faction(getPlayer(g, receipt.ally).faction).name} received ${receipt.allyAmount}, ${receipt.method === 'agreement' ? 'by agreement' : 'using the equal split with any odd spice going to the ally'}.`,
{ faction: 'ecaz', name: 'Shared collection' },
);
if (g.advanced) {
const balances = Object.fromEntries(g.players.map(player => [player.id,player.spice]));
const transfers = nexusRule(() => quoteAdvancedSharedOrgiz(g,receipt,balances));
for (const transfer of transfers) {
getPlayer(g,transfer.from).spice -= transfer.amount;
getPlayer(g,transfer.player).spice += transfer.amount;
log(g, `${faction(getPlayer(g,transfer.player).faction).name} took ${transfer.amount} spice from ${faction(getPlayer(g,transfer.from).faction).name} through Orgiz Processing Station after the original shared territory allocation.`,
{faction:getPlayer(g,transfer.player).faction,name:'Orgiz Processing Station'});
}
}
} else
log(
g,
+2 -2
View File
@@ -4226,7 +4226,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The five revealed location tokens become separate, initially empty territories inside their surroundings. A private peek does not open the location. Normal ground movement enters through the surrounding territory, shipment uses stronghold prices, at most two factions may occupy it, and forces inside are protected from storms and sandworms. Only Jacurutu Sietch counts toward stronghold victory.',
'Native typed forces enter as their actual ordinary and elite groups, never as passengers from the mobile stronghold or the concealed value of a No-Field. Richese may ship or move a concealed marker into an actually revealed nested location at sector zero; it remains one effective presence until its original reveal materializes available reserves. CHOAM Card Stash uses its five-card limit, drawing first and choosing any held discard afterward.',
'At the start of the next turn, before the storm and mobile stronghold movement, each eligible faction may move any positive subset of its non-advisor groups from the surrounding territory into the newly revealed location. The signed choice preserves ordinary and elite forces across sectors, normal source storm and occupancy restrictions, and costs no spice, shipment or normal movement. The printed text gives no sequence and does not restate the source-storm restriction; processing tokens in reveal order and factions in storm order while excluding storm sources are source inferences.',
'A sole Cistern occupant receives two bank spice during Collection. In Advanced, a sole Orgiz occupant takes one collected spice per territory where an unambiguous rival collected board spice. Multiple collected deposits in that territory do not add transfers. Basic retains its provisional one-per-positive-deposit interpretation. Contested ownership, different or shared payers leave only that uncertain transfer unpaid; ordinary Collection continues.',
'During Collection, after ordinary advisor releases, each fighter occupant of Cistern receives two bank spice, and each fighter occupant of Orgiz takes one spice from the unique rival collector of a qualifying deposit. Orgiz owners resolve in original storm order; an empty purse cannot pay and creates no debt. Advanced uses the supplied territory Collection event and Basic keeps the provisional one-per-positive-deposit mapping. When Ecaz and its ally share a collected territory, the real allocation settles first and the Orgiz owner then takes from the first actual positive foreign recipient. Contested ownership, different payers and unresolved Cistern ownership contests remain guarded; ordinary Collection continues.',
'Jacurutu pays one bank spice for each opposing undialed physical force actually sent to the Tanks. Where an Advanced dial admits different physical counts, the defeated player chooses an exact ordinary/elite/paid reading before support, casualties or rewards settle; unique readings remain automatic. This choice timing is a visibly provisional implementation interpretation. Original Harass returns bind their physical dial and actual withdrawal/retreat counters do not earn spice.',
'Each non-advisor Testing Station occupant may decrease ordinary storm movement by one, keep it or increase it by one. The provisional order is the original storm order, once per owner, with cumulative adjustments on current distance. Choose after the ordinary card window; traversal/protection starts only after all choices. Weather Control is unaffected. Original natural source/base and existing sole-frame history remain authoritative; legacy movement without a source is not invented.',
'A current non-advisor Shrine occupant may play a physical Truthtrance card as Karama, or a physical Karama as Truthtrance, within the implemented timing and effect paths. The printed identity and actual card custody remain unchanged; a committed Truthtrance conversion survives departure. This does not create missing Karama powers or settle their pending rules.',
@@ -4236,7 +4236,7 @@ export const RULE_TOPICS: RuleTopic[] = [
checklist: [
{
area: 'Implementation', status: 'Partial',
detail: 'Original classic and supported native Skills/Nexus/Discovery entries retain their original selected modules, Maker votes/typed rides, source-local held benefits, training and owner-labelled coalition aftermath. Mixed Jacurutu now pauses for an exact defeated-player physical reading before settlement, excluding actual returns from Tanks income. Testing Station uses a signed cumulative storm-order owner queue before one traversal; original source and sole-frame history remain. Advanced Orgiz aggregates territory Collection; Basic stays provisional. Other pending sources and public starts remain guarded.',
detail: 'Original classic and supported native Skills/Nexus/Discovery entries retain their original selected modules, Maker votes/typed rides, source-local held benefits, training and owner-labelled coalition aftermath. Mixed Jacurutu now pauses for an exact defeated-player physical reading before settlement, excluding actual returns from Tanks income. Testing Station uses a signed cumulative storm-order owner queue before one traversal. Cistern pays each fighter occupant, and Orgiz charges each occupant in storm order without debt; Advanced shared Ecaz lots settle their real allocation before the transfer. Advanced Orgiz aggregates territory Collection; Basic stays provisional. Other pending sources and public starts remain guarded.',
evidence: ['game/discovery-module-profile.ts', 'game/richese-no-field.ts', 'game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'],
},
{
+36 -57
View File
@@ -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};
}
+103
View File
@@ -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 ?? [] };
}
+51 -29
View File
@@ -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,