Integrate Caladan victory placement and entry reactions
This commit is contained in:
1 parent
97edc3eac8
commit
2e4aba9630
18 files changed
+789
-76
No files matched your search
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { CaladanReinforcement } from '../components/caladan-reinforcement';
|
||||
import { caladanReinforcementCanAct, caladanReinforcementChoice } from '../game/caladan-reinforcement-options';
|
||||
import {
|
||||
caladanVictoryFixture, prepareVictoryBattle, commitVictoryPlans, finishVictoryCalls,
|
||||
victoryPlayer,
|
||||
} from './fixture-caladan-victory';
|
||||
|
||||
// Existing controlled victory source: genuine Homeworld setup/deal, staged
|
||||
// later phase/armies/wallets and storm position, then real battle packets yield
|
||||
// the choice. This is not original-game progression or an HMS entry programme.
|
||||
// SSR and shared-adapter checks do not operate the stateful React chooser.
|
||||
const sources = new Map<boolean, Game>();
|
||||
function choiceGame(advanced = true): Game {
|
||||
let source = sources.get(advanced);
|
||||
if (!source) {
|
||||
const initial = caladanVictoryFixture({ advanced });
|
||||
initial.storm = 13;
|
||||
source = finishVictoryCalls(commitVictoryPlans(prepareVictoryBattle(initial)));
|
||||
assert.equal(source.decision?.kind, 'caladanReinforcement');
|
||||
assert.equal(source.lastBattleContext?.winner, 'a');
|
||||
sources.set(advanced, source);
|
||||
}
|
||||
return structuredClone(source);
|
||||
}
|
||||
function html(game: Game, owner = 'a', busy = false) {
|
||||
return renderToStaticMarkup(createElement(CaladanReinforcement, { game: viewGame(game, owner), busy,
|
||||
act() { assert.fail('Rendering the chooser must not submit a decision.'); } }));
|
||||
}
|
||||
|
||||
void test('the engine-owned Caladan choice keeps its default target and original exact one-force adapter packet', () => {
|
||||
const game = choiceGame(), view = viewGame(game, 'a'), offer = view.caladanReinforcement;
|
||||
assert.ok(offer);
|
||||
const target = offer.destinations.find(destination => !destination.blocked); assert.ok(target);
|
||||
assert.ok(target.id.startsWith('hagga_basin:'));
|
||||
const markup = html(game);
|
||||
assert.ok(markup.includes(`<option value="${target.id}" selected="">`));
|
||||
const choice = caladanReinforcementChoice(view, target.id);
|
||||
assert.deepEqual(choice, { blocked: null, action: { type: 'decision', event: offer.event,
|
||||
destination: target.id, amount: 1 } });
|
||||
assert.ok(choice.action);
|
||||
const before = victoryPlayer(game, 'a');
|
||||
const after = applyAction(game, 'a', choice.action), placed = victoryPlayer(after, 'a');
|
||||
assert.equal(placed.reserves, before.reserves - 1);
|
||||
assert.equal(placed.forces[target.id], (before.forces[target.id] ?? 0) + 1);
|
||||
});
|
||||
|
||||
void test('arrival guidance distinguishes placement from shipment and preserves the original Face Dance explanation', () => {
|
||||
const game = choiceGame(), before = structuredClone(game);
|
||||
for (let render = 0; render < 2; render++) {
|
||||
const markup = html(game);
|
||||
assert.match(markup, /original surviving winner at the original battle location/);
|
||||
assert.match(markup, /including inside the Hidden Mobile Stronghold/);
|
||||
assert.match(markup, /direct-shipment ban does not block this placement/);
|
||||
assert.match(markup, /No spice, shipment, movement or revival allowance is spent/);
|
||||
assert.match(markup, /no Spiritual Advisor opportunity is created/);
|
||||
assert.match(markup, /Storm-sector entry remains unavailable/);
|
||||
assert.match(markup, /Advanced Bene Gesserit Intrusion resolves first/);
|
||||
assert.match(markup, /Provisional arrival classification: eligible Ambassador and Terror reactions then follow their original arrival order/);
|
||||
assert.match(markup, /aria-label="Provisional Face Dance order"/);
|
||||
assert.match(markup, /joins the original victorious army before any later Face Dance/);
|
||||
assert.match(markup, /does not create another victory or grant another Caladan reinforcement/);
|
||||
}
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('actual storm and foreign targets stay unavailable and another viewer receives no owned chooser', () => {
|
||||
const game = choiceGame(), view = viewGame(game, 'a'), offer = view.caladanReinforcement;
|
||||
assert.ok(offer);
|
||||
const storm = offer.destinations.find(destination => destination.id === 'hagga_basin:13'); assert.ok(storm);
|
||||
assert.match(storm.blocked ?? '', /storm/i);
|
||||
assert.equal(caladanReinforcementChoice(view, storm.id).action, null);
|
||||
assert.equal(caladanReinforcementChoice(view, 'carthag:11').action, null);
|
||||
assert.match(html(game), /<option[^>]*value="hagga_basin:13"[^>]*disabled=""/);
|
||||
const rival = viewGame(game, 'g');
|
||||
assert.equal(caladanReinforcementCanAct(rival), false);
|
||||
assert.equal(caladanReinforcementChoice(rival, 'hagga_basin:12').action, null);
|
||||
assert.equal(html(game, 'g'), '');
|
||||
});
|
||||
|
||||
void test('busy disables the existing chooser while Basic keeps the placement guidance without an Advanced Intrusion claim', () => {
|
||||
const game = choiceGame(), busy = html(game, 'a', true);
|
||||
assert.match(busy, /<select[^>]*disabled=""/);
|
||||
assert.equal((busy.match(/<button[^>]*disabled=""/g) ?? []).length, 2);
|
||||
assert.match(busy, /Provisional arrival classification/);
|
||||
const basic = choiceGame(false), markup = html(basic);
|
||||
assert.match(markup, /no Spiritual Advisor opportunity is created/);
|
||||
assert.match(markup, /Provisional arrival classification/);
|
||||
assert.doesNotMatch(markup, /Advanced Bene Gesserit Intrusion resolves first/);
|
||||
const view = viewGame(basic, 'a');
|
||||
assert.ok(caladanReinforcementChoice(view, 'hagga_basin:12').action);
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { fighterCount } from '../game/advisors';
|
||||
import { caladanReinforcementChoice } from '../game/caladan-reinforcement-options';
|
||||
import { TERROR_KINDS } from '../game/moritani-terror';
|
||||
import {
|
||||
actCaladanArrival as act, advanceCaladanArrival as advance,
|
||||
allowCaladanArrivalResponses as allow, caladanArrivalInventory as inventory,
|
||||
caladanArrivalPlayer as p, caladanArrivalReload as reload,
|
||||
createCaladanArrivalFixture as fixture,
|
||||
} from './fixture-caladan-arrival';
|
||||
|
||||
function reject(g: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, actor, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
function completed(g: Game, event: string) {
|
||||
assert.equal(g.homeworldVictoryReinforcement!.event, event);
|
||||
assert.equal(g.homeworldVictoryReinforcement!.stage, 'complete');
|
||||
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed, true);
|
||||
assert.equal(p(g,'a').reserves, 5);
|
||||
inventory(g);
|
||||
}
|
||||
|
||||
void test('controlled Caladan battle sources record actual plans, calls and the original free addition', () => {
|
||||
const f = fixture({bg:true});
|
||||
assert.match(f.disclosure, /Controlled.*not public acquisition/);
|
||||
assert.equal(f.sourceCount, f.actions.length);
|
||||
assert.equal(f.actions.filter(s => s.action.type === 'chooseBattle').length,1);
|
||||
assert.equal(f.actions.filter(s => s.action.type === 'battlePlan').length,2);
|
||||
assert.equal(f.actions.filter(s => s.action.type === 'traitorCall').length,2);
|
||||
assert.equal(f.source.homeworldVictoryReinforcement,undefined);
|
||||
const before = structuredClone(p(f.game,'a'));
|
||||
const g = act(reload(f.game),'a',f.action);
|
||||
assert.equal(p(g,'a').reserves,before.reserves-1);
|
||||
assert.equal(p(g,'a').forces[f.destination],before.forces[f.destination]+1);
|
||||
for (const field of ['spice','shipped','moved','battleLosses'] as const)
|
||||
assert.deepEqual(p(g,'a')[field],before[field]);
|
||||
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
|
||||
assert.equal(g.homeworldRevivalReturn,undefined);
|
||||
inventory(g);
|
||||
});
|
||||
|
||||
for (const flip of [false,true])
|
||||
void test(`Caladan Advanced Intrusion ${flip ? 'flip' : 'keep'} waits for the original arrival`, () => {
|
||||
const f = fixture({bg:true});
|
||||
let g = act(reload(f.game),'a',f.action);
|
||||
assert.equal(g.decision?.kind,'intrusion');
|
||||
assert.equal(g.decision?.player,'bg');
|
||||
assert.equal(g.decision?.kind === 'intrusion' && g.decision.victoryEvent,f.event);
|
||||
assert.equal(fighterCount(p(g,'bg'),f.territory),1);
|
||||
reject(g,'a',f.action);
|
||||
g = act(g,'bg',{type:'decision',accept:flip});
|
||||
if (flip) {
|
||||
assert.equal(g.response?.kind,'advisorFlip');
|
||||
assert.equal(g.response?.advisorVictoryEvent,f.event);
|
||||
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
|
||||
g = allow(g);
|
||||
}
|
||||
g = advance(g);
|
||||
assert.equal(fighterCount(p(g,'bg'),f.territory),flip ? 0 : 1);
|
||||
assert.equal(p(g,'bg').forces[f.destination],1);
|
||||
assert.equal(p(g,'a').forces[f.destination],3);
|
||||
completed(g,f.event);
|
||||
reject(g,'a',f.action);
|
||||
});
|
||||
|
||||
void test('Caladan Terror decline leaves the original hidden token and completes once', () => {
|
||||
const f = fixture({terror:'robbery'});
|
||||
let g = act(reload(f.game),'a',f.action);
|
||||
assert.equal(g.pendingTerrorEntry?.cause,'caladanReinforcement');
|
||||
assert.equal(g.pendingTerrorEntry?.victoryEvent,f.event);
|
||||
assert.equal(g.pendingTerrorEntry?.entrant,'a');
|
||||
assert.equal(g.pendingTerrorEntry?.amount,1);
|
||||
assert.equal(g.pendingTerrorEntry?.elite,0);
|
||||
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
|
||||
reject(g,'a',{type:'decision',decline:true});
|
||||
g = act(g,'m',{type:'decision',decline:true});
|
||||
g = advance(g);
|
||||
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'placed');
|
||||
assert.equal(p(g,'a').forces[f.destination],3);
|
||||
completed(g,f.event);
|
||||
});
|
||||
|
||||
for (const kind of TERROR_KINDS)
|
||||
void test(`Caladan original Terror ${kind} resolves before its reinforcement completes`, () => {
|
||||
const f = fixture({terror:kind});
|
||||
let g = act(reload(f.game),'a',f.action);
|
||||
const before = reload(g), entry = g.pendingTerrorEntry!;
|
||||
assert.equal(entry.stage,'offer');
|
||||
assert.equal(entry.victoryEvent,f.event);
|
||||
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
|
||||
g = act(g,'m',{type:'decision',reveal:true});
|
||||
if (kind === 'robbery') {
|
||||
assert.equal(g.pendingTerrorEntry?.stage,'robbery');
|
||||
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
|
||||
g = act(g,'m',{type:'decision',choice:'spice'});
|
||||
const stolen = Math.ceil(p(before,'a').spice/2);
|
||||
assert.equal(p(g,'a').spice,p(before,'a').spice-stolen);
|
||||
assert.equal(p(g,'m').spice,p(before,'m').spice+stolen);
|
||||
} else if (kind === 'sneakAttack') {
|
||||
assert.equal(g.pendingTerrorEntry?.stage,'sneakAttack');
|
||||
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
|
||||
g = act(g,'m',{type:'decision',amount:1});
|
||||
assert.equal(p(g,'m').reserves,p(before,'m').reserves-1);
|
||||
assert.equal(p(g,'m').forces[f.destination],1);
|
||||
assert.equal(p(g,'m').spice,p(before,'m').spice);
|
||||
} else if (kind === 'sabotage') {
|
||||
g = advance(g);
|
||||
assert.equal(g.pendingTerrorEntry?.stage,'gift');
|
||||
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
|
||||
assert.ok(g.discard.some(c => c.id === f.heldCard));
|
||||
assert.equal(p(g,'a').hand.length,0);
|
||||
g = act(g,'m',{type:'decision',decline:true});
|
||||
} else if (kind === 'assassination') {
|
||||
const deadBefore = p(before,'a').leaders.filter(l => l.dead).length;
|
||||
assert.equal(p(g,'a').leaders.filter(l => l.dead).length,deadBefore+1);
|
||||
const victim = p(g,'a').leaders.find(l => l.dead && !p(before,'a').leaders.find(old => old.id === l.id)!.dead)!;
|
||||
assert.equal(p(g,'m').spice,p(before,'m').spice+victim.strength);
|
||||
} else if (kind === 'extortion') {
|
||||
assert.ok(g.moritaniExtortion);
|
||||
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'extortion');
|
||||
assert.equal(p(g,'m').spice,p(before,'m').spice);
|
||||
} else {
|
||||
assert.equal(p(g,'a').forces[f.destination] ?? 0,0);
|
||||
assert.equal(p(g,'a').tanks,p(before,'a').tanks+3);
|
||||
}
|
||||
g = advance(g);
|
||||
assert.equal(g.pendingTerrorEntry,null);
|
||||
if (kind !== 'extortion')
|
||||
assert.equal(g.moritaniTerror!.tokens.find(t => t.id === f.token)!.status,'removed');
|
||||
completed(g,f.event);
|
||||
reject(g,'a',f.action);
|
||||
});
|
||||
|
||||
void test('Caladan adds directly to a surviving army aboard the actual Ix HMS without shipping', () => {
|
||||
const f = fixture({hms:true});
|
||||
assert.ok(f.source.mobileStronghold?.location);
|
||||
assert.equal(f.territory,'hidden_mobile_stronghold');
|
||||
const choice = caladanReinforcementChoice(viewGame(f.game,'a'),f.destination);
|
||||
assert.equal(choice.blocked,null);
|
||||
assert.deepEqual(choice.action,f.action);
|
||||
const before = structuredClone(p(f.game,'a'));
|
||||
const g = advance(act(reload(f.game),'a',choice.action!));
|
||||
assert.equal(p(g,'a').forces[f.destination],3);
|
||||
for (const field of ['spice','shipped','moved'] as const)
|
||||
assert.deepEqual(p(g,'a')[field],before[field]);
|
||||
assert.deepEqual(g.mobileStronghold,f.game.mobileStronghold);
|
||||
completed(g,f.event);
|
||||
});
|
||||
|
||||
void test('Caladan original event and owner reject stale, foreign and oversized entry before cost', () => {
|
||||
const f = fixture({bg:true});
|
||||
reject(f.game,'g',f.action);
|
||||
reject(f.game,'a',{...f.action,event:`${f.event}:stale`});
|
||||
reject(f.game,'a',{...f.action,amount:2});
|
||||
reject(f.game,'a',{...f.action,destination:'arrakeen:10'});
|
||||
assert.equal(p(f.game,'a').reserves,6);
|
||||
assert.equal(p(f.game,'a').forces[f.destination],2);
|
||||
inventory(f.game);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles use the actual Caladan and Intrusion controls legally', () => {
|
||||
const f = fixture({bg:true});
|
||||
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
|
||||
let g = reload(f.game);
|
||||
const own = viewGame(g,'a');
|
||||
own.players.find(seat => seat.id === 'a')!.bot = profile;
|
||||
const action = botActions(own).find(a => a.type === 'decision' && a.event === f.event);
|
||||
assert.ok(action,`${profile} needs a current original reinforcement action.`);
|
||||
g = act(g,'a',action);
|
||||
if (g.decision?.kind === 'intrusion') {
|
||||
const view = viewGame(g,'bg');
|
||||
view.players.find(seat => seat.id === 'bg')!.bot = profile;
|
||||
const reaction = botActions(view)[0];
|
||||
assert.ok(reaction,`${profile} needs a native Intrusion choice.`);
|
||||
g = allow(act(g,'bg',reaction));
|
||||
}
|
||||
g = advance(g);
|
||||
assert.equal(g.homeworldVictoryReinforcement!.stage,'complete');
|
||||
assert.equal(p(g,'a').reserves,action.decline ? 6 : 5);
|
||||
assert.equal(g.lastBattleContext!.caladanReinforcement!.completed,true);
|
||||
inventory(g);
|
||||
}
|
||||
});
|
||||
|
||||
for (const first of ['first','second'] as const)
|
||||
void test(`Caladan Ambassador ${first} in storm order retains its original victory through Terror`, () => {
|
||||
const f = fixture({terror:'robbery',ambassador:first});
|
||||
let g = act(reload(f.game),'a',f.action);
|
||||
assert.equal(g.pendingArrivalOverlap?.first,first === 'first' ? 'ambassador' : 'terror');
|
||||
assert.equal(g.pendingArrivalOverlap?.victoryEvent,f.event);
|
||||
const declineAmbassador = () => {
|
||||
const entry = g.pendingAmbassador; assert.ok(entry);
|
||||
assert.equal(entry.victoryEvent,f.event);
|
||||
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
|
||||
g = act(g,'ec',{type:'decision',event:entry.event,decline:true});
|
||||
};
|
||||
const declineTerror = () => {
|
||||
assert.equal(g.pendingTerrorEntry?.victoryEvent,f.event);
|
||||
assert.equal(g.homeworldVictoryReinforcement!.stage,'arrival');
|
||||
g = act(g,'m',{type:'decision',decline:true});
|
||||
};
|
||||
if (first === 'first') { declineAmbassador(); declineTerror(); }
|
||||
else { declineTerror(); declineAmbassador(); }
|
||||
g = advance(g);
|
||||
assert.equal(g.pendingArrivalOverlap,null);
|
||||
assert.equal(g.homeworldVictoryReinforcement!.ambassadors!.length,1);
|
||||
assert.equal(g.homeworldVictoryReinforcement!.ambassadors![0].completed,true);
|
||||
assert.equal(p(g,'a').forces[f.destination],3);
|
||||
completed(g,f.event);
|
||||
});
|
||||
@@ -29,8 +29,9 @@ function source(f: Fixture) {
|
||||
assert.equal(player(f.controlled, f.tleilaxu).reserves, 2);
|
||||
assert.equal(player(f.controlled, f.tleilaxu).forces['polar_sink:0'], 18);
|
||||
assert.equal(player(f.game, f.atreides).reserves, 6); assert.equal(army(f.game, f.atreides), 3);
|
||||
assert.equal(player(f.game, f.atreides).tanks, 0); assert.equal(player(f.game, f.tleilaxu).tanks, 2);
|
||||
assert.equal(player(f.game, f.tleilaxu).reserves, 0); assert.equal(army(f.game, f.tleilaxu), 0);
|
||||
assert.equal(player(f.game, f.atreides).tanks, 0); assert.equal(player(f.game, f.tleilaxu).tanks, 0);
|
||||
assert.equal(player(f.game, f.defender).tanks, 2);
|
||||
assert.equal(player(f.game, f.tleilaxu).reserves, 2); assert.equal(army(f.game, f.tleilaxu), 2);
|
||||
assert.equal(f.game.decision?.kind, 'caladanReinforcement'); assert.equal(f.game.battle ?? null, null);
|
||||
assert.equal(f.game.pendingFaceDance!.player, f.tleilaxu); assert.equal(f.game.pendingFaceDance!.identity, f.winnerLeader);
|
||||
assert.equal(player(f.game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
|
||||
@@ -56,7 +57,8 @@ function give(f: Fixture, actions: Step[] = []) {
|
||||
}
|
||||
function completed(game: Game, f: Fixture, actions: Step[] = []) {
|
||||
const done = finish(game, actions);
|
||||
assert.equal(done.phase, 7); assert.equal(done.battle ?? null, null); assert.equal(done.decision ?? null, null);
|
||||
assert.equal(done.phase, army(done, f.atreides) > 0 ? 6 : 7);
|
||||
assert.equal(done.battle ?? null, null); assert.equal(done.decision ?? null, null);
|
||||
assert.equal(done.pendingFaceDance ?? null, null); assert.equal(viewGame(done, f.atreides).caladanReinforcement, null);
|
||||
assert.equal(done.homeworldVictoryReinforcement!.stage, 'complete');
|
||||
assert.equal(done.lastBattleContext!.event, f.event); assert.equal(done.lastBattleContext!.winner, f.atreides);
|
||||
@@ -82,8 +84,8 @@ for (const advanced of [false, true]) {
|
||||
game = act(game, f.tleilaxu, f.faceDanceAction, actions);
|
||||
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(army(game, f.atreides), 0);
|
||||
assert.equal(player(game, f.atreides).forces['polar_sink:0'], 11); assert.equal(player(game, f.atreides).tanks, 0);
|
||||
assert.equal(player(game, f.tleilaxu).reserves, 4); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 14);
|
||||
assert.equal(player(game, f.tleilaxu).tanks, 2);
|
||||
assert.equal(player(game, f.tleilaxu).reserves, 6); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 14);
|
||||
assert.equal(player(game, f.tleilaxu).tanks, 0); assert.equal(player(game, f.defender).tanks, 2);
|
||||
const dead = player(game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!;
|
||||
assert.equal(dead.dead, true); assert.equal(dead.deaths, beforeLeader.deaths + 1);
|
||||
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, true);
|
||||
@@ -91,7 +93,7 @@ for (const advanced of [false, true]) {
|
||||
game = completed(game, f, actions);
|
||||
assert.equal(actions.filter(step => step.action.reveal === true).length, 1);
|
||||
assert.equal(actions.filter(step => step.action.destination === f.destination).length, 1);
|
||||
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(player(game, f.tleilaxu).reserves, 4);
|
||||
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(player(game, f.tleilaxu).reserves, 6);
|
||||
});
|
||||
|
||||
void test(`${band} controlled native Homeworld victory keeps its fourth visitor when Tleilaxu declines Face Dance`, () => {
|
||||
@@ -101,7 +103,7 @@ for (const advanced of [false, true]) {
|
||||
game = act(game, f.tleilaxu, f.faceDanceDecline, actions); game = completed(game, f, actions);
|
||||
assert.deepEqual(game.players, before); assert.deepEqual(game.homeworlds!.custody, custody);
|
||||
assert.equal(player(game, f.atreides).reserves, 5); assert.equal(army(game, f.atreides), 4);
|
||||
assert.equal(player(game, f.tleilaxu).reserves, 0); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 18);
|
||||
assert.equal(player(game, f.tleilaxu).reserves, 2); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 18);
|
||||
assert.equal(player(game, f.atreides).leaders.find(leader => leader.id === f.winnerLeader)!.dead, false);
|
||||
assert.equal(player(game, f.tleilaxu).faceDancers!.find(card => card.leader === f.winnerLeader)!.revealed, false);
|
||||
});
|
||||
@@ -115,8 +117,8 @@ for (const advanced of [false, true]) {
|
||||
game = act(game, f.tleilaxu, { ...f.faceDanceAction, sources: { 'polar_sink:0': 3 } });
|
||||
game = completed(game, f);
|
||||
assert.equal(player(game, f.atreides).reserves, 9); assert.equal(army(game, f.atreides), 0);
|
||||
assert.equal(player(game, f.tleilaxu).reserves, 3); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 15);
|
||||
assert.equal(player(game, f.tleilaxu).tanks, 2);
|
||||
assert.equal(player(game, f.tleilaxu).reserves, 5); assert.equal(player(game, f.tleilaxu).forces['polar_sink:0'], 15);
|
||||
assert.equal(player(game, f.tleilaxu).tanks, 0); assert.equal(player(game, f.defender).tanks, 2);
|
||||
});
|
||||
|
||||
void test(`${band} controlled Homeworld reinforcement and subsequent Face Dance reject stale, foreign, quantity and live-source over-cap actions`, () => {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeHomeworldGameForAudit, joinGame,
|
||||
newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
|
||||
import { location, territory as boardTerritory } from '../game/board';
|
||||
import { homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { placeTerror, type TerrorKind } from '../game/moritani-terror';
|
||||
import {
|
||||
caladanVictoryFixture, holdVictoryCard, positionVictoryArmy,
|
||||
victoryPlayer, victoryReload,
|
||||
} from './fixture-caladan-victory';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export const caladanArrivalPlayer = victoryPlayer;
|
||||
export const caladanArrivalReload = victoryReload;
|
||||
export type CaladanArrivalStep = { actor: string; action: Action };
|
||||
|
||||
/** Controlled, conserved battle source. The inherited Homeworld preview
|
||||
* initializer is not a public start journey. Wallets, later turn/phase and
|
||||
* positions, selected held original cards, and the placed original Terror token are
|
||||
* scenario inputs. Only the recorded battle and arrival actions are native
|
||||
* chronology; no victory, entry, reaction or reinforcement receipt is installed. */
|
||||
export const caladanArrivalDisclosure =
|
||||
'Controlled Homeworld preview setup and later battle positions/purses/cards/token placement; recorded native battle and arrival actions, not public acquisition history.';
|
||||
|
||||
export function actCaladanArrival(
|
||||
game: Game, actor: string, action: Action, actions: CaladanArrivalStep[] = [],
|
||||
): Game {
|
||||
const result = normalizeAutomaticGame(applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Resolve only actual neutral native controls, preserving the decisions under
|
||||
* test. It never seals a plan, chooses an entry reaction or advances a new phase. */
|
||||
export function advanceCaladanArrival(
|
||||
game: Game, actions: CaladanArrivalStep[] = [],
|
||||
): Game {
|
||||
let g = game;
|
||||
for (let n = 0; n < 100; n++) {
|
||||
if (g.decision && ['caladanReinforcement', 'intrusion', 'moritaniTerror',
|
||||
'ambassador', 'arrivalOverlap'].includes(g.decision.kind)) return g;
|
||||
if (!g.battle && !g.response && !g.pendingTreacheryDiscard) return g;
|
||||
const next = nextStrongholdFactionsNativeStep(g);
|
||||
if (!next) return g;
|
||||
g = actCaladanArrival(g, next.actor, next.action, actions);
|
||||
}
|
||||
assert.fail('The bounded native Caladan continuation did not stop.');
|
||||
}
|
||||
|
||||
export function allowCaladanArrivalResponses(
|
||||
game: Game, actions: CaladanArrivalStep[] = [],
|
||||
): Game {
|
||||
let g = game;
|
||||
for (let n = 0; g.response && n < 40; n++) {
|
||||
const actor = g.players.find(p => !g.response!.passed.includes(p.id));
|
||||
assert.ok(actor);
|
||||
g = actCaladanArrival(g, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.equal(g.response, null);
|
||||
return advanceCaladanArrival(g, actions);
|
||||
}
|
||||
|
||||
export function caladanArrivalInventory(g: Game) {
|
||||
homeworldGameIntegrity(g);
|
||||
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)];
|
||||
assert.equal(cards.length, g.expansions.includes('ix') ? 47 : 33);
|
||||
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
|
||||
for (const p of g.players) {
|
||||
const visitors = Object.values(g.homeworlds!.custody!.visitors).reduce(
|
||||
(sum, world) => sum + (world[p.id]?.normal ?? 0) + (world[p.id]?.elite ?? 0), 0);
|
||||
const stars = p.elites ? p.elites.reserves + p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0) : 0;
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0) + visitors, 20);
|
||||
assert.ok(stars >= 0 && p.spice >= 0);
|
||||
}
|
||||
return { cards: cards.length, leaders: g.players.flatMap(p => p.leaders).length };
|
||||
}
|
||||
|
||||
function hmsBattleSource(): Game {
|
||||
let g = createGame('CALADANHMSARRIVAL', newPlayer('a', 'Atreides', 'atreides'), true, ['ix']);
|
||||
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
||||
joinGame(g, newPlayer('i', 'Ixians', 'ixians'));
|
||||
g = applyAction(g, 'a', { type: 'homeworlds', enabled: true });
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g = initializeHomeworldGameForAudit(g);
|
||||
for (let n = 0; (g.status === 'setup' || !g.mobileStronghold?.location) && n < 140; n++) {
|
||||
const next = nextStrongholdFactionsNativeStep(g);
|
||||
if (next) g = applyAction(g, next.actor, next.action);
|
||||
else {
|
||||
const choice = g.players.map(p => ({actor:p.id, action:botActions(viewGame(g,p.id))[0]})).find(c => c.action);
|
||||
assert.ok(choice);
|
||||
g = applyAction(g, choice.actor, choice.action);
|
||||
}
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
assert.ok(g.mobileStronghold?.location, 'The original Ix setup must place its actual HMS.');
|
||||
// Conserved later scenario positions, not a claimed native movement journey.
|
||||
const ix = victoryPlayer(g, 'i');
|
||||
ix.reserves += Object.values(ix.forces).reduce((sum, count) => sum + count, 0);
|
||||
ix.forces = {};
|
||||
ix.elites!.reserves += Object.values(ix.elites!.forces).reduce((sum, count) => sum + count, 0);
|
||||
ix.elites!.forces = {};
|
||||
for (const p of g.players) { g.deck.push(...p.hand.splice(0)); p.spice = 20; }
|
||||
Object.assign(g, { phase:6, turn:2, storm:18, phaseOpening:null, response:null,
|
||||
decision:null, active:'a', ready:[], order:g.players.map(p => p.id) });
|
||||
positionVictoryArmy(g, 'a', 6, 3);
|
||||
positionVictoryArmy(g, 'g', 17, 3);
|
||||
for (const id of ['a', 'g']) {
|
||||
const p = victoryPlayer(g, id);
|
||||
delete p.forces['hagga_basin:12'];
|
||||
p.forces['hidden_mobile_stronghold:0'] = 3;
|
||||
}
|
||||
caladanArrivalInventory(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
export function createCaladanArrivalFixture(options: {
|
||||
bg?: boolean; terror?: TerrorKind; hms?: boolean; ambassador?: 'first' | 'second';
|
||||
} = {}) {
|
||||
assert.ok(!options.hms || (!options.bg && !options.terror));
|
||||
let g = options.hms ? hmsBattleSource() : caladanVictoryFixture({
|
||||
advanced:true,
|
||||
extraSeats: options.bg ? [{id:'bg',faction:'beneGesserit'}] :
|
||||
options.terror ? [{id:'m',faction:'moritani'}, ...(options.ambassador ? [{id:'ec',faction:'ecaz'} as const] : [])] : [],
|
||||
});
|
||||
const territory = options.hms ? 'hidden_mobile_stronghold' : options.terror ? 'arrakeen' : 'hagga_basin';
|
||||
const destination = `${territory}:${options.hms ? 0 : options.terror ? 10 : 12}`;
|
||||
let token: string | undefined;
|
||||
let heldCard: string | undefined;
|
||||
if (options.bg) {
|
||||
const bg = victoryPlayer(g, 'bg');
|
||||
Object.assign(bg, {reserves:19,forces:{'hagga_basin:12':1},advisors:{}});
|
||||
holdVictoryCard(g,'g','karama'); // Keep an actual preventable flip response open.
|
||||
}
|
||||
if (options.terror) {
|
||||
// Ecaz/Moritani native roster preview; no extra Treachery module is added.
|
||||
g.expansions.push('ecaz');
|
||||
for (const id of ['a','g']) {
|
||||
const p = victoryPlayer(g,id);
|
||||
delete p.forces['hagga_basin:12'];
|
||||
p.forces['arrakeen:10'] = 3;
|
||||
}
|
||||
const original = g.moritaniTerror!.tokens.find(t => t.kind === options.terror)!;
|
||||
token = original.id;
|
||||
g.moritaniTerror = placeTerror(g.moritaniTerror!, token, territory, 1);
|
||||
if (options.terror === 'sabotage') {
|
||||
heldCard = holdVictoryCard(g,'a','shield');
|
||||
holdVictoryCard(g,'m','karama'); // An actual held card supplies the optional gift.
|
||||
}
|
||||
}
|
||||
if (options.ambassador) {
|
||||
assert.ok(options.terror);
|
||||
// Controlled original-token supply and storm order; neither reaction has
|
||||
// been accepted and no later arrival/continuation receipt is installed.
|
||||
g.ecazAmbassadors = createAmbassadors(() => 0);
|
||||
const ambassador = g.ecazAmbassadors.tokens.find(token => token.zone === 'supply')!;
|
||||
g.ecazAmbassadors = placeAmbassador(g.ecazAmbassadors, ambassador.id, {turn:1,availableSpice:20,
|
||||
destination:{id:territory,stronghold:true,inStorm:false,allowed:true}}).state;
|
||||
g.order = options.ambassador === 'first' ? ['a','g','ec','m'] : ['a','g','m','ec'];
|
||||
}
|
||||
// A separately staged one-counter contest retains Battle phase after this
|
||||
// victory, so unrelated automatic Collection income is not part of entry proof.
|
||||
const other = location('shield_wall', boardTerritory('shield_wall').sectors[0]);
|
||||
const atreides = victoryPlayer(g,'a'), guildArmy = victoryPlayer(g,'g');
|
||||
assert.ok(atreides.forces['polar_sink:0'] > 0 && guildArmy.reserves > 0);
|
||||
atreides.forces['polar_sink:0']--; atreides.forces[other] = 1;
|
||||
guildArmy.reserves--; guildArmy.forces[other] = 1;
|
||||
caladanArrivalInventory(g);
|
||||
const source = victoryReload(g), actions: CaladanArrivalStep[] = [];
|
||||
g = actCaladanArrival(g,'a',{type:'chooseBattle',territory,target:'g'},actions);
|
||||
g = advanceCaladanArrival(g,actions);
|
||||
assert.ok(g.battle && !g.battle.preparation && !g.decision && !g.response);
|
||||
const a = victoryPlayer(g,'a').leaders.reduce((best,l) => l.strength > best.strength ? l : best);
|
||||
const guild = victoryPlayer(g,'g').leaders.reduce((best,l) => l.strength < best.strength ? l : best);
|
||||
g = actCaladanArrival(g,'a',{type:'battlePlan',leader:a.id,dial:1,support:1},actions);
|
||||
g = actCaladanArrival(g,'g',{type:'battlePlan',leader:guild.id,dial:0,support:0},actions);
|
||||
g = advanceCaladanArrival(g,actions);
|
||||
assert.equal(g.decision?.kind,'caladanReinforcement');
|
||||
assert.equal(g.lastBattleContext?.winner,'a');
|
||||
assert.equal(victoryPlayer(g,'a').forces[destination],2);
|
||||
const event = g.homeworldVictoryReinforcement!.event;
|
||||
const offer = viewGame(g,'a').caladanReinforcement!;
|
||||
caladanArrivalInventory(g);
|
||||
return {game:g,source,actions,sourceActions:actions,sourceCount:actions.length,
|
||||
disclosure:caladanArrivalDisclosure,territory,destination,event,token,heldCard,offer,
|
||||
action:{type:'decision',event,destination,amount:1} as Action,
|
||||
decline:{type:'decision',event,decline:true} as Action};
|
||||
}
|
||||
@@ -85,10 +85,14 @@ export function advanceCaladanFaceDanceHomeworld(game: Game, until: (state: Game
|
||||
}
|
||||
throw Error('The original Homeworld battle did not reach its bounded continuation');
|
||||
}
|
||||
/** Finish only this battle; retain the original Collection opening. */
|
||||
/** Finish this original aftermath only. A declined Face Dance can leave a
|
||||
* further mandatory battle against the noncombatant native Tleilaxu army. */
|
||||
export function finishCaladanFaceDanceHomeworld(game: Game, actions: CaladanFaceDanceHomeworldStep[] = []): Game {
|
||||
const event = game.lastBattleContext?.event; assert.ok(event);
|
||||
return advanceCaladanFaceDanceHomeworld(game,
|
||||
state => state.phase === 7 && !state.battle && !state.response && !state.decision, actions);
|
||||
state => state.lastBattleContext?.event === event && !state.pendingFaceDance &&
|
||||
state.lastBattleContext.caladanReinforcement?.completed === true &&
|
||||
!state.battle && !state.response && !state.decision && !state.phaseOpening, actions);
|
||||
}
|
||||
|
||||
/** Genuine original Ix/Homeworld audit setup, then an explicitly CONTROLLED
|
||||
@@ -126,10 +130,13 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
|
||||
positionVictoryArmy(game, observer, 20, 0);
|
||||
positionVictoryArmy(game, tleilaxu, 2, 0);
|
||||
positionVictoryInvader(game, atreides, territory, 3);
|
||||
positionVictoryInvader(game, observer, territory, 2);
|
||||
const defender = observer;
|
||||
const winner = caladanFaceDanceHomeworldPlayer(game, atreides), native = caladanFaceDanceHomeworldPlayer(game, tleilaxu);
|
||||
const opposing = caladanFaceDanceHomeworldPlayer(game, defender);
|
||||
const winnerDisc = [...winner.leaders].filter(leader => !leader.dead && !leader.usedAt)
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
const loserDisc = [...native.leaders].filter(leader => !leader.dead && !leader.usedAt)
|
||||
const loserDisc = [...opposing.leaders].filter(leader => !leader.dead && !leader.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength)[0]; assert.ok(winnerDisc && loserDisc && winnerDisc.strength > loserDisc.strength);
|
||||
const winnerLeader = winnerDisc.id, loserLeader = loserDisc.id;
|
||||
let exchange: { from: string; returned: string; selected: string } | null = null;
|
||||
@@ -148,14 +155,14 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
|
||||
const controlled = structuredClone(game);
|
||||
assert.deepEqual(caladanFaceDanceHomeworldInventory(game), originalInventory);
|
||||
assert.deepEqual(caladanFaceDanceHomeworldTraitors(game), originalTraitors);
|
||||
const battleAction: Action = { type: 'chooseBattle', territory, target: tleilaxu };
|
||||
const battleAction: Action = { type: 'chooseBattle', territory, target: defender };
|
||||
act(atreides, battleAction); const beforePreparation = structuredClone(game);
|
||||
game = advanceCaladanFaceDanceHomeworld(game, state => !!state.battle && !state.response && !state.decision &&
|
||||
!state.phaseOpening && !state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
|
||||
const beforePlans = structuredClone(game);
|
||||
const planSteps: CaladanFaceDanceHomeworldStep[] = [
|
||||
{ actor: atreides, action: { type: 'battlePlan', leader: winnerLeader, dial: 0, support: 0, weapon: null, defense: null } },
|
||||
{ actor: tleilaxu, action: { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: null, defense: null } },
|
||||
{ actor: defender, action: { type: 'battlePlan', leader: loserLeader, dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
for (const step of planSteps) act(step.actor, step.action);
|
||||
const revealed = structuredClone(game);
|
||||
@@ -165,7 +172,9 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
|
||||
assert.equal(caladanFaceDanceHomeworldPlayer(game, atreides).reserves, 6);
|
||||
assert.equal(caladanFaceDanceHomeworldArmy(game, atreides), 3);
|
||||
assert.equal(caladanFaceDanceHomeworldPlayer(game, atreides).tanks, 0);
|
||||
assert.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).tanks, 2);
|
||||
assert.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).tanks, 0);
|
||||
assert.equal(caladanFaceDanceHomeworldPlayer(game, tleilaxu).reserves, 2);
|
||||
assert.equal(caladanFaceDanceHomeworldPlayer(game, defender).tanks, 2);
|
||||
assert.ok(game.pendingFaceDance && game.pendingFaceDance.identity === winnerLeader);
|
||||
const event = game.homeworldVictoryReinforcement!.event;
|
||||
const reinforcementAction: Action = { type: 'decision', event, destination, amount: 1 };
|
||||
@@ -173,17 +182,17 @@ export function createCaladanFaceDanceHomeworldFixture(options: { advanced?: boo
|
||||
const faceDanceAction: Action = { type: 'decision', reveal: true, sources: { 'polar_sink:0': 4 } };
|
||||
const faceDanceDecline: Action = { type: 'decision', reveal: false };
|
||||
return { game, source: structuredClone(game), advanced, atreides, winner: atreides, owner: atreides, tleilaxu,
|
||||
observer, actor: atreides, territory, destination, event, winnerLeader, loserLeader,
|
||||
observer, defender, actor: atreides, territory, destination, event, winnerLeader, loserLeader,
|
||||
action: reinforcementAction, reinforcementAction, reinforcementDecline, faceDanceAction, faceDanceDecline,
|
||||
battleAction, planSteps, lobby, setup, afterSetup, controlled, exchange, beforePreparation, beforePlans, revealed,
|
||||
originalInventory, originalTraitors, originalLeaders, originalPendingFaceDance: structuredClone(game.pendingFaceDance),
|
||||
originalPhysical: caladanFaceDanceHomeworldPhysical(game), originalContext: structuredClone(game.lastBattleContext),
|
||||
actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
'Actual original A/G/Tleilaxu Ix47 Homeworld audit setup and logged native setup choices; Basic/Advanced is selected before the original initializer. This unchanged fixture-caladan-victory profile does not enable occupation-preview benefits or a new public start gate.',
|
||||
'CONTROLLED conserved later phase-six position: original dealt cards return to their original deck; A has Caladan6, three visitors on Tleilax and Polar11; Tleilaxu has native2 and Polar18; Guild has native20. The exact original custody quote moves A3 from Caladan9 to the visitor pool. No extra counter, card or disc is created.',
|
||||
'Actual original A/G/Tleilaxu Ix47 Homeworld audit setup and logged native setup choices; Basic/Advanced is selected before the original initializer. Original occupation state/preview flags remain unchanged; no occupied benefit or native-only Face Dance restriction is disabled.',
|
||||
'CONTROLLED conserved later phase-six position: original dealt cards return to their original deck; A has Caladan6, three visitors on Tleilax and Polar11; Tleilaxu has native2 and Polar18; Guild has native18 and two visitors on Tleilax. Exact original custody quotes move A3 from Caladan9 and Guild2 from Junction20 into their visitor pools. Tleilaxu stays a noncombatant native, so A never solely occupies Tleilax. No extra counter, card or disc is created.',
|
||||
'CONTROLLED matching Face Dancer availability exchanges one original Traitor identity with its actual reserve/held slot when necessary. All original Traitors/discs stay unique; no replacement or reveal status is installed.',
|
||||
'Actual chooseBattle, preparation/response passes, zero-dial unarmed native-leader plans, outcome readiness and declined Traitor votes create a normal A victory, no A ordinary casualties and Tleilaxu2 Tanks. Stop at the original Caladan choice before native Face Dance. Optional transfer and reveal remain caller-owned.',
|
||||
'Actual A-versus-Guild chooseBattle, preparation/response passes, zero-dial unarmed faction-leader plans and outcome readiness create a normal A victory, no A ordinary casualties and Guild2 Tanks; two visiting combatants have no Homeworld Traitor voters. Native Tleilaxu2 remains, permitting its native Face Dance without occupied-Tleilax immunity. Stop at the original Caladan choice. Declining Face Dance leaves the later A–T battle unresolved rather than claiming Collection.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ void test('victory on native Caladan is a physical no-op, never a newly created
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('permission is territorial; sector choices stay at the original battle and storm/HMS remain explicitly blocked', () => {
|
||||
void test('permission is territorial; storm stays unavailable while the separate addition can reach a won HMS', () => {
|
||||
const g = fixture();
|
||||
const sectors = territory('imperial_basin').sectors;
|
||||
g.players[0].forces = { [location('imperial_basin', sectors[0])]: 1 };
|
||||
@@ -130,19 +130,17 @@ void test('permission is territorial; sector choices stay at the original battle
|
||||
HomeworldVictoryReinforcementContext['mobileStronghold']
|
||||
>;
|
||||
g.players[0].forces = { [location(MOBILE_STRONGHOLD, 0)]: 1 };
|
||||
assert.match(
|
||||
quote(g, victory(MOBILE_STRONGHOLD)).destinations[0].blocked!,
|
||||
/Hidden Mobile Stronghold/,
|
||||
);
|
||||
assert.equal(quote(g, victory(MOBILE_STRONGHOLD)).destinations[0].blocked, null);
|
||||
assert.equal(transfer(g, victory(MOBILE_STRONGHOLD), `${MOBILE_STRONGHOLD}:0`).transfer.players[0].reserves, 5);
|
||||
});
|
||||
|
||||
void test('actual entry boundaries retain occupancy, BG Intrusion and public low-Grumman count distinctions', () => {
|
||||
void test('arrival reactions do not prohibit the original addition; ordinary allied occupancy still does', () => {
|
||||
const g = fixture();
|
||||
const bg = newPlayer('b', 'BG', 'beneGesserit');
|
||||
bg.forces = { 'arrakeen:10': 1 };
|
||||
bg.reserves = 19;
|
||||
g.players.push(bg);
|
||||
assert.match(quote(g, victory()).destinations[0].blocked!, /Intrusion/);
|
||||
assert.equal(quote(g, victory()).destinations[0].blocked, null);
|
||||
g.players.pop();
|
||||
const moritani = newPlayer('m', 'Moritani', 'moritani');
|
||||
moritani.reserves = 8;
|
||||
@@ -151,7 +149,7 @@ void test('actual entry boundaries retain occupancy, BG Intrusion and public low
|
||||
const token = state.tokens[0];
|
||||
state = placeTerror(state, token.id, 'arrakeen', 1);
|
||||
g.moritaniTerror = state;
|
||||
assert.match(quote(g, victory()).destinations[0].blocked!, /Terror/);
|
||||
assert.equal(quote(g, victory()).destinations[0].blocked, null);
|
||||
moritani.reserves = 7;
|
||||
assert.equal(quote(g, victory()).destinations[0].blocked, null);
|
||||
g.players[0].ally = 'h';
|
||||
|
||||
Reference in new issue
Block a user