Connect bounded arrival overlap and Fremen Nexus movement Betrayal

This commit is contained in:
Quintine committed 2026-09-29 18:01:14 +10:00
1 parent 8ddd138e87
commit b2603f6715
26 files changed
+1644 -118

No files matched your search

@@ -0,0 +1,250 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
type Owner = 'ambassador' | 'terror';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const shipment: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 };
const movement: Action = {
type: 'move',
from: 'imperial_basin:10',
territory: 'arrakeen',
sector: 10,
amount: 2,
};
/** Both inventories are physical, legally placed in the same stronghold. No optional module competes for arrival. */
function table(first: Owner, advanced: boolean, cause: 'shipment' | 'movement' = 'shipment') {
const g = createGame('OVERLAP', newPlayer('ec', 'Ecaz', 'ecaz'), advanced, ['ecaz']);
g.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('in', 'Entrant', 'harkonnen'));
Object.assign(g, {
status: 'playing', phase: 5, turn: 3, storm: 18, active: 'in',
order: first === 'ambassador' ? ['ec', 'm', 'in'] : ['m', 'ec', 'in'],
deck: baseDeck(),
});
g.movementRemaining = [...g.order];
for (const player of g.players) {
Object.assign(player, { hand: [], spice: 20, forces: {}, reserves: 20, shipped: false, moved: 0 });
}
if (cause === 'movement') {
Object.assign(seat(g, 'in'), {
shipped: true,
forces: { 'imperial_basin:10': 3 },
reserves: 17,
});
}
const ambassadors = createAmbassadors(() => 0.2);
const emperor = ambassadors.tokens.find((token) => token.effect === 'emperor')!;
ambassadors.cohort = [
emperor.id,
...ambassadors.tokens.filter((token) => token.effect !== 'ecaz' && token.id !== emperor.id)
.slice(0, 4).map((token) => token.id),
];
for (const token of ambassadors.tokens) {
token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) ? 'supply' : 'pool';
token.location = null;
}
g.ecazAmbassadors = placeAmbassador(ambassadors, emperor.id, {
turn: 2,
availableSpice: 20,
destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true },
}).state;
const terror = createTerrorState(() => 0.2);
const robbery = terror.tokens.find((token) => token.kind === 'robbery')!;
g.moritaniTerror = placeTerror(terror, robbery.id, 'arrakeen', 2);
return { g, ambassadorToken: emperor.id, terrorToken: robbery.id };
}
function unchangedRejection(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
function overlap(g: Game, first: Owner, active: Owner) {
const pending = g.pendingArrivalOverlap;
assert.ok(pending);
assert.equal(pending.first, first);
assert.equal(pending.stage, first === active ? 'first' : 'second');
assert.equal(pending.entrant, 'in');
assert.equal(pending.territory, 'arrakeen');
assert.equal(pending.sector, 10);
assert.equal(pending.amount, 2);
assert.equal(pending.elite, 0);
assert.equal(pending.turn, 3);
assert.equal(pending.phase, 5);
assert.equal(pending.ecaz, 'ec');
assert.equal(pending.moritani, 'm');
assert.ok(pending.signature);
for (const id of ['ec', 'm', 'in']) {
const view = viewGame(g, id);
assert.deepEqual(view.arrivalOverlap, {
first, active, entrant: 'in', territory: 'arrakeen',
});
assert.equal(JSON.stringify(view.arrivalOverlap).includes(pending.signature), false);
assert.equal('pendingArrivalOverlap' in view, false);
}
return pending;
}
function privateTerror(g: Game, token: string) {
const owner = viewGame(g, 'm');
const entrant = viewGame(g, 'in');
const ecaz = viewGame(g, 'ec');
const ownToken = owner.moritaniTerror!.tokens.find((t) => t.id === token)!;
const foreignTokens = [entrant, ecaz].map((v) => v.moritaniTerror!.tokens.find((t) => t.id === token)!);
assert.equal('kind' in ownToken && ownToken.kind, 'robbery');
for (const foreignToken of foreignTokens) assert.equal('kind' in foreignToken, false);
for (const view of [entrant, ecaz]) {
assert.equal('pendingTerrorEntry' in view, false);
assert.equal(JSON.stringify(view).includes('"kind":"robbery"'), false);
}
if (g.pendingTerrorEntry) {
assert.equal(owner.terrorEntry && 'kind' in owner.terrorEntry && owner.terrorEntry.kind, 'robbery');
for (const view of [entrant, ecaz]) assert.equal('kind' in (view.terrorEntry ?? {}), false);
}
}
function decideAmbassador(g: Game, trigger: boolean) {
return applyAction(g, 'ec', {
type: 'decision', event: g.pendingAmbassador!.event,
...(trigger ? { trigger: true, beneficiary: 'ec' } : { decline: true }),
});
}
function decideTerror(g: Game, reveal: boolean) {
return applyAction(g, 'm', { type: 'decision', ...(reveal ? { reveal: true } : { decline: true }) });
}
for (const advanced of [false, true])
for (const first of ['ambassador', 'terror'] as const)
for (const cause of ['shipment', 'movement'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${cause}: ${first} first, both optional offers and original entry survive JSON`, () => {
const { g: initial, ambassadorToken, terrorToken } = table(first, advanced, cause);
const action = cause === 'shipment' ? shipment : movement;
const offered = applyAction(initial, 'in', action);
const witness = overlap(offered, first, first);
assert.equal(witness.cause, cause);
assert.equal(witness.resume, 'none');
assert.equal(witness.ambassadorToken, ambassadorToken);
assert.deepEqual(witness.terrorTokens, [terrorToken]);
assert.equal(offered.decision?.kind, first === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror');
assert.equal(offered.decision?.player, first === 'ambassador' ? 'ec' : 'm');
assert.equal(offered.pendingAmbassador?.entrant ?? offered.pendingTerrorEntry?.entrant, 'in');
assert.equal(seat(offered, 'in').forces['arrakeen:10'], 2);
assert.equal(seat(offered, 'in').reserves, cause === 'shipment' ? 18 : 17);
assert.equal(seat(offered, 'in').spice, cause === 'shipment' ? 18 : 20);
if (cause === 'shipment') assert.equal(seat(offered, 'in').shipped, true);
else {
assert.equal(seat(offered, 'in').forces['imperial_basin:10'], 1);
assert.equal(seat(offered, 'in').moved, 1);
}
privateTerror(offered, terrorToken);
unchangedRejection(offered, 'in', { type: 'endMovement' });
unchangedRejection(offered, first === 'ambassador' ? 'm' : 'ec', {
type: 'decision', decline: true,
});
let second = first === 'ambassador'
? decideAmbassador(reload(offered), false)
: decideTerror(reload(offered), false);
const next = first === 'ambassador' ? 'terror' : 'ambassador';
const secondWitness = overlap(second, first, next);
assert.equal(secondWitness.signature, witness.signature);
assert.equal(secondWitness.cause, cause);
assert.equal(secondWitness.entrant, witness.entrant);
assert.equal(second.decision?.kind, next === 'ambassador' ? 'ecazAmbassador' : 'moritaniTerror');
assert.equal(second.decision?.player, next === 'ambassador' ? 'ec' : 'm');
assert.equal(second.pendingAmbassador?.entrant ?? second.pendingTerrorEntry?.entrant, 'in');
assert.equal(seat(second, 'in').forces['arrakeen:10'], 2);
privateTerror(second, terrorToken);
unchangedRejection(second, first === 'ambassador' ? 'ec' : 'm', {
type: 'decision',
...(first === 'ambassador' ? { event: offered.pendingAmbassador!.event } : {}),
decline: true,
});
second = next === 'ambassador'
? decideAmbassador(reload(second), false)
: decideTerror(reload(second), false);
assert.equal(second.pendingArrivalOverlap ?? null, null);
assert.equal(second.pendingAmbassador ?? null, null);
assert.equal(second.pendingTerrorEntry ?? null, null);
assert.equal(second.decision, null);
assert.equal(viewGame(second, 'in').arrivalOverlap, null);
assert.equal(seat(second, 'in').forces['arrakeen:10'], 2);
assert.equal(seat(second, 'in').spice, cause === 'shipment' ? 18 : 20);
assert.equal(second.ecazAmbassadors!.tokens.find((t) => t.id === ambassadorToken)!.zone, 'placed');
assert.equal(second.moritaniTerror!.tokens.find((t) => t.id === terrorToken)!.status, 'placed');
unchangedRejection(second, 'in', action);
});
}
for (const first of ['ambassador', 'terror'] as const) {
void test(`${first} first: persisted arrival witness rejects changed entry and owner order`, () => {
const offered = applyAction(table(first, false).g, 'in', shipment);
for (const change of [
(g: Game) => { g.pendingArrivalOverlap!.amount = 3; },
(g: Game) => { g.pendingArrivalOverlap!.entrant = 'ec'; },
(g: Game) => { g.pendingArrivalOverlap!.terrorTokens = []; },
(g: Game) => { g.pendingArrivalOverlap!.first = first === 'ambassador' ? 'terror' : 'ambassador'; },
(g: Game) => { g.order.reverse(); },
]) {
const corrupt = reload(offered);
change(corrupt);
unchangedRejection(corrupt, first === 'ambassador' ? 'ec' : 'm', first === 'ambassador'
? { type: 'decision', event: corrupt.pendingAmbassador!.event, decline: true }
: { type: 'decision', decline: true });
}
});
}
for (const first of ['ambassador', 'terror'] as const) {
void test(`${first} first: Emperor and Robbery settle exactly once with original entrant and private token custody`, () => {
const { g, ambassadorToken, terrorToken } = table(first, true);
let state = applyAction(g, 'in', shipment);
const signature = overlap(state, first, first).signature;
if (first === 'ambassador') {
const event = state.pendingAmbassador!.event;
state = decideAmbassador(reload(state), true);
assert.equal(seat(state, 'ec').spice, 25);
assert.equal(state.ecazAmbassadors!.tokens.find((t) => t.id === ambassadorToken)!.zone, 'used');
overlap(state, first, 'terror');
privateTerror(state, terrorToken);
unchangedRejection(state, 'ec', { type: 'decision', event, trigger: true, beneficiary: 'ec' });
state = decideTerror(reload(state), true);
assert.equal(state.pendingTerrorEntry?.stage, 'robbery');
assert.equal(state.pendingTerrorEntry?.entrant, 'in');
assert.equal(state.pendingArrivalOverlap?.signature, signature);
state = applyAction(reload(state), 'm', { type: 'decision', choice: 'spice' });
} else {
state = decideTerror(reload(state), true);
assert.equal(state.pendingTerrorEntry?.stage, 'robbery');
assert.equal(state.pendingTerrorEntry?.entrant, 'in');
assert.equal(state.pendingArrivalOverlap?.signature, signature);
state = applyAction(reload(state), 'm', { type: 'decision', choice: 'spice' });
const event = state.pendingAmbassador!.event;
overlap(state, first, 'ambassador');
assert.equal(state.pendingAmbassador?.entrant, 'in');
state = decideAmbassador(reload(state), true);
unchangedRejection(state, 'ec', { type: 'decision', event, trigger: true, beneficiary: 'ec' });
}
assert.equal(state.pendingArrivalOverlap ?? null, null);
assert.equal(state.decision, null);
assert.equal(seat(state, 'in').spice, 9);
assert.equal(seat(state, 'm').spice, 29);
assert.equal(seat(state, 'ec').spice, 25);
assert.equal(seat(state, 'in').reserves, 18);
assert.equal(seat(state, 'in').forces['arrakeen:10'], 2);
assert.equal(state.ecazAmbassadors!.tokens.find((t) => t.id === ambassadorToken)!.zone, 'used');
assert.equal(state.moritaniTerror!.tokens.find((t) => t.id === terrorToken)!.status, 'removed');
assert.equal(state.log.filter((line) => line.automatic?.name === 'Emperor Ambassador').length, 1);
unchangedRejection(state, 'm', { type: 'decision', choice: 'spice' });
unchangedRejection(state, 'in', shipment);
assert.deepEqual(reload(state).players, state.players);
});
}
@@ -0,0 +1,281 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import type * as Rooms from '../db/rooms';
import type { RoomsClock, SeatAuth } from '../db/rooms';
import { viewGame, type Action } from '../game/engine';
import { baseDeck } from '../game/cards';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import { unitStore } from './fixture-nexus-room-store';
const clock: RoomsClock = { now: () => 45000, sleep: async () => {} };
async function room(t: test.TestContext, ecazFirst: boolean) {
const store = unitStore();
t.after(() => store.sqlite.close());
const created = await store.rooms.createRoom('Ecaz', 'ecaz', false, ['ecaz']);
const code = created.view.code;
const moritani = await store.rooms.joinRoom(code, 'Moritani', 'moritani');
const entrant = await store.rooms.joinRoom(code, 'Entrant', 'harkonnen');
const tokens = [created.token, moritani.token!, entrant.token!];
const auth = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token)));
const [ec, m, incoming] = auth.map((seat) => seat.playerId);
const g = await store.rooms.readRoom(code);
Object.assign(g, {
status: 'playing', phase: 5, turn: 3, storm: 18, active: incoming,
order: ecazFirst ? [ec, m, incoming] : [m, ec, incoming], deck: baseDeck(),
});
g.movementRemaining = [...g.order];
for (const player of g.players)
Object.assign(player, { hand: [], spice: 20, forces: {}, reserves: 20, shipped: false, moved: 0 });
const ambassadors = createAmbassadors(() => 0.2);
const emperor = ambassadors.tokens.find((token) => token.effect === 'emperor')!;
ambassadors.cohort = [emperor.id, ...ambassadors.tokens
.filter((token) => token.effect !== 'ecaz' && token.id !== emperor.id)
.slice(0, 4).map((token) => token.id)];
for (const token of ambassadors.tokens) {
token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) ? 'supply' : 'pool';
token.location = null;
}
g.ecazAmbassadors = placeAmbassador(ambassadors, emperor.id, {
turn: 2, availableSpice: 20,
destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true },
}).state;
const terror = createTerrorState(() => 0.2);
const robbery = terror.tokens.find((token) => token.kind === 'robbery')!;
g.moritaniTerror = placeTerror(terror, robbery.id, 'arrakeen', 2);
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
const ship: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 };
const offered = await store.rooms.act(code, auth[2], g.version, ship, clock);
assert.deepEqual(offered.arrivalOverlap, {
first: ecazFirst ? 'ambassador' : 'terror',
active: ecazFirst ? 'ambassador' : 'terror',
entrant: incoming, territory: 'arrakeen',
});
store.writes.length = 0;
return { ...store, code, tokens, auth, ids: { ec, m, incoming }, ship, emperor: emperor.id, robbery: robbery.id };
}
interface Fixture {
sqlite: DatabaseSync;
restart: () => typeof Rooms;
code: string;
tokens: string[];
auth: SeatAuth[];
ids: { ec: string; m: string; incoming: string };
ship: Action;
emperor: string;
robbery: string;
writes: { expected: number; changes: number }[];
}
const rows = (f: { sqlite: DatabaseSync }) => f.sqlite.prepare('SELECT state, version FROM rooms ORDER BY code').all();
async function saved(f: Fixture) {
const g = await f.restart().readRoom(f.code);
const before = rows(f);
for (let index = 0; index < f.tokens.length; index++) {
const rooms = f.restart();
const auth = await rooms.authenticate(f.code, f.tokens[index]);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(g, auth.playerId));
assert.equal('pendingArrivalOverlap' in view, false);
assert.equal('pendingTerrorEntry' in view, false);
assert.equal('kind' in view.moritaniTerror!.tokens.find((token) => token.id === f.robbery)!, index === 1 ||
g.moritaniTerror!.tokens.find((token) => token.id === f.robbery)!.status === 'removed');
assert.equal(JSON.stringify(view.arrivalOverlap).includes(f.robbery), false);
assert.equal(JSON.stringify(view.arrivalOverlap).includes(g.pendingArrivalOverlap?.signature ?? 'unavailable'), false);
}
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(rows(f), before);
return g;
}
for (const ecazFirst of [true, false]) {
void test(`SQLite CAS restores ${ecazFirst ? 'Ecaz-first' : 'Moritani-first'} two-stage arrival without replaying shipment`, async (t) => {
const f = await room(t, ecazFirst);
let g = await saved(f);
const version = g.version;
const signature = g.pendingArrivalOverlap!.signature;
const wrongOwner = ecazFirst ? 1 : 0;
const firstOwner = ecazFirst ? 0 : 1;
const first: Action = ecazFirst
? { type: 'decision', event: g.pendingAmbassador!.event, trigger: true, beneficiary: f.ids.ec }
: { type: 'decision', reveal: true };
const before = rows(f);
await assert.rejects(f.restart().act(f.code, f.auth[wrongOwner], version, first, clock));
await assert.rejects(f.restart().act(f.code, f.auth[firstOwner], version - 1, first, clock), /table changed/);
assert.deepEqual(rows(f), before);
assert.equal(f.writes.length, 0);
await f.restart().act(f.code, f.auth[firstOwner], version, first, clock);
g = await saved(f);
assert.equal(g.pendingArrivalOverlap!.signature, signature);
assert.equal(g.pendingArrivalOverlap!.entrant, f.ids.incoming);
assert.equal(g.players[2].forces['arrakeen:10'], 2);
assert.equal(g.players[2].reserves, 18);
assert.equal(g.players[2].spice, 18);
if (!ecazFirst) {
assert.equal(g.pendingTerrorEntry?.stage, 'robbery');
await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock);
g = await saved(f);
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
assert.equal(g.decision?.kind, 'ecazAmbassador');
} else {
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
assert.equal(g.decision?.kind, 'moritaniTerror');
}
const prior = rows(f);
await assert.rejects(f.restart().act(f.code, f.auth[firstOwner], g.version, first, clock));
assert.deepEqual(rows(f), prior);
if (ecazFirst) {
await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', reveal: true }, clock);
g = await saved(f);
assert.equal(g.pendingTerrorEntry?.entrant, f.ids.incoming);
await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock);
} else {
await f.restart().act(f.code, f.auth[0], g.version, {
type: 'decision', event: g.pendingAmbassador!.event, trigger: true, beneficiary: f.ids.ec,
}, clock);
}
g = await saved(f);
assert.equal(g.pendingArrivalOverlap ?? null, null);
assert.equal(g.pendingAmbassador ?? null, null);
assert.equal(g.pendingTerrorEntry ?? null, null);
assert.equal(g.decision, null);
assert.equal(g.players[2].spice, 9);
assert.equal(g.players[1].spice, 29);
assert.equal(g.players[0].spice, 25);
assert.equal(g.players[2].forces['arrakeen:10'], 2);
assert.equal(g.ecazAmbassadors!.tokens.find((token) => token.id === f.emperor)!.zone, 'used');
assert.equal(g.moritaniTerror!.tokens.find((token) => token.id === f.robbery)!.status, 'removed');
const finished = rows(f);
await assert.rejects(f.restart().act(f.code, f.auth[2], g.version, f.ship, clock));
await assert.rejects(f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock));
assert.deepEqual(rows(f), finished);
});
}
const ride: Action = {
type: 'decision', accept: true, territory: 'arrakeen', sector: 10,
forces: { 'imperial_basin:10': 2 },
};
/** A phase-one Fremen ride into the shared stronghold, with a second ride still queued. */
async function wormRoom(t: test.TestContext, advanced: boolean) {
const store = unitStore();
t.after(() => store.sqlite.close());
const created = await store.rooms.createRoom('Ecaz', 'ecaz', advanced, ['ecaz']);
const code = created.view.code;
const moritani = await store.rooms.joinRoom(code, 'Moritani', 'moritani');
const rider = await store.rooms.joinRoom(code, 'Rider', 'fremen');
const tokens = [created.token, moritani.token!, rider.token!];
const auth = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token)));
const [ec, m, incoming] = auth.map((seat) => seat.playerId);
const g = await store.rooms.readRoom(code);
Object.assign(g, {
status: 'playing', phase: 1, turn: 3, storm: 18, active: null,
order: [m, ec, incoming], deck: baseDeck(),
});
g.movementRemaining = [...g.order];
for (const player of g.players)
Object.assign(player, { hand: [], spice: 20, forces: {}, reserves: 20, shipped: false, moved: 0 });
g.players[2].forces = { 'imperial_basin:10': 3, 'hagga_basin:12': 1 };
g.players[2].reserves = 16;
const ambassadors = createAmbassadors(() => 0.2);
const emperor = ambassadors.tokens.find((token) => token.effect === 'emperor')!;
ambassadors.cohort = [emperor.id, ...ambassadors.tokens
.filter((token) => token.effect !== 'ecaz' && token.id !== emperor.id)
.slice(0, 4).map((token) => token.id)];
for (const token of ambassadors.tokens) {
token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id) ? 'supply' : 'pool';
token.location = null;
}
g.ecazAmbassadors = placeAmbassador(ambassadors, emperor.id, {
turn: 2, availableSpice: 20,
destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true },
}).state;
const terror = createTerrorState(() => 0.2);
const robbery = terror.tokens.find((token) => token.kind === 'robbery')!;
g.moritaniTerror = placeTerror(terror, robbery.id, 'arrakeen', 2);
g.decision = { kind: 'wormRide', player: incoming, territory: 'imperial_basin' };
g.wormRides = ['hagga_basin'];
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
store.writes.length = 0;
return {
...store, code, tokens, auth, ids: { ec, m, incoming },
robbery: robbery.id, emperor: emperor.id,
};
}
for (const advanced of [false, true]) {
void test(`SQLite CAS restores a ${advanced ? 'Advanced' : 'Basic'} Fremen worm ride through both arrival reactions`, async (t) => {
const f = await wormRoom(t, advanced);
const read = async () => f.restart().readRoom(f.code);
let g = await read();
const version = g.version;
const rideOffered = await f.restart().act(f.code, f.auth[2], version, ride, clock);
assert.equal('pendingArrivalOverlap' in rideOffered, false);
g = await read();
assert.equal(g.pendingArrivalOverlap?.resume, 'wormRide');
assert.equal(g.pendingArrivalOverlap?.cause, 'wormRide');
assert.equal(g.pendingArrivalOverlap?.first, 'terror');
assert.equal(g.pendingTerrorEntry?.cause, 'wormRide');
assert.equal(g.pendingTerrorEntry?.resume, 'none');
assert.deepEqual(g.wormRides, ['hagga_basin']);
assert.equal(g.players[2].forces['arrakeen:10'], 2);
const spiceAfterRide = g.players[2].spice;
const views = await Promise.all(f.tokens.map(async (token) => {
const rooms = f.restart();
const auth = await rooms.authenticate(f.code, token);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(await read(), auth.playerId));
assert.equal('pendingArrivalOverlap' in view, false);
assert.equal('pendingTerrorEntry' in view, false);
return view;
}));
assert.deepEqual(views[0].arrivalOverlap, {
first: 'terror', active: 'terror', entrant: f.ids.incoming, territory: 'arrakeen',
});
for (const index of [0, 1, 2]) {
const token = views[index].moritaniTerror!.tokens.find(
(candidate) => candidate.id === f.robbery,
)!;
assert.equal('kind' in token, index === 1);
assert.equal('terrorEntry' in views[index], true);
assert.equal('kind' in (views[index].terrorEntry ?? {}), index === 1);
}
const before = rows(f);
await assert.rejects(f.restart().act(f.code, f.auth[2], g.version, ride, clock));
assert.deepEqual(rows(f), before);
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(rows(f), before);
g = await read();
await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', reveal: true }, clock);
g = await read();
assert.equal(g.pendingTerrorEntry?.stage, 'robbery');
await f.restart().act(f.code, f.auth[1], g.version, { type: 'decision', choice: 'spice' }, clock);
g = await read();
assert.equal(g.pendingTerrorEntry, null);
assert.equal(g.pendingArrivalOverlap?.stage, 'second');
assert.equal(g.decision?.kind, 'ecazAmbassador');
await f.restart().act(f.code, f.auth[0], g.version, {
type: 'decision', event: g.pendingAmbassador!.event, decline: true,
}, clock);
g = await read();
assert.equal(g.pendingArrivalOverlap, null);
assert.equal(g.pendingAmbassador, null);
assert.equal(g.pendingTerrorEntry, null);
assert.deepEqual(g.wormRides, []);
assert.deepEqual(g.decision, {
kind: 'wormRide', player: f.ids.incoming, territory: 'hagga_basin',
});
assert.equal(g.players[2].forces['arrakeen:10'], 2);
assert.equal(g.players[2].forces['imperial_basin:10'], 1);
const stolen = spiceAfterRide - g.players[2].spice;
assert.equal(stolen, 10, 'Robbery takes spice once');
assert.equal(g.players[1].spice, 30, 'Moritani keeps the stolen spice exactly once');
assert.equal(g.moritaniTerror!.tokens.find((token) => token.kind === 'robbery')!.status, 'removed');
assert.equal(g.ecazAmbassadors!.tokens.find((token) => token.effect === 'emperor')!.zone, 'placed');
});
}
@@ -0,0 +1,194 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import { createTerrorState, placeTerror, type TerrorKind } from '../game/moritani-terror';
import { botArrivalBlock } from '../game/bot-arrival';
const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const action = (g: Game, id: string, a: Action) => applyAction(reload(g), id, a);
/** Classic Ecaz/Moritani table where Moritani precedes Ecaz in storm order. */
function table(kind: TerrorKind, order: string[] = ['m', 'ec', 'in']) {
const g = createGame('OVERLAPWORM', newPlayer('ec', 'Ecaz', 'ecaz'), true, ['ecaz']);
g.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('in', 'Rider', 'fremen'));
Object.assign(g, {
status: 'playing', phase: 1, turn: 3, storm: 18, active: null, order,
deck: baseDeck(),
});
g.movementRemaining = ['in', 'm', 'ec'];
for (const player of g.players)
Object.assign(player, { hand: [], spice: 20, forces: {}, reserves: 20 });
seat(g, 'in').forces = { 'imperial_basin:10': 3, 'hagga_basin:12': 1 };
seat(g, 'in').reserves = 16;
const ambassadors = createAmbassadors(() => 0.2);
const emperor = ambassadors.tokens.find((token) => token.effect === 'emperor')!;
ambassadors.cohort = [emperor.id, ...ambassadors.tokens
.filter((token) => token.effect !== 'ecaz' && token.id !== emperor.id)
.slice(0, 4).map((token) => token.id)];
for (const token of ambassadors.tokens) {
token.zone = token.effect === 'ecaz' || ambassadors.cohort.includes(token.id)
? 'supply' : 'pool';
token.location = null;
}
g.ecazAmbassadors = placeAmbassador(ambassadors, emperor.id, {
turn: 2, availableSpice: 20,
destination: { id: 'arrakeen', stronghold: true, inStorm: false, allowed: true },
}).state;
const terror = createTerrorState(() => 0.2);
const token = terror.tokens.find((candidate) => candidate.kind === kind)!;
g.moritaniTerror = placeTerror(terror, token.id, 'arrakeen', 1);
return { g, terror: token.id };
}
const ride: Action = {
type: 'decision', accept: true, territory: 'arrakeen', sector: 10,
forces: { 'imperial_basin:10': 2 },
};
/** A Fremen ride into the stronghold, leaving a queued second ride behind it. */
function wormRide(g: Game) {
const state = reload(g);
state.decision = { kind: 'wormRide', player: 'in', territory: 'imperial_basin' };
state.wormRides = ['hagga_basin'];
return applyAction(state, 'in', ride);
}
function ready(state: Game) {
let g = state;
for (const player of state.players)
if (!g.ready.includes(player.id)) g = action(g, player.id, { type: 'ready' });
return g;
}
const queuedRide = { kind: 'wormRide', player: 'in', territory: 'hagga_basin' } as const;
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);
const offered = wormRide(g);
assert.equal(offered.pendingArrivalOverlap?.resume, 'wormRide');
assert.equal(offered.pendingArrivalOverlap?.cause, 'wormRide');
assert.equal(offered.pendingArrivalOverlap?.first, 'terror');
assert.equal(offered.pendingArrivalOverlap?.stage, 'first');
assert.equal(offered.pendingTerrorEntry?.cause, 'wormRide');
assert.equal(offered.pendingTerrorEntry?.resume, 'none');
assert.deepEqual(offered.wormRides, ['hagga_basin']);
assert.equal(seat(offered, 'in').forces['arrakeen:10'], 2);
const beforeDraw = seat(offered, 'm').hand.map((card) => card.id);
let state = action(offered, 'm', { type: 'decision', reveal: true });
assert.equal(state.pendingTerrorEntry?.stage, 'robbery');
state = action(state, 'm', { type: 'decision', choice: 'card' });
assert.equal(state.pendingTerrorEntry?.stage, 'discard');
state = action(state, 'm', { type: 'decision', card: seat(state, 'm').hand[0].id });
assert.equal(state.pendingTerrorEntry, null);
assert.equal(state.pendingArrivalOverlap?.stage, 'second');
assert.equal(state.decision?.kind, 'ecazAmbassador');
assert.equal(seat(state, 'm').hand.length, 4);
assert.equal(state.discard.filter((card) => beforeDraw.includes(card.id)).length, 1);
assert.equal(state.moritaniTerror!.tokens.find((token) => token.id === terror)!.status, 'removed');
state = action(state, 'ec', {
type: 'decision', event: state.pendingAmbassador!.event, decline: true,
});
assert.equal(state.pendingArrivalOverlap ?? null, null);
assert.deepEqual(state.decision, queuedRide);
assert.deepEqual(state.wormRides, []);
assert.equal(seat(state, 'in').forces['arrakeen:10'], 2);
assert.equal(seat(state, 'in').forces['imperial_basin:10'], 1);
});
void test('Ambassador first lets the second Terror discard, gift once and resume the ride', () => {
const { g, terror } = table('sabotage', ['ec', 'm', 'in']);
seat(g, 'in').hand = g.deck.splice(0, 1);
seat(g, 'm').hand = g.deck.splice(0, 1);
const offered = wormRide(g);
assert.equal(offered.pendingArrivalOverlap?.first, 'ambassador');
assert.equal(offered.decision?.kind, 'ecazAmbassador');
let state = action(offered, 'ec', {
type: 'decision', event: offered.pendingAmbassador!.event, decline: true,
});
assert.equal(state.pendingArrivalOverlap?.stage, 'second');
assert.equal(state.decision?.kind, 'moritaniTerror');
assert.equal(state.pendingTerrorEntry?.cause, 'wormRide');
assert.equal(state.pendingTerrorEntry?.resume, 'none');
state = action(state, 'm', { type: 'decision', reveal: true });
assert.equal(state.pendingTerrorEntry?.stage, 'gift');
state = action(state, 'm', { type: 'decision', decline: true });
assert.equal(state.pendingTerrorEntry, null);
assert.equal(state.pendingArrivalOverlap ?? null, null);
assert.equal(state.moritaniTerror!.tokens.find((token) => token.id === terror)!.status, 'removed');
assert.deepEqual(state.decision, queuedRide);
assert.equal(seat(state, 'in').forces['imperial_basin:10'], 1);
assert.equal(seat(state, 'in').forces['arrakeen:10'], 2);
const ended = action(state, 'in', { type: 'decision', accept: false });
assert.deepEqual(ended.wormRides, []);
assert.equal(ended.pendingArrivalOverlap ?? null, null);
assert.equal(ended.pendingTerrorEntry ?? null, null);
assert.equal(ended.decision ?? null, null);
assert.equal(seat(ended, 'in').forces['arrakeen:10'], 2);
});
void test('a summoned worm during the overlap opens its Nexus before the queued ride', () => {
const { g } = table('robbery');
const karama = g.deck.find((card) => card.effect === 'karama')!;
g.deck = g.deck.filter((card) => card.id !== karama.id);
seat(g, 'in').hand = [karama];
const offered = wormRide(g);
assert.equal(offered.pendingArrivalOverlap?.resume, 'wormRide');
assert.equal(offered.decision?.kind, 'moritaniTerror');
// The summoned worm has nobody to devour on the Great Flat, so it resolves at once.
let state = action(offered, 'in', {
type: 'card', mode: 'special', card: karama.id, territory: 'the_great_flat',
});
assert.equal(state.summonedWorm ?? null, null);
assert.equal(state.summonedNexusBeforeRides, true);
assert.equal(state.pendingArrivalOverlap?.resume, 'wormRide');
assert.equal(state.decision?.kind, 'moritaniTerror');
assert.equal(state.discard.filter((card) => card.id === karama.id).length, 1);
assert.equal(seat(state, 'in').specialKaramaUsed, true);
state = action(state, 'm', { type: 'decision', reveal: true });
state = action(state, 'm', { type: 'decision', choice: 'spice' });
assert.equal(state.pendingTerrorEntry, null);
assert.equal(state.decision?.kind, 'ecazAmbassador');
state = action(state, 'ec', {
type: 'decision', event: state.pendingAmbassador!.event, decline: true,
});
assert.equal(state.nexus, true);
assert.deepEqual(state.wormRides, ['hagga_basin']);
assert.equal(state.decision, null, 'the Nexus must precede the queued ride');
assert.equal(state.pendingArrivalOverlap ?? null, null);
state = ready(state);
assert.equal(state.nexus, true, 'the Nexus follows the summoned spice blow');
assert.deepEqual(state.wormRides, ['hagga_basin']);
assert.equal(state.decision ?? null, null);
assert.equal(state.pendingTerrorEntry ?? null, null);
assert.equal(seat(state, 'in').forces['imperial_basin:10'], 1);
});
void test('bots treat the supported overlapping stronghold as a legal ordinary arrival', () => {
const { g } = table('robbery');
Object.assign(g, { phase: 5, active: 'in' });
const mover = seat(g, 'in');
Object.assign(mover, { shipped: true, forces: { 'imperial_basin:10': 2 }, reserves: 18 });
const move: Action = {
type: 'move', from: 'imperial_basin:10', territory: 'arrakeen', sector: 10, amount: 2,
};
const view = viewGame(g, 'in');
assert.equal(view.arrivalOverlapMode, true);
assert.equal(botArrivalBlock(view, move), null);
assert.equal(botArrivalBlock({ ...view, arrivalOverlapMode: false }, move),
'Ambassadors combined with another arrival reaction are still being implemented. This entry has not been committed.');
const entered = applyAction(reload(g), 'in', move);
assert.equal(entered.pendingArrivalOverlap?.cause, 'movement');
assert.equal(entered.pendingArrivalOverlap?.first, 'terror');
assert.equal(entered.pendingArrivalOverlap?.ecaz, 'ec');
assert.equal(entered.pendingArrivalOverlap?.moritani, 'm');
assert.equal(entered.pendingTerrorEntry?.cause, 'movement');
assert.equal(entered.pendingTerrorEntry?.resume, 'none');
assert.equal(seat(entered, 'in').forces['arrakeen:10'], 2);
});
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action } from '../game/engine';
import { TERRITORIES, distance, location, splitLocation } from '../game/board';
import {
finishNexusSpice, nexusInventory, nexusPlayer, nexusReady, nexusTurnTwo,
orderNexusSpice,
} from './fixture-nexus-cards';
export const fremenBetrayalSource = 'red_chasm:7';
const destinations = TERRITORIES.filter(t => t.type === 'sand').flatMap(t =>
t.sectors.filter(sector => sector !== 18).map(sector => location(t.id, sector)));
export function fremenBetrayalMovement(range: number): Action {
const destination = destinations.find(to =>
distance(fremenBetrayalSource, to, key => splitLocation(key).sector === 18) === range);
assert.ok(destination, `range ${range} from ${fremenBetrayalSource}`);
const to = splitLocation(destination);
return {
type: 'move', from: fremenBetrayalSource, amount: 4,
territory: to.territory, sector: to.sector,
};
}
/** Actual setup, second-turn Nexus draw, Bidding and Revival; only final board
* force positions are chosen for a deterministic native movement boundary. */
export function fremenBetrayalFixture(
advanced = false,
ids: [string, string, string] = ['f', 'a', 'h'],
) {
const [riderId, allyId, holderId] = ids;
let g = nexusTurnTwo({ seatIds: ids, hostFaction: 'fremen', secondFaction: 'atreides', advanced });
orderNexusSpice(g, ['worm', 'land', 'land']);
g = nexusReady(nexusReady(g));
assert.equal(g.nexus, true);
g = applyAction(g, riderId, { type: 'alliance', target: allyId });
g = applyAction(g, allyId, { type: 'alliance', target: riderId });
g = finishNexusSpice(g);
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
const cards = g.nexusCards!.cards!;
cards.deck = ['fremen', ...cards.deck.filter(card => card !== 'fremen')];
g = applyAction(g, holderId, {
type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0,
});
assert.equal(g.nexusCards!.cards!.hands[holderId], 'fremen');
g = nexusReady(g);
for (let step = 0; g.phase === 3 && step < 30; step++) {
if (g.phaseOpening) {
const id = g.players.find(p => !g.phaseOpening!.passed.includes(p.id))!.id;
g = applyAction(g, id, { type: 'ready' });
} else if (g.auction) g = applyAction(g, g.auction.active, { type: 'passBid' });
else g = nexusReady(g);
}
assert.equal(g.phase, 4);
for (let step = 0; g.phase === 4 && step < 20; step++) {
if (g.response) {
const id = g.players.find(p => !g.response!.passed.includes(p.id))!.id;
g = applyAction(g, id, { type: 'passResponse' });
} else g = nexusReady(g);
}
assert.equal(g.phase, 5);
const rider = nexusPlayer(g, riderId);
const total = rider.reserves + Object.values(rider.forces).reduce((sum, count) => sum + count, 0);
rider.forces = { [fremenBetrayalSource]: 4 };
rider.reserves = total - 4;
if (rider.elites) {
const eliteTotal = rider.elites.reserves +
Object.values(rider.elites.forces).reduce((sum, count) => sum + count, 0);
rider.elites.forces = {};
rider.elites.reserves = eliteTotal;
}
rider.shipped = true;
rider.moved = 0;
g.active = riderId;
nexusInventory(g);
const offer = viewGame(g, holderId).nexusFremenBetrayal!;
assert.equal(offer.blocked, null);
return { g, play: { type: 'nexusFremenBetrayal', event: offer.event } as Action };
}
+109 -4
View File
@@ -2,7 +2,6 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
quoteCompletedMovementArrival,
MovementArrivalError,
type CompletedMovementArrivalInput,
} from '../game/karama-movement-preflight';
import {
@@ -69,14 +68,120 @@ void test('a plain movement has no arrival reaction, and an eligible token opens
quoteCompletedMovementArrival({ ...input, terror: [terror] }).reaction,
'terror',
);
});
void test('supported simultaneous Ambassador and Terror arrival quotes one bounded overlap without changing the original entry', () => {
const input = {
...fixture(),
overlapSupported: true,
ambassadors: [ambassador],
terror: [terror],
};
const before = structuredClone(input);
assert.deepEqual(quoteCompletedMovementArrival(input), {
intrusion: false,
reaction: 'overlap',
retiresOrnithopter: false,
});
assert.deepEqual(quoteCompletedMovementArrival(input), {
intrusion: false,
reaction: 'overlap',
retiresOrnithopter: false,
});
assert.deepEqual(input, before);
assert.throws(
() => quoteCompletedMovementArrival({ ...input, overlapSupported: false }),
/Ambassadors combined with another arrival reaction/,
);
assert.throws(
() => quoteCompletedMovementArrival({ ...input, overlapSupported: undefined }),
/Ambassadors combined with another arrival reaction/,
);
assert.equal(
quoteCompletedMovementArrival({ ...input, terror: [] }).reaction,
'ambassador',
);
assert.equal(
quoteCompletedMovementArrival({ ...input, ambassadors: [] }).reaction,
'terror',
);
});
void test('overlap support does not bypass competing controls, fighter response, or BG intrusion', () => {
const input = {
...fixture(),
overlapSupported: true,
ambassadors: [ambassador],
terror: [terror],
};
for (const flag of [
'response',
'decision',
'pendingTerror',
'pendingAmbassador',
] as const)
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
controls: { ...input.controls, [flag]: true },
}),
/Ambassadors combined with another arrival reaction/,
flag,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
ambassadors: [ambassador],
terror: [terror],
order: { ...input.order, wantsFighters: true },
}),
MovementArrivalError,
/Ambassadors combined with another arrival reaction/,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
players: input.players.map((p) =>
p.id === 'b' ? { ...p, forces: { 'broken_land:9': 1 } } : p,
),
}),
/Ambassadors combined with another arrival reaction/,
);
});
void test('overlap support never turns same-territory movement or shared ownership into an entry', () => {
const input = {
...fixture(),
overlapSupported: true,
ambassadors: [ambassador],
terror: [terror],
};
assert.equal(
quoteCompletedMovementArrival({
...input,
order: { ...input.order, origin: 'broken_land' },
}).reaction,
null,
);
assert.equal(
quoteCompletedMovementArrival({
...input,
order: {
...input.order,
origins: ['broken_land', 'broken_land'],
},
}).reaction,
null,
);
assert.throws(
() =>
quoteCompletedMovementArrival({
...input,
players: input.players.map((p) =>
p.id === 'm' ? { ...p, id: 'e' } : p,
),
}),
/Ambassadors combined with another arrival reaction/,
);
});
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { RoomsClock } from '../db/rooms';
import { nexusPlayer } from './fixture-nexus-cards';
import { fremenBetrayalFixture, fremenBetrayalMovement } from './fixture-nexus-fremen-betrayal';
import { unitStore } from './fixture-nexus-room-store';
const clock: RoomsClock = { now: () => 45000, sleep: async () => {} };
void test('concurrent authenticated Fremen Betrayal commits one physical card and restores the turn-long range limit', async t => {
const store = unitStore();
t.after(() => store.sqlite.close());
const created = await store.rooms.createRoom('Fremen SQL', 'fremen', false, []);
const code = created.view.code;
const atreides = await store.rooms.joinRoom(code, 'Atreides', 'atreides');
const harkonnen = await store.rooms.joinRoom(code, 'Harkonnen', 'harkonnen');
const tokens = [created.token, atreides.token!, harkonnen.token!];
const auth = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const [fremen, ally, holder] = auth.map(seat => seat.playerId) as [string, string, string];
const { g, play } = fremenBetrayalFixture(false, [fremen, ally, holder]);
g.code = code;
g.version = (await store.rooms.readRoom(code)).version;
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
store.writes.length = 0;
const before = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code);
const rivalView = await store.rooms.readSeatView(code, auth[1]);
assert.equal(rivalView.nexusFremenBetrayal, null);
assert.equal(rivalView.nexusCards?.card, null);
const results = await Promise.allSettled([
store.restart().act(code, auth[2], g.version, play, clock),
store.restart().act(code, auth[2], g.version, play, clock),
]);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
assert.notDeepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), before);
const saved = await store.restart().readRoom(code);
assert.equal(saved.nexusCards!.cards!.hands[holder], null);
assert.equal(saved.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
assert.equal(saved.nexusFremenBetrayalHistory?.length, 1);
assert.equal(nexusPlayer(saved, fremen).fremenNexusMovementBlockedTurn, saved.turn);
const freshAuth = await store.restart().authenticate(code, tokens[0]);
const view = await store.restart().readSeatView(code, freshAuth);
assert.equal(view.players.find(player => player.id === fremen)!.fremenMovementBlocked, true);
assert.equal(view.nexusFremenBetrayal, null);
const stable = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code);
await assert.rejects(store.restart().act(code, freshAuth, saved.version, fremenBetrayalMovement(2), clock));
await assert.rejects(store.restart().act(code, auth[2], saved.version, play, clock));
assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), stable);
});
+107
View File
@@ -0,0 +1,107 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction, normalizeAutomaticGame, viewGame, type Action, type Game,
} from '../game/engine';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { nexusInventory, nexusPlayer, nexusReload } from './fixture-nexus-cards';
import {
fremenBetrayalFixture as setup,
fremenBetrayalMovement as movement,
} from './fixture-nexus-fremen-betrayal';
const fremen = 'f';
const ally = 'a';
const holder = 'h';
function rejected(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action));
assert.deepEqual(g, before);
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} spent Betrayal suppresses only the native range through its turn and reload`, () => {
const { g, play } = setup(advanced);
assert.equal(viewGame(g, fremen).nexusFremenBetrayal, null);
assert.equal(viewGame(g, ally).nexusFremenBetrayal, null);
assert.equal(viewGame(g, holder).nexusCards?.card, 'fremen');
rejected(g, fremen, play);
rejected(g, holder, { ...play, event: 'stale' });
rejected(g, holder, { ...play, target: fremen });
assert.deepEqual(botActions(viewGame(g, holder))[0], play);
let state = applyAction(nexusReload(g), holder, play);
nexusInventory(state);
assert.equal(state.nexusCards!.cards!.hands[holder], null);
assert.equal(state.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
assert.equal(nexusPlayer(state, fremen).fremenNexusMovementBlockedTurn, state.turn);
assert.equal(viewGame(state, fremen).players.find(p => p.id === fremen)!.fremenMovementBlocked, true);
assert.equal(viewGame(state, fremen).players.find(p => p.id === fremen)!.fremenNexusMovementBlocked, true);
rejected(state, holder, play);
rejected(state, fremen, movement(2));
state = applyAction(nexusReload(state), fremen, movement(1));
assert.equal(state.pendingFremenMove ?? null, null);
assert.equal(state.response?.kind, undefined);
assert.equal(nexusPlayer(state, fremen).moved, 1);
assert.equal(nexusPlayer(state, fremen).fremenNexusMovementBlockedTurn, state.turn);
const restored = normalizeAutomaticGame(nexusReload(state));
assert.equal(nexusPlayer(restored, fremen).fremenNexusMovementBlockedTurn, restored.turn);
assert.deepEqual(restored.nexusFremenBetrayalHistory, state.nexusFremenBetrayalHistory);
});
}
void test('all four bot holders spend the card before an active Fremen bot moves', () => {
for (const difficulty of DIFFICULTIES) {
const { g, play } = setup();
nexusPlayer(g, holder).bot = difficulty;
nexusPlayer(g, fremen).bot = difficulty;
assert.deepEqual(botActions(viewGame(g, holder))[0], play);
const first = runBots(g, 1);
assert.equal(first.nexusCards!.cards!.hands[holder], null);
assert.equal(nexusPlayer(first, fremen).moved, 0);
assert.equal(nexusPlayer(first, fremen).fremenNexusMovementBlockedTurn, first.turn);
}
});
void test('independent city ornithopters retain their range despite spent Betrayal', () => {
const { g, play } = setup();
const state = applyAction(g, holder, play);
const f = nexusPlayer(state, fremen);
f.reserves--;
f.forces['arrakeen:10'] = 1;
const moved = applyAction(state, fremen, movement(2));
assert.equal(moved.pendingFremenMove ?? null, null);
assert.equal(nexusPlayer(moved, fremen).moved, 1);
assert.equal(nexusPlayer(moved, fremen).fremenNexusMovementBlockedTurn, moved.turn);
});
void test('turn-scoped suppression expires without returning the physical card', () => {
const { g, play } = setup();
const next = nexusReload(applyAction(g, holder, play));
next.turn++;
next.active = fremen;
next.movementRemaining = [...next.order];
nexusPlayer(next, fremen).moved = 0;
const view = viewGame(next, fremen);
assert.equal(view.players.find(p => p.id === fremen)!.fremenNexusMovementBlocked, false);
assert.equal(view.players.find(p => p.id === fremen)!.fremenMovementBlocked, false);
const moved = applyAction(next, fremen, movement(2));
assert.equal(nexusPlayer(moved, fremen).moved, 1);
assert.equal(moved.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
});
void test('changed saved turn, target or suppression rejects before advancing movement', () => {
const { g, play } = setup();
const played = applyAction(g, holder, play);
for (const change of [
(state: Game) => { state.nexusFremenBetrayalHistory![0].target = ally; },
(state: Game) => { state.nexusFremenBetrayalHistory![0].turn--; },
(state: Game) => { delete nexusPlayer(state, fremen).fremenNexusMovementBlockedTurn; },
(state: Game) => { state.nexusCards!.cards!.discard = []; },
]) {
const damaged = nexusReload(played);
change(damaged);
rejected(damaged, fremen, movement(1));
}
});
-37
View File
@@ -329,43 +329,6 @@ void test('no-choice decision checklist preserves genuine alternatives and persi
);
});
void test('Ecaz entry checklist separates eight supported effects and private historical snapshots from unopened interactions', () => {
const topic = RULE_TOPICS.find(
(candidate) => candidate.id === 'ecaz-ambassadors',
)!;
const guide = topic.steps.join(' ');
assert.equal(topic.coverage, 'Partial');
assert.match(
guide,
/Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated/,
);
assert.match(guide, /Bene Gesserit copies of those eight effects/);
assert.match(guide, /recipient’s private inspection history/);
assert.match(guide, /do not become a live view of later changes/);
assert.match(guide, /closing them needs no game acknowledgement/);
assert.match(guide, /including none, for three spice each/);
assert.match(guide, /Ixians require one available card/);
assert.match(
guide,
/Entries with competing arrival reactions remain blocked/,
);
assert.match(guide, /Full Ecaz starts remain disabled/);
const evidence = topic.checklist!.find(
(item) => item.area === 'Verification',
)!.evidence!;
assert.ok(evidence.includes('tests/ecaz-entry.test.ts'));
assert.ok(evidence.includes('tests/ecaz-entry-bots.test.ts'));
assert.ok(evidence.includes('tests/guild-ambassador-bots.test.ts'));
assert.ok(evidence.includes('tests/guild-ambassador-engine.test.ts'));
assert.match(
guide,
/No-Field substitution.*awaits the user’s interpretation/,
);
assert.match(
guide,
/Simultaneous Intrusion\/Terror and Ambassador\/Terror combinations remain blocked/,
);
});
void test('Nullentropy checklist preserves paid-only privacy, real choices, recovery and explicit interpretation guards', () => {
const topic = RULE_TOPICS.find(