Enable advanced mixed-faction paired 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
49f9ebdea0
commit
eb68f28a3a
9 files changed
+312
-65
No files matched your search
@@ -37,7 +37,9 @@ async function fixture(t: test.TestContext) {
|
||||
ornithopter: 'range3',
|
||||
});
|
||||
assert.equal(game.decision?.kind, 'choamMovement');
|
||||
// Restore the checkpoint's legacy topology after its genuine unspent declaration.
|
||||
// Restore an unsupported legacy Semuta topology after its genuine
|
||||
// unspent declaration; the ordinary mixed-faction profile is now supported.
|
||||
game.semutaPreview = true;
|
||||
addArrivalTerror(game);
|
||||
game.version = 31;
|
||||
const pending = arrivalReload(game),
|
||||
|
||||
@@ -28,47 +28,46 @@ function reject(g: Game, player: string, action: Action, pattern?: RegExp) {
|
||||
}
|
||||
const overlap = /Ambassadors combined with another arrival reaction/;
|
||||
|
||||
void test('the reproduced Tleilaxu arrival and native Ix/Fremen movement declarations reject known unsupported overlap before prevention windows or RNG', (t) => {
|
||||
const games = ['tleilaxu', 'ixians', 'fremen'].map((faction) => {
|
||||
const g = movementArrivalGame(faction as 'tleilaxu' | 'ixians' | 'fremen');
|
||||
if (faction !== 'tleilaxu') nativeArrivalSource(g);
|
||||
if (faction === 'fremen') addArrivalAmbassador(g, 'atreides');
|
||||
holdArrivalCard(g, 'choam', 'Karama');
|
||||
return g;
|
||||
});
|
||||
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
||||
throw Error('Unsupported arrival sampled RNG');
|
||||
});
|
||||
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
||||
throw Error('Unsupported arrival allocated an event');
|
||||
});
|
||||
for (const g of games) {
|
||||
reject(g, g.active!, arrivalMove(g), overlap);
|
||||
assert.equal(g.pendingChoamMove, undefined);
|
||||
assert.equal(g.pendingIxMove, undefined);
|
||||
assert.equal(g.pendingFremenMove, undefined);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.decision, null);
|
||||
void test('Tleilaxu and native Ix/Fremen declarations open their prevention windows before a paired arrival commits', () => {
|
||||
for (const faction of ['tleilaxu', 'ixians', 'fremen'] as const) {
|
||||
const before = movementArrivalGame(faction);
|
||||
if (faction !== 'tleilaxu') nativeArrivalSource(before);
|
||||
if (faction === 'fremen') addArrivalAmbassador(before, 'atreides');
|
||||
holdArrivalCard(before, 'choam', 'Karama');
|
||||
const forces = structuredClone(arrivalPlayer(before, faction).forces);
|
||||
const elites = structuredClone(arrivalPlayer(before, faction).elites);
|
||||
const ambassadors = structuredClone(before.ecazAmbassadors);
|
||||
const terror = structuredClone(before.moritaniTerror);
|
||||
const g = applyAction(before, faction, arrivalMove(before));
|
||||
assert.deepEqual(arrivalPlayer(g, faction).forces, forces);
|
||||
assert.deepEqual(arrivalPlayer(g, faction).elites, elites);
|
||||
assert.equal(arrivalPlayer(g, faction).moved, 0);
|
||||
assert.deepEqual(g.ecazAmbassadors, ambassadors);
|
||||
assert.deepEqual(g.moritaniTerror, terror);
|
||||
if (faction === 'tleilaxu') {
|
||||
assert.equal(g.decision?.kind, 'choamMovement');
|
||||
assert.ok(g.pendingChoamMove);
|
||||
} else {
|
||||
assert.equal(g.response?.kind, faction === 'ixians' ? 'ixMovement' : 'fremenMovement');
|
||||
assert.ok(faction === 'ixians' ? g.pendingIxMove : g.pendingFremenMove);
|
||||
}
|
||||
}
|
||||
assert.equal(random.mock.callCount(), 0);
|
||||
assert.equal(uuid.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
void test('known unsupported entry rejects before putting a newly played Ornithopter into escrow or creating its event', (t) => {
|
||||
const g = movementArrivalGame(),
|
||||
card = holdArrivalCard(g, 'tleilaxu', 'Ornithopter');
|
||||
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
||||
throw Error('Movement card allocated an event');
|
||||
});
|
||||
reject(
|
||||
g,
|
||||
void test('a paired arrival can put a newly played Ornithopter into escrow before the prevention choice', () => {
|
||||
const before = movementArrivalGame(),
|
||||
card = holdArrivalCard(before, 'tleilaxu', 'Ornithopter');
|
||||
const g = applyAction(
|
||||
before,
|
||||
'tleilaxu',
|
||||
{ ...arrivalMove(g), movementCard: card, ornithopter: 'range3' },
|
||||
overlap,
|
||||
{ ...arrivalMove(before), movementCard: card, ornithopter: 'range3' },
|
||||
);
|
||||
assert.ok(arrivalPlayer(g, 'tleilaxu').hand.some((c) => c.id === card));
|
||||
assert.equal(g.ornithopter, undefined);
|
||||
assert.equal(uuid.mock.callCount(), 0);
|
||||
assert.equal(g.decision?.kind, 'choamMovement');
|
||||
assert.ok(g.ornithopter);
|
||||
assert.equal(arrivalPlayer(g, 'tleilaxu').hand.some((c) => c.id === card), false);
|
||||
assert.equal(g.discard.some((c) => c.id === card), false);
|
||||
assert.deepEqual(arrivalPlayer(g, 'tleilaxu').forces, { 'arrakeen:10': 3 });
|
||||
assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 0);
|
||||
});
|
||||
|
||||
for (const tokens of ['ambassador', 'terror'] as const)
|
||||
@@ -132,8 +131,9 @@ void test('a saved CHOAM decline releases an uncommitted unsupported arrival wit
|
||||
ornithopter: 'range3',
|
||||
});
|
||||
const flight = structuredClone(g.ornithopter);
|
||||
// Model the persisted checkpoint: the unsupported second token was already
|
||||
// present in a legacy saved CHOAM window, whose declaration spent nothing.
|
||||
// Model a legacy saved CHOAM window where a still-unsupported Semuta
|
||||
// overlay and second token were already present after an unspent declaration.
|
||||
g.semutaPreview = true;
|
||||
addArrivalTerror(g);
|
||||
const before = structuredClone(g),
|
||||
resources = structuredClone(arrivalResources(g));
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
||||
import {
|
||||
addArrivalAmbassador,
|
||||
arrivalMove,
|
||||
arrivalPlayer,
|
||||
arrivalReload,
|
||||
holdArrivalCard,
|
||||
movementArrivalGame,
|
||||
nativeArrivalSource,
|
||||
} from './deferred-movement-arrival-fixture';
|
||||
|
||||
type Mover = 'tleilaxu' | 'ixians' | 'fremen';
|
||||
type Reaction = 'ambassador' | 'terror';
|
||||
|
||||
function table(mover: Mover, first: Reaction = 'ambassador') {
|
||||
const g = movementArrivalGame(mover);
|
||||
if (mover !== 'tleilaxu') nativeArrivalSource(g);
|
||||
// A Fremen entrant does not trigger the Fremen Ambassador.
|
||||
if (mover === 'fremen') addArrivalAmbassador(g, 'atreides');
|
||||
const order = g.order.filter(id => id !== 'ecaz' && id !== 'moritani');
|
||||
g.order = first === 'ambassador'
|
||||
? ['ecaz', 'moritani', ...order]
|
||||
: ['moritani', 'ecaz', ...order];
|
||||
return g;
|
||||
}
|
||||
|
||||
function declineReaction(g: Game, reaction: Reaction): Game {
|
||||
if (reaction === 'ambassador') {
|
||||
assert.ok(g.pendingAmbassador);
|
||||
return applyAction(arrivalReload(g), 'ecaz', {
|
||||
type: 'decision', event: g.pendingAmbassador.event, decline: true,
|
||||
});
|
||||
}
|
||||
assert.ok(g.pendingTerrorEntry);
|
||||
return applyAction(arrivalReload(g), 'moritani', { type: 'decision', decline: true });
|
||||
}
|
||||
|
||||
function assertMovedOnce(g: Game, mover: Mover) {
|
||||
assert.deepEqual(arrivalPlayer(g, mover).forces, { 'carthag:11': 3 });
|
||||
assert.equal(arrivalPlayer(g, mover).moved, 1);
|
||||
if (mover === 'ixians')
|
||||
assert.deepEqual(arrivalPlayer(g, mover).elites!.forces, { 'carthag:11': 1 });
|
||||
}
|
||||
|
||||
for (const mover of ['tleilaxu', 'ixians', 'fremen'] as const)
|
||||
for (const first of ['ambassador', 'terror'] as const) {
|
||||
void test(`${mover} movement through CHOAM decline resolves ${first} first once`, () => {
|
||||
let g = table(mover, first);
|
||||
const source = structuredClone(arrivalPlayer(g, mover).forces);
|
||||
const ambassadors = structuredClone(g.ecazAmbassadors);
|
||||
const terror = structuredClone(g.moritaniTerror);
|
||||
g = applyAction(g, mover, arrivalMove(g));
|
||||
assert.equal(g.decision?.kind, 'choamMovement');
|
||||
assert.equal(viewGame(g, 'choam').decision?.kind, 'choamMovement');
|
||||
assert.deepEqual(arrivalPlayer(g, mover).forces, source);
|
||||
assert.equal(arrivalPlayer(g, mover).moved, 0);
|
||||
assert.deepEqual(g.ecazAmbassadors, ambassadors);
|
||||
assert.deepEqual(g.moritaniTerror, terror);
|
||||
g = applyAction(arrivalReload(g), 'choam', { type: 'decision', decline: true });
|
||||
assert.equal(g.pendingChoamMove, null);
|
||||
assert.equal(g.pendingArrivalOverlap?.first, first);
|
||||
assert.equal(g.pendingArrivalOverlap?.stage, 'first');
|
||||
assert.equal(viewGame(g, mover).arrivalOverlap?.first, first);
|
||||
assert.equal(g.decision?.kind, first === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror');
|
||||
assertMovedOnce(g, mover);
|
||||
g = declineReaction(g, first);
|
||||
const second = first === 'ambassador' ? 'terror' : 'ambassador';
|
||||
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
|
||||
assert.equal(g.decision?.kind, second === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror');
|
||||
assertMovedOnce(g, mover);
|
||||
g = declineReaction(g, second);
|
||||
assert.equal(g.pendingArrivalOverlap, null);
|
||||
assert.equal(g.pendingAmbassador, null);
|
||||
assert.equal(g.pendingTerrorEntry, null);
|
||||
assert.equal(g.decision, null);
|
||||
assert.equal(g.active, mover);
|
||||
assertMovedOnce(g, mover);
|
||||
assert.deepEqual(g.ecazAmbassadors, ambassadors);
|
||||
assert.deepEqual(g.moritaniTerror, terror);
|
||||
assert.throws(() => applyAction(g, mover, arrivalMove(g)));
|
||||
});
|
||||
}
|
||||
|
||||
void test('Baliset prevents the overlapping move without spending movement or arrival tokens', () => {
|
||||
let g = table('tleilaxu');
|
||||
const baliset = holdArrivalCard(g, 'choam', 'Baliset');
|
||||
holdArrivalCard(g, 'tleilaxu', 'Karama');
|
||||
const source = structuredClone(arrivalPlayer(g, 'tleilaxu').forces);
|
||||
const ambassadors = structuredClone(g.ecazAmbassadors);
|
||||
const terror = structuredClone(g.moritaniTerror);
|
||||
const remaining = structuredClone(g.movementRemaining);
|
||||
g = applyAction(g, 'tleilaxu', arrivalMove(g));
|
||||
g = applyAction(g, 'choam', {
|
||||
type: 'card', card: baliset, mode: 'choam', target: 'tleilaxu', territory: 'carthag',
|
||||
});
|
||||
assert.equal(g.response?.kind, 'choamWorthless');
|
||||
assert.ok(viewGame(g, 'tleilaxu').responseControls?.cancelCards.length);
|
||||
g = applyAction(arrivalReload(g), 'tleilaxu', { type: 'passResponse' });
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.pendingChoamMove, null);
|
||||
assert.equal(g.pendingArrivalOverlap ?? null, null);
|
||||
assert.deepEqual(arrivalPlayer(g, 'tleilaxu').forces, source);
|
||||
assert.equal(arrivalPlayer(g, 'tleilaxu').moved, 0);
|
||||
assert.deepEqual(g.movementRemaining, remaining);
|
||||
assert.deepEqual(g.ecazAmbassadors, ambassadors);
|
||||
assert.deepEqual(g.moritaniTerror, terror);
|
||||
assert.equal(g.discard.filter(card => card.id === baliset).length, 1);
|
||||
});
|
||||
|
||||
void test('CHOAM bot profiles have a legal path through the overlapping movement offer', () => {
|
||||
const initial = table('tleilaxu');
|
||||
const g = applyAction(initial, 'tleilaxu', arrivalMove(initial));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(g, 'choam');
|
||||
view.players.find(p => p.id === 'choam')!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action, difficulty);
|
||||
const next = applyAction(g, 'choam', action);
|
||||
assert.equal(next.pendingArrivalOverlap?.stage, 'first');
|
||||
assertMovedOnce(next, 'tleilaxu');
|
||||
}
|
||||
});
|
||||
|
||||
void test('Robbery and the Ecaz Ambassador resolve their effects after CHOAM allows movement', () => {
|
||||
let g = table('tleilaxu', 'terror');
|
||||
const terror = createTerrorState(() => 0.2);
|
||||
const robbery = terror.tokens.find(token => token.kind === 'robbery')!;
|
||||
g.moritaniTerror = placeTerror(terror, robbery.id, 'carthag', 2);
|
||||
const ambassadors = createAmbassadors(() => 0.2);
|
||||
const ecaz = ambassadors.tokens.find(token => token.effect === 'ecaz')!;
|
||||
g.ecazAmbassadors = placeAmbassador(ambassadors, ecaz.id, {
|
||||
turn: 2, availableSpice: 20,
|
||||
destination: { id: 'carthag', stronghold: true, inStorm: false, allowed: true },
|
||||
}).state;
|
||||
const moverSpice = arrivalPlayer(g, 'tleilaxu').spice;
|
||||
const moritaniSpice = arrivalPlayer(g, 'moritani').spice;
|
||||
const stolen = Math.ceil(moverSpice / 2);
|
||||
g = applyAction(g, 'tleilaxu', arrivalMove(g));
|
||||
g = applyAction(g, 'choam', { type: 'decision', decline: true });
|
||||
assert.equal(g.pendingArrivalOverlap?.first, 'terror');
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
||||
assertMovedOnce(g, 'tleilaxu');
|
||||
g = applyAction(g, 'moritani', { type: 'decision', reveal: true });
|
||||
assert.equal(g.pendingTerrorEntry?.stage, 'robbery');
|
||||
g = applyAction(g, 'moritani', { type: 'decision', choice: 'spice' });
|
||||
assert.equal(arrivalPlayer(g, 'tleilaxu').spice, moverSpice - stolen);
|
||||
assert.equal(arrivalPlayer(g, 'moritani').spice, moritaniSpice + stolen);
|
||||
assert.equal(g.moritaniTerror!.tokens.find(token => token.id === robbery.id)?.status, 'removed');
|
||||
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
|
||||
assertMovedOnce(g, 'tleilaxu');
|
||||
assert.ok(g.pendingAmbassador);
|
||||
g = applyAction(g, 'ecaz', {
|
||||
type: 'decision', event: g.pendingAmbassador.event, trigger: true,
|
||||
beneficiary: 'ecaz', choice: 'duke',
|
||||
});
|
||||
assert.equal(g.dukeVidal?.controller, 'ecaz');
|
||||
assert.equal(g.ecazAmbassadors!.tokens.find(token => token.id === ecaz.id)?.zone, 'supply');
|
||||
assert.equal(g.pendingArrivalOverlap, null);
|
||||
assert.equal(arrivalPlayer(g, 'tleilaxu').spice, moverSpice - stolen);
|
||||
assert.equal(arrivalPlayer(g, 'moritani').spice, moritaniSpice + stolen);
|
||||
assertMovedOnce(g, 'tleilaxu');
|
||||
});
|
||||
|
||||
void test('Karama cancels Baliset and resumes both reactions for the original move', () => {
|
||||
let g = table('tleilaxu');
|
||||
const baliset = holdArrivalCard(g, 'choam', 'Baliset');
|
||||
const karama = holdArrivalCard(g, 'tleilaxu', 'Karama');
|
||||
g = applyAction(g, 'tleilaxu', arrivalMove(g));
|
||||
g = applyAction(g, 'choam', {
|
||||
type: 'card', card: baliset, mode: 'choam', target: 'tleilaxu', territory: 'carthag',
|
||||
});
|
||||
assert.equal(g.response?.kind, 'choamWorthless');
|
||||
g = applyAction(arrivalReload(g), 'tleilaxu', { type: 'card', card: karama, mode: 'cancel' });
|
||||
assert.equal(g.discard.filter(card => card.id === karama).length, 1);
|
||||
assert.ok(arrivalPlayer(g, 'choam').hand.some(card => card.id === baliset));
|
||||
assert.equal(g.pendingArrivalOverlap?.first, 'ambassador');
|
||||
assertMovedOnce(g, 'tleilaxu');
|
||||
g = declineReaction(g, 'ambassador');
|
||||
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
|
||||
g = declineReaction(g, 'terror');
|
||||
assert.equal(g.pendingArrivalOverlap, null);
|
||||
assertMovedOnce(g, 'tleilaxu');
|
||||
});
|
||||
@@ -598,8 +598,8 @@ function balisetFlight(mode: 'range3' | 'twoGroups', conflict = false) {
|
||||
});
|
||||
assert.equal(g.response?.kind, 'choamWorthless');
|
||||
assert.equal(g.pendingChoamWorthless?.movement, true);
|
||||
// Fresh declarations now reject this topology before opening CHOAM. Model
|
||||
// an already-persisted window (or changed topology) to retain cost preflight coverage.
|
||||
// Model a saved prevention window whose destination gained the paired
|
||||
// arrival tokens after the original uncommitted declaration.
|
||||
if (conflict) addLegacyConflict();
|
||||
assert.deepEqual(physical(g), inventory);
|
||||
return { g, orni, baliset, printed, worthless, destination };
|
||||
@@ -617,37 +617,46 @@ function allowConversion(initial: Game) {
|
||||
|
||||
for (const form of ['printed', 'worthless'] as const)
|
||||
for (const mode of ['range3', 'twoGroups'] as const)
|
||||
void test(`${form} cancellation of real Baliset rejects competing Ambassador and Terror arrival before cost (${mode})`, (t) => {
|
||||
const { g, printed, worthless, orni, baliset } = balisetFlight(
|
||||
void test(`${form} cancellation of real Baliset commits one ${mode} flight and serializes both arrival reactions`, () => {
|
||||
const { g, printed, worthless, orni, baliset, destination } = balisetFlight(
|
||||
mode,
|
||||
true,
|
||||
);
|
||||
const before = structuredClone(g);
|
||||
const inventory = physical(g);
|
||||
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
||||
throw Error('Rejected arrival preflight drew randomness.');
|
||||
const spent = form === 'printed' ? printed : worthless;
|
||||
let done = applyAction(g, form === 'printed' ? 'p' : 'b', {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card: spent,
|
||||
});
|
||||
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
||||
throw Error('Rejected arrival preflight created an event.');
|
||||
});
|
||||
assert.throws(() =>
|
||||
applyAction(g, form === 'printed' ? 'p' : 'b', {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card: form === 'printed' ? printed : worthless,
|
||||
}));
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(physical(g), inventory);
|
||||
assert.equal(g.pendingKarama ?? null, null);
|
||||
assert.equal(g.ornithopter?.card.id, orni);
|
||||
assert.equal(
|
||||
g.discard.some((c) =>
|
||||
[printed, worthless, baliset, orni].includes(c.id),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(random.mock.callCount(), 0);
|
||||
assert.equal(uuid.mock.callCount(), 0);
|
||||
if (form === 'worthless') {
|
||||
assert.equal(done.response?.kind, 'worthlessKarama');
|
||||
assert.equal(seat(done, 'p').forces[destination] ?? 0, 0);
|
||||
done = allowConversion(reload(done));
|
||||
}
|
||||
assert.equal(seat(done, 'p').forces[destination], 1);
|
||||
assert.equal(seat(done, 'p').forces[source], mode === 'twoGroups' ? 1 : 2);
|
||||
assert.equal(seat(done, 'p').moved, mode === 'twoGroups' ? 2 : 1);
|
||||
assert.equal(done.ornithopter, null);
|
||||
assert.equal(done.pendingChoamMove, null);
|
||||
assert.equal(done.discard.filter((c) => c.id === orni).length, 1);
|
||||
assert.equal(done.discard.filter((c) => c.id === spent).length, 1);
|
||||
assert.ok(seat(done, 'c').hand.some((c) => c.id === baliset));
|
||||
assert.deepEqual(physical(done), inventory);
|
||||
assert.equal(done.decision?.kind, 'ecazAmbassador');
|
||||
done = applyAction(reload(done), 'e', {
|
||||
type: 'decision',
|
||||
event: done.pendingAmbassador!.event,
|
||||
decline: true,
|
||||
});
|
||||
assert.equal(done.decision?.kind, 'moritaniTerror');
|
||||
done = applyAction(reload(done), 'm', { type: 'decision', decline: true });
|
||||
assert.equal(done.decision, null);
|
||||
assert.equal(seat(done, 'p').forces[destination], 1);
|
||||
assert.equal(seat(done, 'p').moved, mode === 'twoGroups' ? 2 : 1);
|
||||
assert.deepEqual(physical(done), inventory);
|
||||
});
|
||||
|
||||
for (const form of ['printed', 'worthless'] as const)
|
||||
|
||||
Reference in new issue
Block a user