Connect bounded arrival overlap and Fremen Nexus movement Betrayal
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user