Resolve direct advanced Sneak arrivals
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
8a39f5a172
commit
b79e448deb
8 files changed
+213
-8
No files matched your search
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { guildAmbassadorShipments } from '../game/guild-ambassador';
|
||||
import { reserveShipmentCost } from '../game/shipment-price';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { nextBasicSemutaAmbassadorStep } from './fixture-basic-semuta-ambassador';
|
||||
import { act, player } from './fixture-advanced-overlap-child';
|
||||
import { advancedDirectSneakFixture } from './fixture-advanced-sneak-direct';
|
||||
|
||||
function advanceTo(game: Game, until: (state: Game) => boolean): Game {
|
||||
for (let i = 0; i < 60 && !until(game); i++) {
|
||||
if (game.decision?.kind === 'advisor') {
|
||||
game = act(game, game.decision.player, {type:'decision', accept:false});
|
||||
continue;
|
||||
}
|
||||
if (game.decision?.kind === 'guildShipment') {
|
||||
game = act(game, game.decision.player, {type:'decision', allow:true});
|
||||
continue;
|
||||
}
|
||||
const next = nextBasicSemutaAmbassadorStep(game);
|
||||
assert.ok(next, `The original arrival must continue: ${game.decision?.kind ?? game.response?.kind}`);
|
||||
game = act(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game), 'The requested native arrival reaction is reached');
|
||||
return game;
|
||||
}
|
||||
|
||||
function revealSneak(withBG: boolean) {
|
||||
const f = advancedDirectSneakFixture(withBG);
|
||||
const originalSpice = player(f.game, f.entrant).spice;
|
||||
const price = reserveShipmentCost({faction:'guild', halfRate:true}, 'stronghold', 1);
|
||||
let game = act(f.game, f.entrant, f.shipment);
|
||||
game = advanceTo(game, state => !!state.pendingTerrorEntry);
|
||||
assert.equal(game.pendingArrivalOverlap ?? null, null, 'Guild cannot trigger its own Ambassador');
|
||||
assert.equal(game.pendingTerrorEntry?.token, f.sneak);
|
||||
assert.equal(game.pendingTerrorEntry?.entrant, f.entrant);
|
||||
assert.equal(player(game, f.entrant).spice, originalSpice - price);
|
||||
game = act(game, f.moritani, {type:'decision', reveal:true});
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'sneakAttack');
|
||||
return {f, game, originalSpice, price};
|
||||
}
|
||||
|
||||
for (const withBG of [false, true]) void test(
|
||||
`Advanced direct Sneak opens the Moritani Ambassador entry ${withBG ? 'after BG accompaniment' : 'without BG'}`, () => {
|
||||
const {f, game: before, originalSpice, price} = revealSneak(withBG);
|
||||
const reserves = player(before, f.moritani).reserves;
|
||||
let game = act(before, f.moritani, {type:'decision', amount:1});
|
||||
if (withBG) {
|
||||
assert.equal(game.decision?.kind, 'advisor');
|
||||
assert.equal(game.decision?.player, 'beneGesserit');
|
||||
game = act(game, 'beneGesserit', {type:'decision', accept:false});
|
||||
}
|
||||
game = advanceTo(game, state => !!state.pendingAmbassador);
|
||||
const entry = game.pendingAmbassador; assert.ok(entry);
|
||||
assert.equal(entry.token, f.ambassador);
|
||||
assert.equal(entry.entrant, f.moritani);
|
||||
assert.equal(player(game, f.moritani).reserves, reserves - 1);
|
||||
assert.equal(player(game, f.moritani).forces[`${f.destination}:${f.sector}`], 1);
|
||||
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
|
||||
assert.equal(player(game, f.entrant).spice, originalSpice - price);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'ecaz');
|
||||
view.players.find(p => p.id === 'ecaz')!.bot = difficulty;
|
||||
const action = botActions(view)[0]; assert.ok(action, difficulty);
|
||||
assert.doesNotThrow(() => act(game, 'ecaz', action), difficulty);
|
||||
}
|
||||
game = act(game, 'ecaz', {type:'decision', event:entry.event, decline:true});
|
||||
assert.equal(game.pendingAmbassador, null);
|
||||
assert.equal(game.pendingTerrorEntry, null);
|
||||
assert.equal(game.pendingArrivalOverlap ?? null, null);
|
||||
assert.equal(player(game, f.moritani).reserves, reserves - 1);
|
||||
assert.equal(player(game, f.entrant).spice, originalSpice - price);
|
||||
});
|
||||
|
||||
void test('Advanced direct Sneak sending zero opens no separate entry or accompaniment', () => {
|
||||
const {f, game: before, originalSpice, price} = revealSneak(true);
|
||||
const reserves = player(before, f.moritani).reserves;
|
||||
const game = act(before, f.moritani, {type:'decision', amount:0});
|
||||
assert.equal(player(game, f.moritani).reserves, reserves);
|
||||
assert.equal(game.pendingAmbassador ?? null, null);
|
||||
assert.equal(game.pendingTerrorEntry, null);
|
||||
assert.notEqual(game.decision?.kind, 'advisor');
|
||||
assert.equal(player(game, f.entrant).spice, originalSpice - price);
|
||||
});
|
||||
|
||||
void test('Advanced direct Sneak can trigger the retained Guild Ambassador exactly once', () => {
|
||||
const {f, game: before, originalSpice, price} = revealSneak(false);
|
||||
const reserves = player(before, f.owner).reserves, spice = player(before, f.owner).spice;
|
||||
let game = act(before, f.moritani, {type:'decision', amount:1});
|
||||
game = advanceTo(game, state => !!state.pendingAmbassador);
|
||||
const entry = game.pendingAmbassador; assert.ok(entry);
|
||||
game = act(game, f.owner, {type:'decision', event:entry.event, trigger:true, beneficiary:f.owner});
|
||||
assert.equal(game.pendingAmbassador?.stage, 'ship');
|
||||
const target = guildAmbassadorShipments(game, f.owner).destinations.find(row => row.territory === 'polar_sink');
|
||||
assert.ok(target);
|
||||
game = act(game, f.owner, {type:'decision', event:entry.event, amount:1, elite:0,
|
||||
territory:target.territory, sector:target.sector});
|
||||
game = advanceTo(game, state => !state.pendingAmbassador);
|
||||
assert.equal(game.pendingAmbassador, null);
|
||||
assert.equal(game.pendingTerrorEntry, null);
|
||||
assert.equal(player(game, f.owner).reserves, reserves - 1);
|
||||
assert.equal(player(game, f.owner).spice, spice);
|
||||
assert.equal(player(game, f.entrant).spice, originalSpice - price);
|
||||
assert.equal(player(game, f.entrant).forces[`${f.destination}:${f.sector}`], 1);
|
||||
assert.equal(game.ecazAmbassadors?.tokens.find(token => token.id === f.ambassador)?.zone, 'used');
|
||||
});
|
||||
@@ -69,6 +69,20 @@ function ready(state: Game) {
|
||||
}
|
||||
const queuedRide = { kind: 'wormRide', player: 'in', territory: 'hagga_basin' } as const;
|
||||
|
||||
void test('Advanced worm Sneak shows the temporary positive-entry guard and preserves the ride on zero', () => {
|
||||
const { g } = table('sneakAttack');
|
||||
let state = wormRide(g);
|
||||
assert.match(viewGame(state, 'm').terrorEntry?.sneakAttack?.blocked ?? '', /worm ride/);
|
||||
state = action(state, 'm', {type:'decision', reveal:true});
|
||||
const before = structuredClone(state);
|
||||
assert.throws(() => action(state, 'm', {type:'decision', amount:1}), /worm ride/);
|
||||
assert.deepEqual(state, before);
|
||||
state = action(state, 'm', {type:'decision', amount:0});
|
||||
assert.equal(state.pendingArrivalOverlap?.stage, 'second');
|
||||
state = action(state, 'ec', {type:'decision', event:state.pendingAmbassador!.event, decline:true});
|
||||
assert.deepEqual(state.decision, queuedRide);
|
||||
});
|
||||
|
||||
void test('Terror first keeps Robbery overflow custody and the ordered arrival intact', () => {
|
||||
const { g, terror } = table('robbery');
|
||||
seat(g, 'm').hand = g.deck.splice(0, 4);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import { advancedOverlapChildFixture } from './fixture-advanced-overlap-child';
|
||||
|
||||
/** Genuine Advanced native lobby/setup and Guild shipment from the shared
|
||||
* fixture. Select the existing Guild Ambassador and Sneak Attack markers at
|
||||
* the earned clean turn boundary; no forces, spice or continuation are staged.
|
||||
* The Guild entrant cannot trigger its own Ambassador, but Moritani can. */
|
||||
export function advancedDirectSneakFixture(withBG: boolean) {
|
||||
const f = advancedOverlapChildFixture('fremen', 'terror', withBG);
|
||||
const game = structuredClone(f.game);
|
||||
const ambassadors = game.ecazAmbassadors!;
|
||||
const old = ambassadors.tokens.find(token => token.id === f.token);
|
||||
const guild = ambassadors.tokens.find(token => token.effect === 'guild');
|
||||
assert.ok(old && guild && old.zone === 'placed');
|
||||
old.zone = 'supply'; old.location = null;
|
||||
if (guild.zone === 'pool') {
|
||||
const index = ambassadors.cohort.indexOf(old.id); assert.ok(index >= 0);
|
||||
ambassadors.cohort[index] = guild.id;
|
||||
old.zone = 'pool'; guild.zone = 'supply';
|
||||
}
|
||||
assert.equal(guild.zone, 'supply');
|
||||
guild.zone = 'placed'; guild.location = f.destination;
|
||||
validateAmbassadors(ambassadors);
|
||||
const robbery = game.moritaniTerror!.tokens.find(token => token.id === f.terror);
|
||||
const sneak = game.moritaniTerror!.tokens.find(token => token.kind === 'sneakAttack');
|
||||
assert.ok(robbery && sneak);
|
||||
robbery.status = 'available'; robbery.location = null;
|
||||
// The six-seat shared fixture may already have staged Sneak at a second
|
||||
// site. Move that same physical token rather than creating another one.
|
||||
assert.ok(sneak.status === 'available' || sneak.status === 'placed');
|
||||
sneak.status = 'placed'; sneak.location = f.destination;
|
||||
return { ...f, game, ambassador:guild.id, sneak:sneak.id };
|
||||
}
|
||||
Reference in new issue
Block a user