diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 2299f2df..c74ca775 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index 24ee6830..46e8edf5 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 4e1ed909..5746f627 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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. diff --git a/game/discovery-collection.ts b/game/discovery-collection.ts index b615d15b..365e6054 100644 --- a/game/discovery-collection.ts +++ b/game/discovery-collection.ts @@ -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() : 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>, +): 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; +} diff --git a/game/engine.ts b/game/engine.ts index 52672a57..05958a29 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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, diff --git a/game/reference.ts b/game/reference.ts index 3c828ebb..b275d00b 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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'], }, { diff --git a/tests/discovery-collection.test.ts b/tests/discovery-collection.test.ts index bd3e4a28..c2c7660c 100644 --- a/tests/discovery-collection.test.ts +++ b/tests/discovery-collection.test.ts @@ -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); + } +}); diff --git a/tests/discovery-occupied-collection-runtime.test.ts b/tests/discovery-occupied-collection-runtime.test.ts new file mode 100644 index 00000000..88cd1175 --- /dev/null +++ b/tests/discovery-occupied-collection-runtime.test.ts @@ -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); + }); +} diff --git a/tests/discovery-shared-orgiz-runtime.test.ts b/tests/discovery-shared-orgiz-runtime.test.ts new file mode 100644 index 00000000..a63cb833 --- /dev/null +++ b/tests/discovery-shared-orgiz-runtime.test.ts @@ -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); +}); diff --git a/tests/fixture-discovery-occupied-collection.ts b/tests/fixture-discovery-occupied-collection.ts new file mode 100644 index 00000000..a0a35d01 --- /dev/null +++ b/tests/fixture-discovery-occupied-collection.ts @@ -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}; +} diff --git a/tests/fixture-discovery-shared-orgiz.ts b/tests/fixture-discovery-shared-orgiz.ts new file mode 100644 index 00000000..07f06d90 --- /dev/null +++ b/tests/fixture-discovery-shared-orgiz.ts @@ -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, + 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 ?? [] }; +} diff --git a/tests/fixture-testing-station-contest.ts b/tests/fixture-testing-station-contest.ts index 3dafa5a8..b4191959 100644 --- a/tests/fixture-testing-station-contest.ts +++ b/tests/fixture-testing-station-contest.ts @@ -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 & { + 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 { 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,