Keep Atomics penalties on original targets after alliance changes
This commit is contained in:
1 parent
1f38f12beb
commit
4bf6cfc875
15 files changed
+303
-181
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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,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', () => {
|
||||
|
||||
Reference in new issue
Block a user