Use actual Collection receipts for co-present Orgiz payers
This commit is contained in:
1 parent
bc09ac3cfd
commit
bcd1b1041a
13 files changed
+287
-172
No files matched your search
@@ -146,6 +146,10 @@ void test('collection uses storm order and shared remaining spice, with city rat
|
||||
{ player: 'e', strongholds: 0, collected: 3, desert: 3, balance: 13 },
|
||||
{ player: 'b', strongholds: 0, collected: 0, desert: 0, balance: 10 },
|
||||
]);
|
||||
assert.deepEqual(q.deposits, [
|
||||
{ player: 'a', location: 'red_chasm:7', amount: 6 },
|
||||
{ player: 'e', location: 'red_chasm:7', amount: 3 },
|
||||
]);
|
||||
assert.equal(q.spice['red_chasm:7'], 0);
|
||||
assert.deepEqual(g, before);
|
||||
g.order = ['e', 'a', 'b'];
|
||||
@@ -153,6 +157,10 @@ void test('collection uses storm order and shared remaining spice, with city rat
|
||||
quoteSpiceCollection(g).receipts.map((r) => r.collected),
|
||||
[4, 5, 0],
|
||||
);
|
||||
assert.deepEqual(quoteSpiceCollection(g).deposits, [
|
||||
{ player: 'e', location: 'red_chasm:7', amount: 4 },
|
||||
{ player: 'a', location: 'red_chasm:7', amount: 5 },
|
||||
]);
|
||||
});
|
||||
void test('collection counts a concealed marker as one without inspecting its secret token or reserve value', () => {
|
||||
const g = fixture();
|
||||
|
||||
@@ -557,7 +557,7 @@ void test('Advanced falls through to the next actual collector when an earlier O
|
||||
assert.equal(balance(discovery, 'e'), 1);
|
||||
});
|
||||
|
||||
void test('Advanced does not select a payer from an uncontested sector when another collected sector is contested', () => {
|
||||
void test('Advanced uses actual shared-sector receipts rather than withholding an entire territory for co-present forces', () => {
|
||||
const game = fixture(true);
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
@@ -568,12 +568,31 @@ void test('Advanced does not select a payer from an uncontested sector when anot
|
||||
assert.equal(ordinary.spice['hagga_basin:12'], 2);
|
||||
assert.equal(ordinary.spice['hagga_basin:13'], 0);
|
||||
assert.deepEqual(discovery.effects, [{
|
||||
kind: 'orgiz', player: 'a', from: 'h', location: 'hagga_basin:12',
|
||||
amount: 1, source: 'player',
|
||||
}, {
|
||||
kind: 'orgiz', player: 'a', from: 'h', location: 'red_chasm:7',
|
||||
amount: 1, source: 'player',
|
||||
}]);
|
||||
assert.equal(balance(discovery, 'g'), ordinary.receipts[2].balance);
|
||||
});
|
||||
|
||||
void test('Orgiz never charges a co-present faction that collected zero, even with an old wallet', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const pile of [2, 4]) {
|
||||
const game = fixture(advanced);
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[1].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1, 'hagga_basin:12': 1 };
|
||||
game.players[2].forces = { 'hagga_basin:12': 1 };
|
||||
game.players[2].spice = 20;
|
||||
const { ordinary, discovery } = composed({ ...game, spice: { 'hagga_basin:12': pile } });
|
||||
assert.equal(ordinary.deposits.some(entry => entry.player === 'g'), pile === 4);
|
||||
assert.equal(balance(discovery, 'g'), 20 + (pile === 4 ? 1 : 0));
|
||||
assert.equal(balance(discovery, 'h'), 2 + (pile === 4 ? 1 : 0));
|
||||
assert.equal(discovery.effects.length, pile === 4 ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Advanced shared territories remain unpaid across all their source keys while independent Collection proceeds', () => {
|
||||
const game = fixture(true, [['e', 'ecaz'], ['a', 'atreides'], ['g', 'guild']]);
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
import {prepareOrdinaryPairedOrgizCollection} from './fixture-discovery-shared-orgiz';
|
||||
|
||||
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,
|
||||
@@ -47,3 +48,25 @@ for(const advanced of [false,true]) {
|
||||
assert.deepEqual(wallets(done),expected);
|
||||
});
|
||||
}
|
||||
|
||||
for(const advanced of [false,true])
|
||||
for(const exhaustFirst of [false,true]) {
|
||||
void test(`${advanced?'Advanced':'Basic'} original co-present rock collectors ${exhaustFirst?'exhaust the pile in storm order':'both collect'} without guessing an Orgiz payer`,()=>{
|
||||
const f=prepareOrdinaryPairedOrgizCollection(advanced,exhaustFirst);
|
||||
const before=wallets(f.game);
|
||||
const done=applyAction(f.game,f.finalAction.actor,f.finalAction.action);
|
||||
const expected={...before};
|
||||
if(exhaustFirst)expected[f.first]+=f.amount;
|
||||
else {expected[f.ecaz]+=2;expected[f.guild]+=2}
|
||||
if(advanced)expected[f.guild]+=1; // Original Tuek's Sietch Collection.
|
||||
const ecazCollected=!exhaustFirst||f.first===f.ecaz;
|
||||
if(ecazCollected) {expected[f.ecaz]-=1;expected[f.guild]+=1}
|
||||
assert.deepEqual(wallets(done),expected);
|
||||
assert.equal(done.spice[f.key],exhaustFirst?0:f.amount-4);
|
||||
assert.equal(done.log.filter(entry=>entry.automatic?.name==='Orgiz Processing Station').length,
|
||||
ecazCollected?1:0);
|
||||
assert.equal(done.phase,7);
|
||||
assert.equal(done.decision,null);
|
||||
assert.equal(done.players.find(p=>p.id===f.guild)!.forces[`${ORGIZ_PROCESSING_STATION}:0`],2);
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import {applyAction,type Action,type Game} from '../game/engine';
|
||||
import {splitLocation,territory} from '../game/board';
|
||||
import {ORGIZ_PROCESSING_STATION} from '../game/discovery-collection';
|
||||
import {quoteSpiceCollection} from '../game/board-resolution-quote';
|
||||
import {
|
||||
createDiscoveryOccupantsFixture,advanceTestingStationContest,nextTestingStationContestStep,
|
||||
type TestingStationContestStep,
|
||||
@@ -9,70 +10,82 @@ import {
|
||||
|
||||
const clean=(game:Game)=>!game.decision&&!game.response&&!game.phaseOpening;
|
||||
|
||||
/** Preserve original armies/cards/alliances. Only original unplayed Spice Card
|
||||
* order and the actual token lottery are staged by the shared native prefix. */
|
||||
export function prepareSharedOrgizCollection() {
|
||||
const prefix=createDiscoveryOccupantsFixture({location:ORGIZ_PROCESSING_STATION,advanced:true,secondAmount:2});
|
||||
/** Original setup/forces/cards/alliances. Only unplayed card order, the actual
|
||||
* token lottery and face1 at its real turn-two forecast shuffle are selected. */
|
||||
function prepareOrgizCollectors(advanced:boolean,terrainType:'sand'|'rock',exhaustFirst=false) {
|
||||
const prefix=createDiscoveryOccupantsFixture({location:ORGIZ_PROCESSING_STATION,
|
||||
advanced,secondAmount:2,ordinaryBlowTerrain:'sand',forecastOne:true});
|
||||
const actions:TestingStationContestStep[]=[...prefix.actions];
|
||||
let game=advanceTestingStationContest(prefix.game,g=>g.turn===2&&g.phase===5&&g.active==='ecaz'&&clean(g),actions);
|
||||
const leave:Action={type:'move',from:`${ORGIZ_PROCESSING_STATION}:0`,
|
||||
territory:prefix.parent,sector:prefix.parentSector,amount:2,elite:0};
|
||||
actions.push({actor:'ecaz',action:leave});
|
||||
game=applyAction(game,'ecaz',leave);
|
||||
game=advanceTestingStationContest(game,g=>g.turn===2&&g.phase===8&&clean(g),actions);
|
||||
// Two unused printed blows with different sectors guarantee that at least
|
||||
// one new deposit is outside the next real Storm. Never edit a storm source.
|
||||
let firstSector:number|null=null;
|
||||
game=advanceTestingStationContest(game,g=>g.turn===3&&g.phase===0&&
|
||||
g.stormPending!==null&&clean(g),actions);
|
||||
const incomingStorm=(game.storm+game.stormPending!)%18;
|
||||
const blowKeys:string[]=[];
|
||||
for(let position=0;position<2;position++) {
|
||||
const wanted=position===0?terrainType:'sand';
|
||||
const index=game.spiceDeck.findIndex((card,i)=>i>=position&&'territory' in card&&
|
||||
!card.discovery&&territory(card.territory).type==='sand'&&card.sector!==firstSector);
|
||||
assert.ok(index>=position,'Original unused ordinary blows must supply the native Collection route.');
|
||||
!card.discovery&&territory(card.territory).type===wanted&&
|
||||
card.territory!==prefix.parent&&card.sector!==incomingStorm);
|
||||
assert.ok(index>=position,'Original unused blows must supply the native Collection route.');
|
||||
const card=game.spiceDeck.splice(index,1)[0];
|
||||
game.spiceDeck.splice(position,0,card);
|
||||
if('territory' in card) {
|
||||
firstSector=card.sector;
|
||||
blowKeys.push(`${card.territory}:${card.sector}`);
|
||||
}
|
||||
if('territory' in card)blowKeys.push(`${card.territory}:${card.sector}`);
|
||||
}
|
||||
game=advanceTestingStationContest(game,g=>g.turn===3&&g.phase===5&&clean(g),actions);
|
||||
const target=Object.entries(game.spice).find(([key,amount])=>blowKeys.includes(key)&&amount>=2&&
|
||||
splitLocation(key).sector!==game.storm&&game.players.every(p=>(p.forces[key]??0)===0));
|
||||
assert.ok(target,'An actual original blow must supply an empty non-storm shared collection deposit.');
|
||||
const destination=splitLocation(target[0]);
|
||||
assert.equal(game.storm,incomingStorm);
|
||||
const key=blowKeys[0];
|
||||
const amount=game.spice[key];
|
||||
assert.ok(amount>=4&&game.players.every(p=>(p.forces[key]??0)===0));
|
||||
const destination=splitLocation(key);
|
||||
const first=game.order.find(id=>id==='ecaz'||id==='guild')!;
|
||||
const shipped=new Set<string>();
|
||||
let beforeCollection:Game|null=null;
|
||||
for(let step=0;step<500&&game.turn===3&&!(game.phase===7&&game.decision?.kind==='ecazSpice');step++) {
|
||||
if(game.phase===5&&clean(game)&&game.movementRemaining?.length===1)
|
||||
beforeCollection=game;
|
||||
for(let step=0;step<500&&game.turn===3;step++) {
|
||||
if(game.phase===5&&clean(game)&&game.movementRemaining?.length===1&&shipped.size===2)break;
|
||||
let next:TestingStationContestStep|null;
|
||||
if(game.turn===3&&game.phase===5&&clean(game)&&game.active&&
|
||||
if(game.phase===5&&clean(game)&&game.active&&
|
||||
['ecaz','guild'].includes(game.active)&&!shipped.has(game.active)) {
|
||||
const actor=game.active;
|
||||
next={actor,action:{type:'ship',territory:destination.territory,sector:destination.sector,
|
||||
amount:1,elite:0,allyPayment:0}};
|
||||
amount:exhaustFirst&&actor===first?Math.ceil(amount/2):1,elite:0,allyPayment:0}};
|
||||
shipped.add(actor);
|
||||
} else next=nextTestingStationContestStep(game);
|
||||
assert.ok(next,`Original shared Collection has no continuation: ${JSON.stringify({
|
||||
status:game.status,turn:game.turn,phase:game.phase,decision:game.decision,
|
||||
active:game.active,shipped:[...shipped],target:target[0],
|
||||
})}`);
|
||||
assert.ok(next,'Original collector route must retain a legal continuation.');
|
||||
actions.push(next);
|
||||
game=applyAction(game,next.actor,next.action);
|
||||
}
|
||||
assert.equal(game.decision?.kind,'ecazSpice',JSON.stringify({
|
||||
turn:game.turn,phase:game.phase,shipped:[...shipped],target:target[0],
|
||||
spice:beforeCollection?.spice[target[0]],storm:beforeCollection?.storm,
|
||||
seats:beforeCollection?.players.map(p=>({id:p.id,ally:p.ally,forces:p.forces[target[0]]})),
|
||||
last:actions.slice(-12),
|
||||
}));
|
||||
assert.equal(game.phase,5);
|
||||
assert.equal(game.movementRemaining?.length,1);
|
||||
assert.equal(shipped.size,2);
|
||||
assert.equal(game.players.find(p=>p.id==='ecaz')!.forces[`${ORGIZ_PROCESSING_STATION}:0`],undefined);
|
||||
assert.equal(game.players.find(p=>p.id==='guild')!.forces[`${ORGIZ_PROCESSING_STATION}:0`],2);
|
||||
const ordinary=quoteSpiceCollection(game);
|
||||
const finalAction:TestingStationContestStep={actor:game.active!,action:{type:'endMovement'}};
|
||||
return {...prefix,game,actions,ordinary,finalAction,key,amount,first,
|
||||
ecaz:'ecaz',guild:'guild',owner:'guild'};
|
||||
}
|
||||
|
||||
/** Desert collectors enter the original shared allocation before any credit. */
|
||||
export function prepareSharedOrgizCollection() {
|
||||
const position=prepareOrgizCollectors(true,'sand');
|
||||
const game=applyAction(position.game,position.finalAction.actor,position.finalAction.action);
|
||||
assert.equal(game.decision?.kind,'ecazSpice');
|
||||
const pending=game.ecazCollection!.allocation!;
|
||||
const lot=pending.lots[pending.index];
|
||||
assert.equal(lot.territory,destination.territory);
|
||||
assert.ok(lot.amount>=2);
|
||||
return {...prefix,game,actions,lot,key:target[0],ecaz:'ecaz',guild:'guild',owner:'guild'};
|
||||
assert.equal(lot.territory,splitLocation(position.key).territory);
|
||||
assert.equal(lot.amount,4);
|
||||
return {...position,game,lot,actions:[...position.actions,position.finalAction]};
|
||||
}
|
||||
|
||||
/** Rock retains ordinary Collection: two allies may be present, but only
|
||||
* actual positive source receipts can fund an Orgiz transfer. */
|
||||
export function prepareOrdinaryPairedOrgizCollection(advanced=false,exhaustFirst=false) {
|
||||
const position=prepareOrgizCollectors(advanced,'rock',exhaustFirst);
|
||||
assert.deepEqual(position.ordinary.shared,[]);
|
||||
return position;
|
||||
}
|
||||
|
||||
export function settleSharedOrgiz(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { fighterCount } from '../game/advisors';
|
||||
import { gameDistance } from '../game/board';
|
||||
import { gameDistance, territory } 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';
|
||||
@@ -186,6 +186,25 @@ function selectedTokenLottery<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/** Select printed face1 at its actual forecast shuffle after turn-two Storm,
|
||||
* not by assigning a later forecast or movement-source field. */
|
||||
function selectedForecastOne<T>(enabled:boolean,operation:()=>T):T {
|
||||
if(!enabled)return operation();
|
||||
const descriptor=Object.getOwnPropertyDescriptor(crypto,'getRandomValues');
|
||||
const native=crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues=<V extends ArrayBufferView|null>(array:V):V=>{
|
||||
if(array instanceof Uint32Array&&array.length===1) {
|
||||
array[0]=0xffffffff;
|
||||
return array;
|
||||
}
|
||||
return Reflect.apply(native,crypto,[array]) as V;
|
||||
};
|
||||
try{return operation()}finally{
|
||||
if(descriptor)Object.defineProperty(crypto,'getRandomValues',descriptor);
|
||||
else Reflect.deleteProperty(crypto,'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Held-card staging only: a real unplayed Weather Control card moves to a hand. */
|
||||
export function holdTestingStationStormCard(
|
||||
game: Game,
|
||||
@@ -206,7 +225,7 @@ export function holdTestingStationStormCard(
|
||||
/** 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 },
|
||||
options: { location: OccupiedDiscoveryLocation; advanced?: boolean; secondAmount?: number; adjacentBlow?: boolean; ordinaryBlowTerrain?: 'sand' | 'rock'; forecastOne?: boolean },
|
||||
): DiscoveryOccupantsFixture {
|
||||
const advanced = options.advanced ?? false, location = options.location;
|
||||
const secondAmount = options.secondAmount ?? 1;
|
||||
@@ -240,10 +259,14 @@ export function createDiscoveryOccupantsFixture(
|
||||
const wormPosition = advanced ? 2 : 1;
|
||||
frontCard(game, 0, (candidate) => 'territory' in candidate && candidate.discovery === card.discovery);
|
||||
if (advanced)
|
||||
frontCard(game, 1, (candidate) => 'territory' in candidate && !candidate.discovery && candidate.territory !== placement.territory);
|
||||
frontCard(game, 1, (candidate) => 'territory' in candidate && !candidate.discovery &&
|
||||
candidate.territory !== placement.territory &&
|
||||
(!options.ordinaryBlowTerrain || territory(candidate.territory).type === options.ordinaryBlowTerrain));
|
||||
frontCard(game, wormPosition, (card) => 'worm' in card && !card.greatMaker);
|
||||
for (let position = wormPosition + 1; position < wormPosition + 3; position++)
|
||||
frontCard(game, position, (candidate) => 'territory' in candidate && !candidate.discovery && candidate.territory !== placement.territory);
|
||||
frontCard(game, position, (candidate) => 'territory' in candidate && !candidate.discovery &&
|
||||
candidate.territory !== placement.territory &&
|
||||
(!options.ordinaryBlowTerrain || territory(candidate.territory).type === options.ordinaryBlowTerrain));
|
||||
if (options.adjacentBlow)
|
||||
frontCard(game,wormPosition+1,candidate => 'territory' in candidate && !candidate.discovery &&
|
||||
gameDistance(game,`${placement.territory}:${placement.sector}`,`${candidate.territory}:${candidate.sector}`) === 1);
|
||||
@@ -340,17 +363,18 @@ export function createDiscoveryOccupantsFixture(
|
||||
'The first occupant entered through real next-turn free entry; no force was placed there directly.',
|
||||
);
|
||||
|
||||
game = advanceTestingStationContest(
|
||||
game,
|
||||
(state) =>
|
||||
state.turn === 2 &&
|
||||
state.phase === 1 &&
|
||||
!!state.nexus &&
|
||||
!state.spiceWindow &&
|
||||
!state.spiceResolution &&
|
||||
clean(state),
|
||||
actions,
|
||||
);
|
||||
game = selectedForecastOne(advanced && !!options.forecastOne, () =>
|
||||
advanceTestingStationContest(
|
||||
game,
|
||||
(state) =>
|
||||
state.turn === 2 &&
|
||||
state.phase === 1 &&
|
||||
!!state.nexus &&
|
||||
!state.spiceWindow &&
|
||||
!state.spiceResolution &&
|
||||
clean(state),
|
||||
actions,
|
||||
));
|
||||
const offer: TestingStationContestStep = { actor: ecaz, action: { type: 'alliance', target: guild } };
|
||||
const accept: TestingStationContestStep = { actor: guild, action: { type: 'alliance', target: ecaz } };
|
||||
for (const next of [offer, accept]) {
|
||||
|
||||
Reference in new issue
Block a user