Resume worm rides after advanced Sneak arrivals
Verify and publish checkpoint container / container (push) Successful in 10m40s
Verify and publish checkpoint container / container (push) Successful in 10m40s
This commit is contained in:
1 parent
b79e448deb
commit
57321c2c8d
8 files changed
+363
-36
No files matched your search
@@ -0,0 +1,153 @@
|
||||
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 { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { advancedSneakWormFixture, act, player, type WormOrder } from './fixture-advanced-sneak-worm';
|
||||
|
||||
function reveal(game: Game): Game {
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
|
||||
assert.equal(viewGame(game, 'm').terrorEntry?.sneakAttack?.blocked, null);
|
||||
game = act(game, 'm', {type:'decision', reveal:true});
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'sneakAttack');
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const withBG of [false,true]) void test(
|
||||
`Advanced worm Sneak resolves its own ${withBG ? 'BG and ' : ''}Ambassador before the next ride`, () => {
|
||||
const f = advancedSneakWormFixture('direct', 'terror', withBG);
|
||||
assert.equal(f.game.pendingArrivalOverlap ?? null, null, 'Fremen cannot trigger its own Ambassador');
|
||||
assert.equal(f.game.pendingTerrorEntry?.resume, 'wormRide');
|
||||
let game = reveal(f.game);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'm');
|
||||
view.players.find(player => player.id === 'm')!.bot = difficulty;
|
||||
const choice = botActions(view)[0]; assert.ok(choice, difficulty);
|
||||
assert.doesNotThrow(() => act(game, 'm', choice), difficulty);
|
||||
}
|
||||
const beforeReserves = player(game, 'm').reserves, beforeSpice = player(game, 'm').spice;
|
||||
game = act(game, 'm', {type:'decision', amount:1});
|
||||
assert.equal(game.phase, 1);
|
||||
assert.equal(player(game, 'm').reserves, beforeReserves - 1);
|
||||
assert.equal(player(game, 'm').forces['arrakeen:10'], 1);
|
||||
assert.equal(player(game, 'm').spice, beforeSpice);
|
||||
assert.notDeepEqual(game.decision, f.nextRide, 'The next ride cannot overwrite this arrival');
|
||||
if (withBG) {
|
||||
assert.equal(game.decision?.kind, 'advisor');
|
||||
assert.equal(game.decision?.player, 'bg');
|
||||
game = act(game, 'bg', {type:'decision', accept:false});
|
||||
}
|
||||
const child = game.pendingAmbassador; assert.ok(child, 'Moritani earns the still-placed Ambassador');
|
||||
assert.equal(child.token, f.token);
|
||||
assert.equal(child.entrant, 'm');
|
||||
game = act(game, 'ec', {type:'decision', event:child.event, decline:true});
|
||||
assert.deepEqual(game.decision, f.nextRide);
|
||||
assert.deepEqual(game.wormRides, []);
|
||||
assert.equal(game.pendingAmbassador ?? null, null);
|
||||
assert.equal(game.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(player(game, 'm').reserves, beforeReserves - 1);
|
||||
assert.equal(player(game, 'in').forces['arrakeen:10'], 2);
|
||||
game = act(game, 'in', {type:'decision', accept:false});
|
||||
assert.notEqual(game.decision?.kind, 'wormRide');
|
||||
assert.deepEqual(game.wormRides, []);
|
||||
});
|
||||
|
||||
void test('Advanced worm Sneak choosing zero resumes the ride without a child arrival', () => {
|
||||
const f = advancedSneakWormFixture('direct', 'terror', true);
|
||||
let game = reveal(f.game);
|
||||
const beforeReserves = player(game, 'm').reserves;
|
||||
game = act(game, 'm', {type:'decision', amount:0});
|
||||
assert.equal(player(game, 'm').reserves, beforeReserves);
|
||||
assert.equal(game.pendingAmbassador ?? null, null);
|
||||
assert.equal(game.pendingTerrorEntry ?? null, null);
|
||||
assert.deepEqual(game.decision, f.nextRide);
|
||||
});
|
||||
|
||||
void test('Advanced worm Sneak with no child reaction resumes the next ride once', () => {
|
||||
const f = advancedSneakWormFixture('direct');
|
||||
const source = structuredClone(f.game);
|
||||
const token = source.ecazAmbassadors!.tokens.find(row => row.id === f.token)!;
|
||||
token.zone = 'supply'; token.location = null;
|
||||
let game = reveal(source);
|
||||
game = act(game, 'm', {type:'decision', amount:1});
|
||||
assert.deepEqual(game.decision, f.nextRide);
|
||||
assert.equal(game.pendingSneakWorm ?? null, null);
|
||||
assert.equal(game.pendingAmbassador ?? null, null);
|
||||
assert.deepEqual(game.wormRides, []);
|
||||
});
|
||||
|
||||
void test('Advanced Sneak on the last worm ride settles its child before ending the ride queue', () => {
|
||||
const f = advancedSneakWormFixture('direct');
|
||||
const source = structuredClone(f.game);
|
||||
// Remove the fixture's staged extra ride to exercise the final-ride path.
|
||||
source.wormRides = [];
|
||||
let game = reveal(source);
|
||||
game = act(game, 'm', {type:'decision', amount:1});
|
||||
const child = game.pendingAmbassador; assert.ok(child);
|
||||
assert.equal(child.entrant, 'm');
|
||||
game = act(game, 'ec', {type:'decision', event:child.event, decline:true});
|
||||
assert.equal(game.pendingAmbassador ?? null, null);
|
||||
assert.equal(game.pendingTerrorEntry ?? null, null);
|
||||
assert.notEqual(game.decision?.kind, 'wormRide');
|
||||
assert.deepEqual(game.wormRides, []);
|
||||
assert.equal(player(game, 'm').forces['arrakeen:10'], 1);
|
||||
});
|
||||
|
||||
for (const first of ['terror','ambassador'] as const satisfies readonly WormOrder[]) void test(
|
||||
`Advanced worm ${first}-first overlap finishes its original owners before the Sneak child and next ride`, () => {
|
||||
const f = advancedSneakWormFixture('overlap', first);
|
||||
let game = f.game;
|
||||
assert.equal(game.pendingArrivalOverlap?.first, first);
|
||||
assert.equal(game.pendingArrivalOverlap?.resume, 'wormRide');
|
||||
if (first === 'ambassador') {
|
||||
const original = game.pendingAmbassador; assert.ok(original);
|
||||
game = act(game, 'ec', {type:'decision', event:original.event, decline:true});
|
||||
}
|
||||
game = reveal(game);
|
||||
const beforeReserves = player(game, 'm').reserves;
|
||||
game = act(game, 'm', {type:'decision', amount:1});
|
||||
if (first === 'terror') {
|
||||
const original = game.pendingAmbassador; assert.ok(original);
|
||||
assert.equal(original.entrant, 'in');
|
||||
game = act(game, 'ec', {type:'decision', event:original.event, decline:true});
|
||||
}
|
||||
const child = game.pendingAmbassador; assert.ok(child);
|
||||
assert.equal(child.entrant, 'm');
|
||||
assert.equal(child.token, f.token);
|
||||
assert.notDeepEqual(game.decision, f.nextRide);
|
||||
game = act(game, 'ec', {type:'decision', event:child.event, decline:true});
|
||||
assert.deepEqual(game.decision, f.nextRide);
|
||||
assert.equal(game.pendingArrivalOverlap ?? null, null);
|
||||
assert.equal(player(game, 'm').reserves, beforeReserves - 1);
|
||||
assert.equal(player(game, 'm').forces['arrakeen:10'], 1);
|
||||
});
|
||||
|
||||
void test('Advanced worm Guild Ambassador child Sneak resolves before the parent rider resumes', () => {
|
||||
const f = advancedSneakWormFixture('ambassador', 'ambassador');
|
||||
let game = f.game;
|
||||
const entry = game.pendingAmbassador; assert.ok(entry);
|
||||
assert.equal(game.ecazAmbassadors?.tokens.find(token => token.id === entry.token)?.effect, 'guild');
|
||||
assert.equal(entry.resume, 'wormRide');
|
||||
const spice = player(game, 'ec').spice, ecReserves = player(game, 'ec').reserves;
|
||||
game = act(game, 'ec', {type:'decision', event:entry.event, trigger:true, beneficiary:'ec'});
|
||||
assert.equal(game.pendingAmbassador?.stage, 'ship');
|
||||
assert.ok(guildAmbassadorShipments(game, 'ec').destinations.some(row =>
|
||||
row.territory === f.childTerritory && row.sector === f.childSector));
|
||||
game = act(game, 'ec', {type:'decision', event:entry.event, amount:1, elite:0,
|
||||
territory:f.childTerritory, sector:f.childSector});
|
||||
assert.equal(game.pendingTerrorEntry?.resume, 'ambassador');
|
||||
assert.equal(game.pendingTerrorEntry?.token, f.sneak);
|
||||
game = reveal(game);
|
||||
const reserves = player(game, 'm').reserves;
|
||||
game = act(game, 'm', {type:'decision', amount:1});
|
||||
assert.equal(game.pendingAmbassador ?? null, null);
|
||||
assert.equal(game.pendingTerrorEntry ?? null, null);
|
||||
assert.deepEqual(game.decision, f.nextRide);
|
||||
assert.equal(player(game, 'm').reserves, reserves - 1);
|
||||
assert.equal(player(game, 'm').forces[`${f.childTerritory}:${f.childSector}`], 1);
|
||||
assert.equal(player(game, 'ec').reserves, ecReserves - 1);
|
||||
assert.equal(player(game, 'ec').spice, spice);
|
||||
assert.equal(player(game, 'ec').forces[`${f.childTerritory}:${f.childSector}`], 1);
|
||||
assert.equal(player(game, 'in').forces['arrakeen:10'], 2);
|
||||
});
|
||||
@@ -69,20 +69,39 @@ 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', () => {
|
||||
void test('Advanced worm Sneak preserves the original and child entries before the ride', () => {
|
||||
const { g } = table('sneakAttack');
|
||||
let state = wormRide(g);
|
||||
assert.match(viewGame(state, 'm').terrorEntry?.sneakAttack?.blocked ?? '', /worm ride/);
|
||||
assert.equal(viewGame(state, 'm').terrorEntry?.sneakAttack?.blocked, null);
|
||||
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});
|
||||
state = action(state, 'm', {type:'decision', amount:1});
|
||||
assert.equal(state.pendingArrivalOverlap?.stage, 'second');
|
||||
state = action(state, 'ec', {type:'decision', event:state.pendingAmbassador!.event, decline:true});
|
||||
assert.equal(state.pendingAmbassador?.entrant, 'm');
|
||||
state = action(state, 'ec', {type:'decision', event:state.pendingAmbassador!.event, decline:true});
|
||||
assert.deepEqual(state.decision, queuedRide);
|
||||
});
|
||||
|
||||
void test('a summoned worm during a held Sneak arrival keeps its Nexus before the next ride', () => {
|
||||
const { g } = table('sneakAttack');
|
||||
const karama = g.deck.find(card => card.effect === 'karama')!;
|
||||
g.deck = g.deck.filter(card => card.id !== karama.id);
|
||||
seat(g, 'in').hand = [karama];
|
||||
let state = wormRide(g);
|
||||
state = action(state, 'm', {type:'decision', reveal:true});
|
||||
state = action(state, 'm', {type:'decision', amount:1});
|
||||
assert.equal(state.pendingAmbassador?.entrant, 'in');
|
||||
state = action(state, 'in', {type:'card', mode:'special', card:karama.id, territory:'the_great_flat'});
|
||||
assert.equal(state.summonedNexusBeforeRides, true);
|
||||
state = action(state, 'ec', {type:'decision', event:state.pendingAmbassador!.event, decline:true});
|
||||
assert.equal(state.pendingAmbassador?.entrant, 'm');
|
||||
state = action(state, 'ec', {type:'decision', event:state.pendingAmbassador!.event, decline:true});
|
||||
assert.equal(state.pendingSneakWorm ?? null, null);
|
||||
assert.equal(state.nexus, true);
|
||||
assert.deepEqual(state.wormRides, ['hagga_basin']);
|
||||
assert.equal(state.decision ?? null, null);
|
||||
});
|
||||
|
||||
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,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createAmbassadors, placeAmbassador, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
||||
|
||||
export type WormSneakKind = 'direct' | 'overlap' | 'ambassador';
|
||||
export type WormOrder = 'terror' | 'ambassador';
|
||||
export const player = (game: Game, id: string) => {
|
||||
const result = game.players.find(p => p.id === id); assert.ok(result); return result;
|
||||
};
|
||||
export const act = (game: Game, id: string, action: Action) => applyAction(structuredClone(game), id, action);
|
||||
|
||||
/** Controlled Advanced worm position based on the existing overlap-worm
|
||||
* fixture. Seats, forces, card deck, physical markers and the first and second
|
||||
* rider decisions are staged; the accepted first ride and every subsequent
|
||||
* force placement and decision are actual engine actions. */
|
||||
export function advancedSneakWormFixture(kind: WormSneakKind, first: WormOrder = 'terror', withBG = false) {
|
||||
const game = createGame('ADVANCEDSNEAKWORM', newPlayer('ec', 'Ecaz', 'ecaz'), true, ['ecaz']);
|
||||
game.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('in', 'Rider', 'fremen'));
|
||||
if (withBG) game.players.push(newPlayer('bg', 'Bene Gesserit', 'beneGesserit'));
|
||||
const order = first === 'terror' ? ['m','ec','in'] : ['ec','m','in'];
|
||||
if (withBG) order.push('bg');
|
||||
Object.assign(game, {status:'playing', phase:1, turn:3, storm:18, active:null,
|
||||
order, movementRemaining:[...order], deck:baseDeck()});
|
||||
for (const seat of game.players)
|
||||
Object.assign(seat, {hand:[], spice:20, forces:{}, reserves:20});
|
||||
player(game,'in').forces = {'imperial_basin:10':3, 'hagga_basin:12':1};
|
||||
player(game,'in').reserves = 16;
|
||||
const ambassadors = createAmbassadors(() => 0.2);
|
||||
const effect: AmbassadorEffect = kind === 'direct' ? 'fremen' : kind === 'overlap' ? 'emperor' : 'guild';
|
||||
const token = ambassadors.tokens.find(row => row.effect === effect); assert.ok(token);
|
||||
ambassadors.cohort = [token.id,...ambassadors.tokens.filter(row => row.effect !== 'ecaz' && row.id !== token.id)
|
||||
.slice(0,4).map(row => row.id)];
|
||||
for (const row of ambassadors.tokens) {
|
||||
row.zone = row.effect === 'ecaz' || ambassadors.cohort.includes(row.id) ? 'supply' : 'pool';
|
||||
row.location = null;
|
||||
}
|
||||
game.ecazAmbassadors = placeAmbassador(ambassadors, token.id, {turn:2, availableSpice:20,
|
||||
destination:{id:'arrakeen',stronghold:true,inStorm:false,allowed:true}}).state;
|
||||
const terror = createTerrorState(() => 0.2), sneak = terror.tokens.find(row => row.kind === 'sneakAttack');
|
||||
assert.ok(sneak);
|
||||
const childTerritory = kind === 'ambassador' ? 'carthag' : 'arrakeen';
|
||||
const childSector = kind === 'ambassador' ? 11 : 10;
|
||||
game.moritaniTerror = placeTerror(terror, sneak.id, childTerritory, 1);
|
||||
game.decision = {kind:'wormRide', player:'in', territory:'imperial_basin'};
|
||||
game.wormRides = ['hagga_basin'];
|
||||
const entry = act(game, 'in', {type:'decision', accept:true, territory:'arrakeen', sector:10,
|
||||
forces:{'imperial_basin:10':2}});
|
||||
return {game:entry, token:token.id, sneak:sneak.id, kind, first, withBG, childTerritory, childSector,
|
||||
nextRide:{kind:'wormRide',player:'in',territory:'hagga_basin'} as const};
|
||||
}
|
||||
Reference in new issue
Block a user