Handle advisor conversion after worm rides
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
ad0abfef98
commit
c608d6fea6
15 files changed
+507
-14
No files matched your search
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { actAdvisorWormRide as act, advisorWormRidePlayer as player,
|
||||
createAdvisorWormRideFixture as fixture } from './fixture-advisor-worm-ride-flip';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' :
|
||||
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
||||
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
|
||||
},
|
||||
});
|
||||
const {GameTable} = await import('../components/game-table');
|
||||
aliases.deregister();
|
||||
|
||||
/** Public setup is genuine; the fixture discloses its staged ride and conserved
|
||||
* force placement. The real ride action opens the response under test. */
|
||||
function response() {
|
||||
const f = fixture();
|
||||
const game = act(f.game,f.fremen,f.action);
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer,'wormRide');
|
||||
return {...f,game};
|
||||
}
|
||||
function table(game:Game, player:string, busy = false) {
|
||||
return renderToStaticMarkup(createElement(GameTable, {
|
||||
game:viewGame(game,player), busy,
|
||||
send:async () => { assert.fail('Rendering must not submit an action.'); },
|
||||
onExit() { assert.fail('Rendering must not leave the table.'); },
|
||||
}));
|
||||
}
|
||||
|
||||
void test('the real worm-ride conversion explains its committed source to every seat', () => {
|
||||
const f = response(), before = structuredClone(f.game);
|
||||
for (const actor of [f.bg,f.fremen,f.holder]) {
|
||||
const html = table(f.game,actor);
|
||||
assert.match(html,/Bene Gesserit token flip/);
|
||||
assert.match(html,/The worm ride and any collection are complete/);
|
||||
assert.match(html,/advisors are now alone and must become fighters before the original arrival continues/);
|
||||
assert.match(html,/Karama prevents only this conversion/);
|
||||
}
|
||||
assert.deepEqual(f.game,before);
|
||||
});
|
||||
|
||||
void test('only the eligible holder sees native Karama and allowance controls', () => {
|
||||
const f = response(), holder = viewGame(f.game,f.holder);
|
||||
assert.ok(holder.responseControls?.cancelCards.includes(f.karama));
|
||||
const html = table(f.game,f.holder);
|
||||
assert.match(html,/Cancel with Karama/);
|
||||
assert.match(html,/Allow this power/);
|
||||
assert.doesNotMatch(table(f.game,f.bg),/Cancel with Karama/);
|
||||
const busy = table(f.game,f.holder,true);
|
||||
assert.match(busy,/<button(?=[^>]*disabled="")[^>]*>Cancel with Karama/);
|
||||
});
|
||||
|
||||
void test('Fremen sees why summoning another worm waits for the current conversion', () => {
|
||||
const f = fixture();
|
||||
const holder = player(f.game,f.holder), fremen = player(f.game,f.fremen);
|
||||
const index = holder.hand.findIndex(card => card.id === f.karama);
|
||||
assert.ok(index >= 0 && fremen.hand.length > 0);
|
||||
// Conserved physical-card swap before the actual ride gives Fremen Karama.
|
||||
[holder.hand[index],fremen.hand[0]] = [fremen.hand[0],holder.hand[index]];
|
||||
const game = act(f.game,f.fremen,f.action), before = structuredClone(game);
|
||||
const html = table(game,f.fremen);
|
||||
assert.match(html,/Resolve the completed worm ride’s advisor conversion before summoning another worm/);
|
||||
assert.match(html,/<button(?=[^>]*disabled="")[^>]*>Spend Karama · summon Shai-Hulud/);
|
||||
assert.throws(() => act(game,f.fremen,{type:'card',mode:'special',card:f.karama,territory:'hagga_basin'}));
|
||||
assert.deepEqual(game,before);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createAdvisorWormRideFixture as fixture, actAdvisorWormRide as act,
|
||||
advisorWormRideInventory as inventory, advisorWormRidePlayer as player,
|
||||
passAdvisorWormRide as pass } from './fixture-advisor-worm-ride-flip';
|
||||
import { sandmasterRide, sandmasterWormGame } from './fixture-sandmaster-worm';
|
||||
|
||||
for (const canceled of [false,true]) void test(
|
||||
`native worm ride remains committed when source advisor conversion is ${canceled ? 'prevented' : 'allowed'}`, () => {
|
||||
const f = fixture(), original = structuredClone(f.game);
|
||||
let game = act(f.game,f.fremen,f.action);
|
||||
assert.deepEqual(f.game,original);
|
||||
assert.equal(game.response?.kind,'advisorFlip');
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer,'wormRide');
|
||||
assert.equal(player(game,f.fremen).forces[f.source] ?? 0,0);
|
||||
assert.equal(player(game,f.fremen).forces[f.destination],2);
|
||||
assert.equal(player(game,f.fremen).forces['hagga_basin:12'],1);
|
||||
assert.ok(isAdvisor(player(game,f.bg),f.origin));
|
||||
assert.ok(viewGame(game,f.holder).responseControls?.cancelCards.includes(f.karama));
|
||||
const committed = structuredClone(player(game,f.fremen));
|
||||
game = canceled ? act(game,f.holder,{type:'card',card:f.karama,mode:'cancel'}) : pass(game);
|
||||
assert.deepEqual(player(game,f.fremen),committed);
|
||||
assert.equal(isAdvisor(player(game,f.bg),f.origin),canceled);
|
||||
assert.equal(game.pendingAutomaticAdvisorFlips,undefined);
|
||||
assert.equal(game.decision?.kind,'wormRide');
|
||||
if (game.decision?.kind === 'wormRide') assert.equal(game.decision.territory,f.second);
|
||||
assert.deepEqual(game.wormRides,[]);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length,Number(canceled));
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
},
|
||||
);
|
||||
|
||||
void test('a partial ride leaves Fremen in the source and produces no alone conversion', () => {
|
||||
const f=fixture({partial:true}), game=act(f.game,f.fremen,f.action);
|
||||
assert.equal(player(game,f.fremen).forces[f.source],1);
|
||||
assert.equal(player(game,f.fremen).forces[f.destination],1);
|
||||
assert.ok(isAdvisor(player(game,f.bg),f.origin));
|
||||
assert.equal(game.response?.automaticAdvisorFlip,undefined);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
|
||||
void test('source conversion precedes the real destination intrusion and resumes it once', () => {
|
||||
const f=fixture({intrusion:true});
|
||||
let game=act(f.game,f.fremen,f.action);
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer,'wormRide');
|
||||
assert.equal(game.decision,null);
|
||||
game=pass(game);
|
||||
assert.equal(game.decision?.kind,'intrusion');
|
||||
if (game.decision?.kind === 'intrusion') assert.equal(game.decision.territory,'imperial_basin');
|
||||
assert.equal(player(game,f.fremen).forces[f.destination],2);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
});
|
||||
|
||||
void test('Fremen cannot nest a special worm during the committed source conversion', () => {
|
||||
const f=fixture(), source=structuredClone(f.game);
|
||||
// Transfer the remaining physical Karama from the original deck to Fremen;
|
||||
// the separately held Karama still keeps the advisor response open.
|
||||
const index=source.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [card]=source.deck.splice(index,1); player(source,f.fremen).hand.push(card);
|
||||
const cards=inventory(source);
|
||||
const summon={type:'card',mode:'special',card:card.id,territory:'the_great_flat'};
|
||||
let game=act(source,f.fremen,f.action);
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer,'wormRide');
|
||||
const before=structuredClone(game);
|
||||
assert.throws(() => act(game,f.fremen,summon));
|
||||
assert.deepEqual(game,before);
|
||||
assert.ok(player(game,f.fremen).hand.some(candidate => candidate.id === card.id));
|
||||
assert.equal(player(game,f.fremen).specialKaramaUsed ?? false,false);
|
||||
game=pass(game);
|
||||
assert.equal(game.decision?.kind,'wormRide');
|
||||
assert.equal(player(game,f.fremen).forces[f.destination],2);
|
||||
game=act(game,f.fremen,summon);
|
||||
assert.equal(player(game,f.fremen).specialKaramaUsed,true);
|
||||
assert.equal(game.discard.filter(candidate => candidate.id === card.id).length,1);
|
||||
assert.deepEqual(inventory(game),cards);
|
||||
});
|
||||
|
||||
void test('all four bot profiles can resolve the original worm-ride conversion legally', () => {
|
||||
const f=fixture(), pending=act(f.game,f.fremen,f.action);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(pending,f.holder);
|
||||
view.players.find(seat => seat.id === f.holder)!.bot=difficulty;
|
||||
const action=botActions(view)[0]; assert.ok(action);
|
||||
const game=pass(act(structuredClone(pending),f.holder,action));
|
||||
assert.equal(game.decision?.kind,'wormRide');
|
||||
if (game.decision?.kind === 'wormRide') assert.equal(game.decision.territory,f.second);
|
||||
assert.equal(player(game,f.fremen).forces[f.destination],2);
|
||||
assert.deepEqual(inventory(game),f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
for (const canceled of [false,true]) void test(
|
||||
`Sandmaster's native worm-ride collection occurs once when origin conversion is ${canceled ? 'prevented' : 'allowed'}`, () => {
|
||||
// This fixture uses genuine Leader Skills setup and a staged, conserved
|
||||
// ride. Move the actual BG force from the destination to the ride origin,
|
||||
// and transfer one physical Karama to Fremen for the response.
|
||||
const source=sandmasterWormGame(true,true), bg=player(source,'h'), rider=player(source,'p');
|
||||
bg.forces={'wind_pass:14':1}; bg.reserves=19; bg.advisors={wind_pass:{}};
|
||||
const index=source.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [card]=source.deck.splice(index,1); rider.hand.push(card);
|
||||
const cards=inventory(source), beforeSpice=rider.spice, beforePile=source.spice['red_chasm:7'];
|
||||
let game=act(source,'p',sandmasterRide());
|
||||
assert.equal(game.response?.automaticAdvisorFlip?.producer,'wormRide');
|
||||
assert.equal(player(game,'p').spice,beforeSpice+1);
|
||||
assert.equal(game.spice['red_chasm:7'],beforePile-1);
|
||||
assert.equal(game.log.filter(entry => entry.automatic?.name === 'Sandmaster collection').length,1);
|
||||
game=canceled ? act(game,'p',{type:'card',card:card.id,mode:'cancel'}) : pass(game);
|
||||
assert.equal(player(game,'p').spice,beforeSpice+1);
|
||||
assert.equal(game.spice['red_chasm:7'],beforePile-1);
|
||||
assert.equal(player(game,'p').forces['red_chasm:7'],4);
|
||||
assert.equal(isAdvisor(player(game,'h'),'wind_pass'),canceled);
|
||||
assert.equal(game.log.filter(entry => entry.automatic?.name === 'Sandmaster collection').length,1);
|
||||
assert.deepEqual(inventory(game),cards);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
AutomaticAdvisorFlipError, quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip,
|
||||
type AutomaticAdvisorFlipContext, type AutomaticAdvisorFlipSource, type AutomaticAdvisorWormRide,
|
||||
} from '../game/advisor-automatic-flip';
|
||||
|
||||
type WormSource = Extract<AutomaticAdvisorFlipSource, {producer:'wormRide'}>;
|
||||
|
||||
/** A native Fremen ride has already deposited its physical forces in Hagga.
|
||||
* Bene Gesserit's original Imperial Basin advisors are now alone. */
|
||||
function source() {
|
||||
const ride:AutomaticAdvisorWormRide = {event:'ride-1',player:'f',origin:'imperial_basin',
|
||||
destination:'hagga_basin',sector:12,amount:3,elite:1};
|
||||
const game:AutomaticAdvisorFlipContext = {status:'playing',advanced:true,turn:2,phase:1,
|
||||
players:[
|
||||
{id:'b',faction:'beneGesserit',forces:{'imperial_basin:9':2,'imperial_basin:10':1},
|
||||
advisors:{imperial_basin:{aloneFlipHold:'flip-1'}}},
|
||||
{id:'f',faction:'fremen',forces:{'hagga_basin:12':3},elites:{forces:{'hagga_basin:12':1}}},
|
||||
{id:'a',faction:'atreides',forces:{}},
|
||||
]};
|
||||
const flip:WormSource = {event:'flip-1',owner:'b',territory:'imperial_basin',turn:2,phase:1,
|
||||
forces:{'imperial_basin:9':2,'imperial_basin:10':1},producer:'wormRide',wormRide:ride};
|
||||
return {game,flip};
|
||||
}
|
||||
|
||||
void test('native committed worm ride binds its held advisor group without mutating it', () => {
|
||||
const {game,flip}=source(), before=structuredClone({game,flip});
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(game,flip));
|
||||
assert.deepEqual({game,flip},before);
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(game,JSON.parse(JSON.stringify(flip)) as WormSource));
|
||||
});
|
||||
|
||||
void test('worm ride rejects altered or malformed identity, origin, destination and force custody', () => {
|
||||
const changes:Array<(game:AutomaticAdvisorFlipContext,flip:WormSource)=>void> = [
|
||||
game => {game.phase=0;}, game => {game.advanced=false;}, game => {game.turn++;},
|
||||
(_game,flip) => {flip.wormRide.event='';},
|
||||
(_game,flip) => {flip.wormRide.player='missing';},
|
||||
(game) => {game.players[1].faction='atreides';},
|
||||
(_game,flip) => {flip.wormRide.origin='invalid';},
|
||||
(_game,flip) => {flip.wormRide.destination='invalid';},
|
||||
(_game,flip) => {flip.wormRide.destination=flip.wormRide.origin;},
|
||||
(_game,flip) => {flip.wormRide.sector=10;},
|
||||
(_game,flip) => {flip.wormRide.amount=0;},
|
||||
(_game,flip) => {flip.wormRide.amount=4;},
|
||||
(_game,flip) => {flip.wormRide.elite=4;},
|
||||
(game) => {game.players[1].elites!.forces={'hagga_basin:12':0};},
|
||||
(game) => {game.players[1].elites!.forces={'hagga_basin:12':3};},
|
||||
(_game,flip) => {flip.territory='arrakeen';},
|
||||
(game) => {game.players[0].forces={'imperial_basin:9':1};},
|
||||
(game) => {game.players[2].forces={'imperial_basin:11':1};},
|
||||
];
|
||||
for (const change of changes) {
|
||||
const {game,flip}=source(); change(game,flip); const before=structuredClone({game,flip});
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(game,flip),AutomaticAdvisorFlipError);
|
||||
assert.deepEqual({game,flip},before);
|
||||
}
|
||||
const {game,flip}=source();
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(game,{...flip,wormRide:{...flip.wormRide,extra:true}} as WormSource),AutomaticAdvisorFlipError);
|
||||
});
|
||||
|
||||
void test('worm ride quote uses public force presence and does not inspect hidden hands', () => {
|
||||
const {game,flip}=source();
|
||||
delete game.players[0].advisors!.imperial_basin.aloneFlipHold;
|
||||
const quote=quoteAutomaticAdvisorFlips(game);
|
||||
assert.deepEqual(quote,[{player:'b',territory:'imperial_basin',
|
||||
forces:{'imperial_basin:9':2,'imperial_basin:10':1}}]);
|
||||
game.players[0].advisors!.imperial_basin.aloneFlipHold=flip.event;
|
||||
for (const player of game.players) for (const field of ['hand','leaders','traitors','spice'])
|
||||
Object.defineProperty(player,field,{get() {throw new Error(`Private ${field} inspected`);}});
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(game,flip));
|
||||
});
|
||||
@@ -156,11 +156,13 @@ void test('advisor-only forces cannot play Family Atomics', () => {
|
||||
army(g, 'a', { 'imperial_basin:10': 1 });
|
||||
army(g, 'b', { 'imperial_basin:10': 1 }, ['imperial_basin']);
|
||||
g.phase = 0;
|
||||
g.turn = 2;
|
||||
g.stormPending = 1;
|
||||
const atomics = baseDeck().find((c) => c.effect === 'atomics')!;
|
||||
g.players[1].hand = [atomics];
|
||||
setBattleHand(g, 'b', [atomics.id]);
|
||||
assert.throws(
|
||||
() => applyAction(g, 'b', { type: 'card', card: atomics.id }),
|
||||
/forces on or adjacent/,
|
||||
/fighters on or adjacent/,
|
||||
);
|
||||
});
|
||||
void test('direct shipments match existing advisors, while a new direct shipment enters as fighters', () => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { actAdvisorPreparation as act, advisorPreparationInventory as inventory,
|
||||
advisorPreparationPlayer as player, nextAdvisorPreparationStep } from './fixture-advisor-preparation';
|
||||
|
||||
export const actAdvisorWormRide = act;
|
||||
export const advisorWormRidePlayer = player;
|
||||
export const advisorWormRideInventory = inventory;
|
||||
|
||||
/** Genuine Basic-faction Advanced public start, prediction, native placements
|
||||
* and Traitors. Only the unopened starting Treachery deal is selected. The
|
||||
* later phase, current ride, queued ride and conserved force/stance positions
|
||||
* are explicitly staged to isolate the source departure. */
|
||||
export function createAdvisorWormRideFixture(options: {intrusion?: boolean; partial?: boolean} = {}) {
|
||||
const bg = 'beneGesserit', fremen = 'fremen', holder = 'atreides';
|
||||
let game = createGame('ADVISORWORMRIDE', newPlayer(bg,'Bene Gesserit','beneGesserit'),true);
|
||||
joinGame(game,newPlayer(fremen,'Fremen','fremen'));
|
||||
joinGame(game,newPlayer(holder,'Atreides','atreides'));
|
||||
for (const seat of game.players) game = act(game,seat.id,{type:'ready'});
|
||||
game = act(game,bg,{type:'start',advancedPreview:true});
|
||||
assert.equal(game.status,'setup');
|
||||
const originalCards = inventory(game);
|
||||
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0,2);
|
||||
assert.equal(ordinary.length,2);
|
||||
for (const [position,card] of [ordinary[0],ordinary[1],karama].entries()) {
|
||||
const index = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(index >= position);
|
||||
[game.deck[position],game.deck[index]] = [game.deck[index],game.deck[position]];
|
||||
}
|
||||
for (let count=0; game.status === 'setup' && count<100; count++) {
|
||||
if (game.setupStage === 'forces' && player(game,fremen).reserves === 20) {
|
||||
game = act(game,fremen,{type:'fremenSetup',placements:{sietch_tabr:10}});
|
||||
continue;
|
||||
}
|
||||
const step = nextAdvisorPreparationStep(game); assert.ok(step);
|
||||
game = act(game,step.actor,step.action);
|
||||
}
|
||||
assert.equal(game.status,'playing'); assert.equal(game.advanced,true);
|
||||
assert.ok(player(game,holder).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(inventory(game),originalCards);
|
||||
const bgSeat = player(game,bg), f = player(game,fremen), a = player(game,holder);
|
||||
bgSeat.forces = {'red_chasm:7':2, ...(options.intrusion ? {'imperial_basin:10':1} : {})};
|
||||
bgSeat.reserves = 20 - (options.intrusion ? 3 : 2); bgSeat.tanks = 0;
|
||||
bgSeat.advisors = {red_chasm:{}};
|
||||
f.forces = {'red_chasm:7':2, 'hagga_basin:12':1}; f.reserves = 17; f.tanks = 0;
|
||||
f.elites = {reserves:3,tanks:0,forces:{},revived:0};
|
||||
a.forces = {}; a.reserves = 20; a.tanks = 0;
|
||||
Object.assign(game,{phase:1,turn:2,nexus:true,active:null,ready:[],storm:18,
|
||||
stormPending:null,phaseOpening:null,response:null,truthtrance:null,
|
||||
spiceWindow:null,spiceResolution:null,spiceSequence:{pile:1,skipped:[]},
|
||||
wormRides:['hagga_basin'],decision:{kind:'wormRide',player:fremen,territory:'red_chasm'}});
|
||||
const action:Action = {type:'decision',accept:true,territory:'imperial_basin',sector:10,
|
||||
forces:{'red_chasm:7':options.partial ? 1 : 2},eliteForces:{'red_chasm:7':0}};
|
||||
return {game,bg,fremen,holder,karama:karama.id,action,origin:'red_chasm',source:'red_chasm:7',
|
||||
destination:'imperial_basin:10',second:'hagga_basin',originalCards,partial:!!options.partial,
|
||||
intrusion:!!options.intrusion};
|
||||
}
|
||||
|
||||
/** Pass only the committed source conversion; preserve any next decision. */
|
||||
export function passAdvisorWormRide(source:Game):Game {
|
||||
let game = source;
|
||||
for (let count=0; game.response?.automaticAdvisorFlip && count<30; count++) {
|
||||
const actor = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(actor);
|
||||
game = act(game,actor.id,{type:'passResponse'});
|
||||
}
|
||||
assert.ok(!game.response?.automaticAdvisorFlip); return game;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export function nexusAdvisorFixture(
|
||||
seatIds: options.seatIds,
|
||||
phase: 5,
|
||||
});
|
||||
// The controlled jump to Movement skips Charity's normal phase-end cleanup.
|
||||
delete g.charityOrder;
|
||||
const [owner, opponent] = g.players;
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('beneGesserit');
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createGame,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
initializeBaseGameForAudit,
|
||||
initializeHomeworldGameForAudit,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
@@ -15,7 +16,7 @@ import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
|
||||
function setup(reserves = 11, advanced = true, withKarama = true) {
|
||||
function setup(reserves = 11, advanced = true, withKarama = true, withHomeworlds = true) {
|
||||
let g = createGame(
|
||||
'SPIRITUALHOMEWORLD',
|
||||
newPlayer('atreides', 'Atreides', 'atreides'),
|
||||
@@ -23,10 +24,11 @@ function setup(reserves = 11, advanced = true, withKarama = true) {
|
||||
[],
|
||||
);
|
||||
joinGame(g, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit'));
|
||||
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
|
||||
if (withHomeworlds)
|
||||
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
|
||||
for (const player of g.players)
|
||||
g = applyAction(g, player.id, { type: 'ready' });
|
||||
g = initializeHomeworldGameForAudit(g);
|
||||
g = withHomeworlds ? initializeHomeworldGameForAudit(g) : initializeBaseGameForAudit(g);
|
||||
for (let i = 0; g.status === 'setup' && i < 30; i++) {
|
||||
let moved = false;
|
||||
for (const player of g.players) {
|
||||
@@ -219,11 +221,7 @@ void test('advisor group size rejects malformed requests before cards, reserves
|
||||
});
|
||||
|
||||
void test('absent Homeworld module preserves ordinary one-force accompaniment and rejects two', () => {
|
||||
const g = setup(11).g;
|
||||
// This parity fixture deliberately removes the whole optional module,
|
||||
// including its setup evidence; it does not represent a legal live action.
|
||||
g.homeworlds = null;
|
||||
delete g.homeworldOccupationHistory;
|
||||
const g = setup(11, true, true, false).g;
|
||||
const offered = ship(g);
|
||||
reject(offered, 'beneGesserit', {
|
||||
type: 'decision',
|
||||
|
||||
Reference in new issue
Block a user