Keep Atomics penalties on original targets after alliance changes

This commit is contained in:
Quintine committed 2026-10-14 21:06:32 +11:00
1 parent 1f38f12beb
commit 4bf6cfc875
15 files changed
+303 -181

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import {
applyAction,createGame,initializeFactionExpansionsGameForAudit,joinGame,newPlayer,
type Action,type Game,
} from '../game/engine';
import {nextSpiceBankerIncomeNativeStep,type SpiceBankerIncomeNativeStep} from './fixture-spice-banker-income';
const clean=(game:Game)=>!game.phaseOpening&&!game.response&&!game.decision;
/** Genuine printed setup, Terror placement, worm alliance, paid entry and
* detonation. Only original unplayed Spice Card order is controlled. */
export function createNativeAtomicsAllianceFixture(advanced=false,allied=true,
options:{laterAllianceToken?:boolean}={}) {
let game=createGame('NATIVEATOMICS',newPlayer('m','Moritani','moritani'),advanced,['ecaz']);
for(const [id,faction] of [['a','atreides'],['e','emperor'],['f','fremen']] as const)
joinGame(game,newPlayer(id,id,faction));
for(const p of game.players)if(!p.ready)game=applyAction(game,p.id,{type:'ready'});
const initial=structuredClone(game);
game=initializeFactionExpansionsGameForAudit(game);
const actions:SpiceBankerIncomeNativeStep[]=[];
const step=(actor:string,action:Action)=>{
game=applyAction(game,actor,action);actions.push({actor,action});
};
const advance=(stop:(game:Game)=>boolean)=>{
for(let i=0;i<1000&&!stop(game);i++) {
let next:SpiceBankerIncomeNativeStep|null;
if(game.decision?.kind==='moritaniSetup')
next={actor:'m',action:{type:'decision',territory:'carthag',sector:11}};
else if(game.decision?.kind==='moritaniPlacement')
next={actor:'m',action:{type:'decision',decline:true}};
else next=nextSpiceBankerIncomeNativeStep(game);
assert.ok(next,'A native Atomics control must retain legal continuation.');
step(next.actor,next.action);
}
assert.ok(stop(game),`Native Atomics did not reach its control: ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
};
const front=(position:number,predicate:(card:Game['spiceDeck'][number])=>boolean)=>{
const index=game.spiceDeck.findIndex((card,i)=>i>=position&&predicate(card));
assert.ok(index>=position,'The original unplayed card must remain in its deck.');
game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]);
};
advance(g=>g.status==='playing');
assert.equal(game.players.find(p=>p.id==='m')!.forces['carthag:11'],6);
const ordinaryBlows=advanced?2:1;
for(let i=0;i<ordinaryBlows;i++)front(i,c=>'territory' in c&&!c.discovery&&c.territory!=='sietch_tabr');
front(ordinaryBlows,c=>'worm' in c&&!c.greatMaker);
// Keep the two following original blows ordinary, and reserve a second real
// worm for the later Nexus rather than relying on a random chain of worms.
for(let i=ordinaryBlows+1;i<=ordinaryBlows+2;i++)
front(i,c=>'territory' in c&&!c.discovery&&c.territory!=='sietch_tabr');
front(ordinaryBlows+3,c=>'worm' in c&&!c.greatMaker);
advance(g=>g.turn===1&&g.decision?.kind==='moritaniPlacement');
const token=game.moritaniTerror!.tokens.find(t=>t.kind==='atomics'&&t.status==='available')!;
assert.ok(token);
step('m',{type:'decision',token:token.id,territory:'carthag'});
advance(g=>g.turn===2&&g.phase===1&&g.nexus&&clean(g)&&!g.spiceWindow&&!g.spiceResolution);
if(allied) {
step('m',{type:'alliance',target:'a'});
step('a',{type:'alliance',target:'m'});
}
advance(g=>g.turn===2&&g.phase===5&&g.active==='e'&&clean(g));
step('e',{type:'ship',territory:'carthag',sector:11,amount:1,elite:0,allyPayment:0});
assert.equal(game.decision?.kind,'moritaniTerror');
step('m',{type:'decision',reveal:true});
let laterToken:string|null=null;
if(options.laterAllianceToken) {
advance(g=>g.turn===2&&g.decision?.kind==='moritaniPlacement');
const robbery=game.moritaniTerror!.tokens.find(t=>t.kind==='robbery'&&t.status==='available')!;
assert.ok(robbery);
laterToken=robbery.id;
step('m',{type:'decision',token:robbery.id,territory:'arrakeen'});
}
advance(g=>g.turn===2&&g.phase===8&&clean(g));
front(0,c=>'worm' in c&&!c.greatMaker);
advance(g=>g.turn===3&&g.phase===1&&g.nexus&&clean(g)&&!g.spiceWindow&&!g.spiceResolution);
assert.equal(game.moritaniAtomics!.allyAtActivation,allied?'a':null);
assert.equal(game.players.find(p=>p.id==='m')!.tanks,6);
return {initial,game,actions,token:token.id,laterToken,allied,advanced};
}
export function advanceNativeAtomicsFixture(initial:Game,stop:(game:Game)=>boolean) {
let game=initial;
for(let i=0;i<1000&&!stop(game);i++) {
const next=nextSpiceBankerIncomeNativeStep(game);
assert.ok(next,'The original post-Atomics phase must have a legal continuation.');
game=applyAction(game,next.actor,next.action);
}
assert.ok(stop(game));
return game;
}
-71
View File
@@ -1,71 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MoritaniEntry, MoritaniTerrorSupply } from '../components/moritani-terror';
import { TerrorBoardMarkers } from '../components/terror-board-markers';
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { createTerrorState, placeTerror, revealTerror } from '../game/moritani-terror';
function fixture() {
const game = createGame('ATOMICS-CONTROLS', newPlayer('m', 'Moritani', 'moritani'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('f', 'Fremen', 'fremen'));
game.players.find((player) => player.id === 'm')!.ally = 'a';
game.players.find((player) => player.id === 'a')!.ally = 'm';
game.moritaniTerror = createTerrorState(() => 0);
const token = game.moritaniTerror.tokens.find((item) => item.kind === 'atomics')!;
game.moritaniTerror = placeTerror(game.moritaniTerror, token.id, 'arrakeen', 1);
return { game, token };
}
const supply = (view: GameView) =>
renderToStaticMarkup(createElement(MoritaniTerrorSupply, { game: view }));
const markers = (view: GameView) =>
renderToStaticMarkup(createElement('svg', null, createElement(TerrorBoardMarkers, {
tokens: view.moritaniTerror?.tokens ?? [], atomics: view.moritaniAtomics,
})));
void test('a placed hidden face stays private; only owner sees the Atomics reveal consequences', () => {
const { game } = fixture();
const owner = viewGame(game, 'm');
owner.decision = { kind: 'moritaniTerror', player: 'm', entrant: 'f', territory: 'arrakeen' };
owner.terrorEntry = {
entrant: 'f', territory: 'arrakeen', sector: 10, cause: 'shipment', stage: 'offer',
kind: 'atomics', canReveal: true, canOfferAlliance: false,
};
const offer = renderToStaticMarkup(createElement(MoritaniEntry, { game: owner, busy: false, act() {} }));
assert.match(offer, /Reveal Atomics/);
assert.match(offer, /every faction’s forces/);
assert.match(offer, /including Fremen reinforcements/);
assert.match(offer, /discards one random card per excess/);
const rival = viewGame(game, 'f');
assert.match(supply(rival), /Inspect Hidden Terror/);
assert.doesNotMatch(supply(rival), /Inspect Atomics|Atomics Aftermath/);
assert.match(markers(rival), /hidden Terror token in Arrakeen/);
assert.doesNotMatch(markers(rival), /Atomics Aftermath/);
assert.equal(renderToStaticMarkup(createElement(MoritaniEntry, { game: rival, busy: false, act() {} })), '');
});
void test('after revelation all seats see persistent Aftermath and its exact shipment and hand-limit consequences', () => {
const { game, token } = fixture();
game.moritaniTerror = revealTerror(game.moritaniTerror!, token.id);
game.moritaniAtomics = { territory: 'arrakeen', turn: 2, moritaniId: 'm',
allyAtActivation: 'a', alliancePolicy: 'unresolved' };
game.turn = 2;
game.players.find((player) => player.id === 'm')!.atomicsHandLimitPenalty = true;
game.players.find((player) => player.id === 'a')!.atomicsHandLimitPenalty = true;
for (const id of ['m', 'a', 'f']) {
const view = viewGame(game, id);
const notice = supply(view);
assert.match(notice, /Atomics Aftermath · Arrakeen/);
assert.match(notice, /From turn 2, no faction may ship forces/);
assert.match(notice, /including Fremen reinforcements/);
assert.match(notice, /ordinary movement and Sneak Attack remain possible/);
assert.match(notice, /Moritani and Atreides/);
assert.match(notice, /lasting hand-limit reduction of one/);
const board = markers(view);
assert.match(board, /Atomics Aftermath in Arrakeen: permanent shipment ban/);
assert.doesNotMatch(board, /hidden Terror token in Arrakeen/);
}
});
+26 -15
View File
@@ -154,7 +154,7 @@ void test('Sneak Attack remains a free reserve entry into an Atomics Aftermath s
assert.equal(done.moritaniAtomics?.territory, 'arrakeen');
});
void test('later Moritani alliance change and forged hand penalties reject atomically', () => {
void test('later Moritani alliances preserve the original penalties while forged targets still reject atomically', () => {
const { g } = fixture(false, true);
let state = applyAction(g, 'e', ship);
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
@@ -162,7 +162,17 @@ void test('later Moritani alliance change and forged hand penalties reject atomi
state.nexus = true;
state.spiceWindow = null;
state.spiceResolution = null;
rejected(state, 'm', { type: 'alliance', target: null }, /Atomics/);
const broken = applyAction(state, 'm', { type: 'alliance', target: null });
assert.equal(seat(broken, 'm').ally, null);
assert.equal(seat(broken, 'a').ally, null);
const offered = applyAction(broken, 'm', { type: 'alliance', target: 'e' });
const accepted = applyAction(offered, 'e', { type: 'alliance', target: 'm' });
assert.equal(seat(accepted, 'm').ally, 'e');
assert.equal(seat(accepted, 'e').ally, 'm');
assert.equal(seat(accepted, 'a').ally, null);
assert.deepEqual(accepted.moritaniAtomics, state.moritaniAtomics);
for (const [id, limit] of [['m',3],['a',3],['e',4]] as const)
assert.equal(viewGame(accepted,id).players.find(p=>p.id===id)!.handLimit,limit);
for (const mutate of [
(game: Game) => { delete seat(game, 'a').atomicsHandLimitPenalty; },
(game: Game) => { game.moritaniAtomics = { ...game.moritaniAtomics!, allyAtActivation: 'e' }; },
@@ -174,7 +184,7 @@ void test('later Moritani alliance change and forged hand penalties reject atomi
}
});
void test('a later Nexus filters impossible Moritani offers before bots can stall', () => {
void test('a later Nexus lets all four legal policies accept Moritani without moving its permanent penalty', () => {
const { g } = fixture(false, false);
let state = applyAction(g, 'e', ship);
state = normalizeAutomaticGame(applyAction(state, 'm', { type: 'decision', reveal: true }));
@@ -183,16 +193,17 @@ void test('a later Nexus filters impossible Moritani offers before bots can stal
state.spiceWindow = null;
state.spiceResolution = null;
state.ready = [];
state.allianceOffers = { m: 'e' };
for (const p of state.players) state.deck.push(...p.hand.splice(0));
seat(state, 'm').bot = 'Medium';
seat(state, 'e').bot = 'Medium';
rejected(state, 'e', { type: 'alliance', target: 'm' }, /Atomics/);
rejected(state, 'm', { type: 'alliance', target: 'e' }, /Atomics/);
assert.deepEqual(botActions(viewGame(state, 'm'))[0], { type: 'ready' });
assert.equal(botActions(viewGame(state, 'e')).some(action =>
action.type === 'alliance' && action.target === 'm'), false);
const withdrawn = applyAction(state, 'm', { type: 'alliance' });
assert.equal(withdrawn.allianceOffers.m, undefined);
assert.equal(withdrawn.players.find(p => p.id === 'm')!.ally, null);
const offered = applyAction(state, 'm', { type: 'alliance', target: 'e' });
for (const level of ['Easy','Medium','Hard','Brutal'] as const) {
const view = viewGame(offered, 'e');
view.players.find(player => player.id === 'e')!.bot = level;
const action = botActions(view)[0];
assert.ok(action);
const accepted = applyAction(offered,'e',action);
assert.equal(seat(accepted,'e').ally,'m');
assert.equal(seat(accepted,'m').ally,'e');
assert.equal(viewGame(accepted,'e').players.find(p=>p.id==='e')!.handLimit,4);
assert.equal(viewGame(accepted,'m').players.find(p=>p.id==='m')!.handLimit,3);
assert.equal(viewGame(accepted,'a').players.find(p=>p.id==='a')!.handLimit,4);
}
});
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {applyAction,normalizeAutomaticGame,viewGame,type Game} from '../game/engine';
import {createNativeAtomicsAllianceFixture,advanceNativeAtomicsFixture} from './fixture-moritani-atomics-native';
const limits=(game:Game)=>Object.fromEntries(game.players.map(p=>[p.id,
viewGame(game,p.id).players.find(seat=>seat.id===p.id)!.handLimit]));
for(const advanced of [false,true])for(const allied of [false,true]) {
void test(`${advanced?'Advanced':'Basic'} native Atomics ${allied?'original ally':'unallied'} targets persist after a real later worm Nexus`,()=>{
const f=createNativeAtomicsAllianceFixture(advanced,allied);
const original=structuredClone(f.game);
let game=f.game;
if(allied)game=applyAction(game,'m',{type:'alliance',target:null});
assert.deepEqual(limits(game),{m:3,a:allied?3:4,e:4,f:4});
const offered=applyAction(game,'m',{type:'alliance',target:'e'});
game=applyAction(offered,'e',{type:'alliance',target:'m'});
assert.deepEqual(game.players.map(p=>[p.id,p.ally]),[['m','e'],['a',null],['e','m'],['f',null]]);
assert.deepEqual(limits(game),{m:3,a:allied?3:4,e:4,f:4});
assert.deepEqual(game.moritaniAtomics,original.moritaniAtomics);
assert.deepEqual(game.players.map(p=>p.tanks),[6,0,1,0]);
assert.deepEqual(f.game,original);
});
}
void test('old unresolved Atomics receipts adopt exact original targets without mutating the saved input or replaying effects',()=>{
const f=createNativeAtomicsAllianceFixture(false,true);
const legacy=structuredClone(f.game);
Reflect.set(legacy.moritaniAtomics!,'alliancePolicy','unresolved');
const before=structuredClone(legacy);
const normalized=normalizeAutomaticGame(legacy);
assert.equal(normalized.moritaniAtomics!.alliancePolicy,'activation-targets');
assert.deepEqual(normalized.players,before.players);
assert.deepEqual(normalized.moritaniTerror,before.moritaniTerror);
assert.deepEqual(normalized.discard,before.discard);
const broken=applyAction(legacy,'m',{type:'alliance',target:null});
const offered=applyAction(broken,'m',{type:'alliance',target:'e'});
assert.equal(offered.moritaniAtomics!.alliancePolicy,'activation-targets');
assert.deepEqual(limits(offered),{m:3,a:3,e:4,f:4});
assert.deepEqual(legacy,before);
});
void test('native Enemy of My Enemy can replace the post-Atomics alliance without penalizing its new ally',()=>{
const f=createNativeAtomicsAllianceFixture(false,true,{laterAllianceToken:true});
let game=advanceNativeAtomicsFixture(f.game,g=>g.turn===3&&g.phase===5&&
g.active==='e'&&!g.phaseOpening&&!g.response&&!g.decision);
game=applyAction(game,'e',{type:'ship',territory:'arrakeen',sector:10,amount:1,elite:0,allyPayment:0});
game=applyAction(game,'m',{type:'decision',alliance:true});
game=advanceNativeAtomicsFixture(game,g=>g.pendingTerrorEntry?.stage==='allianceReply'&&!g.response);
game=applyAction(game,'e',{type:'decision',accept:true});
assert.deepEqual(limits(game),{m:3,a:3,e:4,f:4});
assert.equal(game.players.find(p=>p.id==='m')!.ally,'e');
assert.equal(game.players.find(p=>p.id==='a')!.ally,null);
assert.equal(game.players.find(p=>p.id==='e')!.ally,'m');
assert.deepEqual(game.moritaniAtomics,f.game.moritaniAtomics);
assert.equal(game.pendingTerrorEntry,null);
assert.equal(game.moritaniTerror!.tokens.find(token=>token.kind==='robbery')!.status,'available');
assert.equal(game.phase,5);
});
+1 -16
View File
@@ -1,8 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
atomicsAllianceStatus,
atomicsEffectiveHandLimit,
atomicsShipmentBlocked,
quoteMoritaniAtomics,
type AtomicsPlayer,
@@ -46,14 +44,8 @@ void test('no ally at activation: all owners lose typed forces in the territory,
assert.deepEqual(plan.handReductions,
[{ playerId: 'm', limit: 3, randomDiscards: 1 }]);
assert.deepEqual(plan.aftermath, { territory: 'arrakeen', turn: 3,
moritaniId: 'm', allyAtActivation: null, alliancePolicy: 'unresolved' });
moritaniId: 'm', allyAtActivation: null, alliancePolicy: 'activation-targets' });
assert.deepEqual(players, before);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'ix', null, 4), 4);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'm', null, 4), 3);
assert.equal(atomicsAllianceStatus(plan.aftermath, null), 'activation-alliance');
assert.equal(atomicsAllianceStatus(plan.aftermath, 'newAlly'), 'clarification-required');
assert.throws(() => atomicsEffectiveHandLimit(plan.aftermath, 'm', 'newAlly', 4),
/alliance change/);
});
void test('activation ally is reduced with Moritani; counts exceed-one overflow without selecting cards', () => {
@@ -68,13 +60,6 @@ void test('activation ally is reduced with Moritani; counts exceed-one overflow
{ playerId: 'm', limit: 3, randomDiscards: 3 },
{ playerId: 'choam', limit: 4, randomDiscards: 2 },
]);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'choam', 'choam', 5), 4);
assert.equal(atomicsEffectiveHandLimit(plan.aftermath, 'other', 'choam', 8), 8);
for (const next of [null, 'other']) {
assert.equal(atomicsAllianceStatus(plan.aftermath, next), 'clarification-required');
assert.throws(() => atomicsEffectiveHandLimit(plan.aftermath, 'other', next, 8),
/alliance change/);
}
});
void test('Aftermath blocks shipment by territory regardless of faction or sector, not other territories', () => {