Connect Ecaz Betrayal and Fremen worm Cunning with build revision
This commit is contained in:
1 parent
1e690b7062
commit
07c739fcf7
30 files changed
+2878
-25
No files matched your search
@@ -24,6 +24,7 @@ export function nexusFixture(
|
||||
seatIds?: [string, string, string];
|
||||
advanced?: boolean;
|
||||
hostFaction?: 'fremen' | 'moritani' | 'ecaz' | 'guild' | 'emperor';
|
||||
secondFaction?: 'atreides' | 'emperor' | 'beneGesserit';
|
||||
} = {},
|
||||
): Game {
|
||||
const [f, a, h] = options.seatIds ?? ['f', 'a', 'h'];
|
||||
@@ -37,7 +38,9 @@ export function nexusFixture(
|
||||
options.advanced ?? false,
|
||||
[],
|
||||
);
|
||||
joinGame(g, newPlayer(a, 'Atreides', 'atreides'));
|
||||
joinGame(g, newPlayer(a, options.secondFaction === 'emperor' ? 'Emperor' :
|
||||
options.secondFaction === 'beneGesserit' ? 'Bene Gesserit' : 'Atreides',
|
||||
options.secondFaction ?? 'atreides'));
|
||||
joinGame(g, newPlayer(h, 'Harkonnen', 'harkonnen'));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
g.nexusCards = { cards: null, phase: null };
|
||||
@@ -128,6 +131,11 @@ export function finishNexusSpice(state: Game): Game {
|
||||
type: 'decision',
|
||||
accept: false,
|
||||
});
|
||||
else if (g.decision?.kind === 'nexusFremenCunningOffer' ||
|
||||
g.decision?.kind === 'nexusFremenCunningRide')
|
||||
g = applyAction(g, g.decision.player, {
|
||||
type: 'decision', event: g.decision.event, accept: false,
|
||||
});
|
||||
else {
|
||||
assert.equal(g.decision, null);
|
||||
g = nexusReady(g);
|
||||
@@ -141,6 +149,7 @@ export function nexusTurnTwo(
|
||||
seatIds?: [string, string, string];
|
||||
advanced?: boolean;
|
||||
hostFaction?: 'fremen' | 'moritani' | 'ecaz' | 'guild' | 'emperor';
|
||||
secondFaction?: 'atreides' | 'emperor' | 'beneGesserit';
|
||||
} = {},
|
||||
): Game {
|
||||
const first = enterNexusSpice(nexusFixture(options));
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Game } from '../game/engine';
|
||||
import {
|
||||
ecazBetrayalOffer, quoteEcazBetrayal, validateEcazBetrayalSnapshot,
|
||||
type EcazBetrayalSnapshot,
|
||||
} from '../game/nexus-ecaz-betrayal';
|
||||
import { nexusFixture, nexusReload } from './fixture-nexus-cards';
|
||||
|
||||
/** Ordinary printed-board occupation; no other expansion or optional modules. */
|
||||
function position() {
|
||||
const g = nexusFixture({ hostFaction: 'ecaz', advanced: true });
|
||||
const ecaz = g.players.find(p => p.faction === 'ecaz')!;
|
||||
const ally = g.players.find(p => p.faction === 'atreides')!;
|
||||
const holder = g.players.find(p => p.faction === 'harkonnen')!;
|
||||
// The fixture seats only three factions. Model the supported Emperor's
|
||||
// advanced Sardaukar as a genuinely typed physical ally group.
|
||||
ally.faction = 'emperor';
|
||||
ally.forces = { 'wind_pass:14': 3, 'wind_pass:15': 2, 'carthag:11': 1 };
|
||||
ally.reserves = 14;
|
||||
ally.elites = { reserves: 2, tanks: 0, forces: {
|
||||
'wind_pass:14': 1, 'wind_pass:15': 1, 'carthag:11': 1,
|
||||
}, revived: 0 };
|
||||
ecaz.forces = { 'wind_pass:14': 2, 'carthag:11': 1 };
|
||||
ecaz.reserves = 17;
|
||||
ecaz.ally = ally.id;
|
||||
ally.ally = ecaz.id;
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const cardIndex = cards.deck.indexOf('ecaz');
|
||||
assert.ok(cardIndex >= 0);
|
||||
cards.hands[holder.id] = cards.deck.splice(cardIndex, 1)[0];
|
||||
g.phase = 5;
|
||||
g.order = [ecaz.id, holder.id, ally.id];
|
||||
g.active = g.order[0];
|
||||
g.movementRemaining = [...g.order];
|
||||
g.response = null;
|
||||
g.decision = null;
|
||||
g.phaseOpening = null;
|
||||
g.nexusCards!.phase = null;
|
||||
for (const player of g.players) {
|
||||
player.shipped = false;
|
||||
player.moved = 0;
|
||||
}
|
||||
return { g, ecaz: ecaz.id, ally: ally.id, holder: holder.id };
|
||||
}
|
||||
function editGame(g: Game, change: (copy: Game) => void) {
|
||||
const copy = nexusReload(g);
|
||||
change(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
void test('Ecaz Betrayal quotes one selected public territory with every typed sector and no opponent data', () => {
|
||||
const { g, ecaz, ally, holder } = position();
|
||||
const offer = ecazBetrayalOffer(g, holder)!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.ally, ally);
|
||||
assert.deepEqual(offer.territories.map(row => row.territory), ['wind_pass', 'carthag']);
|
||||
assert.deepEqual(offer.territories[0], { territory: 'wind_pass', sectors: {
|
||||
'wind_pass:14': { normal: 2, elite: 1 },
|
||||
'wind_pass:15': { normal: 1, elite: 1 },
|
||||
}, total: { normal: 3, elite: 2 } });
|
||||
assert.deepEqual(offer.territories[1], { territory: 'carthag', sectors: {
|
||||
'carthag:11': { normal: 0, elite: 1 },
|
||||
}, total: { normal: 0, elite: 1 } });
|
||||
const quote = quoteEcazBetrayal(g, holder, offer.event, 'wind_pass');
|
||||
assert.deepEqual(quote, { event: offer.event, owner: holder, ally, turn: g.turn,
|
||||
discardIndex: g.nexusCards!.cards!.discard.length, ...offer.territories[0] });
|
||||
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(g, quote));
|
||||
assert.equal(ecazBetrayalOffer(g, ecaz), null);
|
||||
assert.equal(ecazBetrayalOffer(g, ally), null);
|
||||
assert.equal(ecazBetrayalOffer(g, 'not-seated'), null);
|
||||
const alteredPrivateAndOpponent = editGame(g, copy => {
|
||||
copy.players.find(p => p.id === ecaz)!.traitors = ['private Ecaz traitor'];
|
||||
copy.players.find(p => p.id === holder)!.traitors = ['private holder traitor'];
|
||||
copy.players.find(p => p.id === holder)!.forces['carthag:11'] += 2;
|
||||
});
|
||||
assert.deepEqual(ecazBetrayalOffer(alteredPrivateAndOpponent, holder), offer);
|
||||
});
|
||||
|
||||
void test('Ecaz Betrayal needs reciprocal native alliance, cooccupation, proper card custody and simple roster', () => {
|
||||
const { g, ecaz, ally, holder } = position();
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.ally = null; }), holder)!.blocked!, /reciprocal/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ecaz)!.ally = null; }), holder)!.blocked!, /reciprocal/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === holder)!.ally = ally; }), holder)!.blocked!, /allied player/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ecaz)!.forces = {}; }), holder)!.blocked!, /share no territory/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.forces = {}; copy.players.find(p => p.id === ally)!.elites!.forces = {}; }), holder)!.blocked!, /share no territory/);
|
||||
assert.equal(ecazBetrayalOffer(editGame(g, copy => { copy.nexusCards!.cards!.hands[holder] = null; }), holder), null);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === holder)!.faction = 'richese'; }), holder)!.blocked!, /Combined modules/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.homeworlds = { custody: null }; }), holder)!.blocked!, /Combined modules/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.players.find(p => p.id === ally)!.advisors = { wind_pass: {} }; }), holder)!.blocked!, /advisor/);
|
||||
assert.match(ecazBetrayalOffer(editGame(g, copy => { copy.expansions.push('ix'); }), holder)!.blocked!, /Combined modules/);
|
||||
});
|
||||
|
||||
void test('opening ends with any shipment, movement, first-seat pass, pending decision or automatic continuation', () => {
|
||||
const { g, holder } = position();
|
||||
const blocked = (change: (copy: Game) => void) => {
|
||||
const reason = ecazBetrayalOffer(editGame(g, change), holder)!.blocked;
|
||||
assert.ok(reason, 'The opening action must be unavailable.');
|
||||
return reason;
|
||||
};
|
||||
assert.match(blocked(copy => { copy.players[0].shipped = true; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.players[1].moved = 1; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.movementRemaining!.shift(); copy.active = copy.order[1]; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.movementRemaining!.shift(); copy.active = copy.order[0]; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.active = null; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.phase = 4; }), /before the first/);
|
||||
assert.match(blocked(copy => { copy.phaseOpening = { passed: [], initialize: true }; }), /current interaction/);
|
||||
assert.match(blocked(copy => { copy.decision = { kind: 'guildTiming', player: holder, next: g.order[0], following: g.order[1] }; }), /current interaction/);
|
||||
assert.match(ecazBetrayalOffer(g, holder, true)!.blocked!, /current interaction/);
|
||||
});
|
||||
|
||||
void test('accepted quote rejects foreign territory, stale event and changing typed board custody', () => {
|
||||
const { g, ecaz, ally, holder } = position();
|
||||
const event = ecazBetrayalOffer(g, holder)!.event;
|
||||
assert.throws(() => quoteEcazBetrayal(g, ecaz, event, 'wind_pass'));
|
||||
assert.throws(() => quoteEcazBetrayal(g, holder, 'stale', 'wind_pass'), /stale/);
|
||||
assert.throws(() => quoteEcazBetrayal(g, holder, event, 'polar_sink'), /shared territory/);
|
||||
const quote = quoteEcazBetrayal(g, holder, event, 'wind_pass');
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(editGame(g, copy => { copy.players.find(p => p.id === ally)!.forces['wind_pass:15']--; }), quote), /original physical ally group/);
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(editGame(g, copy => { copy.turn++; }), quote));
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(editGame(g, copy => { copy.nexusCards!.cards!.discard.push('guild'); }), quote));
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(g, { ...quote, ally: holder }), /original physical ally group/);
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(g, { ...quote, territory: 'carthag' }), /invalid saved sector group/);
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(g, { ...quote, total: { normal: 4, elite: 2 } }), /original physical ally group/);
|
||||
});
|
||||
|
||||
void test('malformed sector groups, inherited keys and invalid physical counts cannot become historical snapshots', () => {
|
||||
const { g, holder, ally } = position();
|
||||
const event = ecazBetrayalOffer(g, holder)!.event;
|
||||
const quote = quoteEcazBetrayal(g, holder, event, 'wind_pass');
|
||||
const bad = (patch: Partial<EcazBetrayalSnapshot>) =>
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(g, { ...quote, ...patch }));
|
||||
bad({ sectors: Object.assign(Object.create({ 'wind_pass:15': { normal: 1, elite: 1 } }), {
|
||||
'wind_pass:14': { normal: 2, elite: 1 },
|
||||
}) });
|
||||
bad({ sectors: { 'wind_pass:14': { normal: 2, elite: 1 },
|
||||
'carthag:11': { normal: 1, elite: 1 } } });
|
||||
bad({ sectors: { 'wind_pass:14': { normal: -1, elite: 4 },
|
||||
'wind_pass:15': { normal: 1, elite: 1 } } });
|
||||
bad({ sectors: { 'wind_pass:14': { normal: 2.5, elite: 1 },
|
||||
'wind_pass:15': { normal: 1, elite: 1 } } });
|
||||
bad({ sectors: { 'wind_pass:14': { normal: 2, elite: 1, secret: 7 } as never,
|
||||
'wind_pass:15': { normal: 1, elite: 1 } } });
|
||||
const inherited = nexusReload(g);
|
||||
const inheritedAlly = inherited.players.find(p => p.id === ally)!;
|
||||
inheritedAlly.forces = Object.assign(Object.create({ 'wind_pass:13': 1 }), inheritedAlly.forces);
|
||||
assert.throws(() => ecazBetrayalOffer(inherited, holder), /plain physical/);
|
||||
assert.throws(() => ecazBetrayalOffer(editGame(g, copy => {
|
||||
copy.players.find(p => p.id === ally)!.elites!.forces['wind_pass:14'] = 9;
|
||||
}), holder), /typed force/);
|
||||
assert.throws(() => ecazBetrayalOffer(editGame(g, copy => {
|
||||
copy.players.find(p => p.id === ally)!.forces['wind_pass:15'] = NaN;
|
||||
}), holder), /board custody/);
|
||||
});
|
||||
|
||||
void test('pending response validates the exact captured group after physical card discard without rereading a now-empty hand', () => {
|
||||
const { g, holder, ally } = position();
|
||||
const event = ecazBetrayalOffer(g, holder)!.event;
|
||||
const snapshot = quoteEcazBetrayal(g, holder, event, 'wind_pass');
|
||||
const pending = editGame(g, copy => {
|
||||
copy.nexusCards!.cards!.hands[holder] = null;
|
||||
copy.nexusCards!.cards!.discard.push('ecaz');
|
||||
});
|
||||
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(pending, snapshot, true));
|
||||
const anotherSpentCard = editGame(pending, copy => {
|
||||
const cards = copy.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('guild');
|
||||
assert.ok(index >= 0);
|
||||
cards.discard.push(cards.deck.splice(index, 1)[0]);
|
||||
});
|
||||
assert.doesNotThrow(() => validateEcazBetrayalSnapshot(anotherSpentCard, snapshot, true),
|
||||
'A separately spent Nexus card cannot invalidate this pending Ecaz return.');
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(pending, snapshot));
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(editGame(pending, copy => {
|
||||
copy.players.find(p => p.id === ally)!.elites!.forces['wind_pass:14'] = 0;
|
||||
}), snapshot, true), /original physical ally group/);
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(editGame(pending, copy => {
|
||||
copy.nexusCards!.cards!.discard.push('guild');
|
||||
}), snapshot, true), /Nexus/);
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(pending,
|
||||
{ ...snapshot, discardIndex: snapshot.discardIndex + 1 }, true), /pending card/);
|
||||
assert.throws(() => validateEcazBetrayalSnapshot(pending, { ...snapshot, event: 'forged' }, true), /pending card/);
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import type { RoomsClock, SeatAuth } from '../db/rooms';
|
||||
import {
|
||||
finishNexusSpice, nexusAllow, nexusInventory, nexusPlayer, nexusReady,
|
||||
nexusTurnTwo, orderNexusSpice,
|
||||
} from './fixture-nexus-cards';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
async function fixture(t: test.TestContext): Promise<Fixture> {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const made = await store.rooms.createRoom('Ecaz Betrayal SQL', 'ecaz', true, ['ecaz']);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of ['emperor', 'harkonnen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
const ids = auths.map(auth => auth.playerId) as [string, string, string];
|
||||
const [ecaz, ally, owner] = ids;
|
||||
|
||||
let g = nexusTurnTwo({ seatIds: ids, hostFaction: 'ecaz', secondFaction: 'emperor', advanced: true });
|
||||
orderNexusSpice(g, ['worm', 'land', 'land']);
|
||||
g = nexusReady(nexusReady(g));
|
||||
g = applyAction(g, ecaz, { type: 'alliance', target: ally });
|
||||
g = applyAction(g, ally, { type: 'alliance', target: ecaz });
|
||||
assert.equal(nexusPlayer(g, ecaz).ally, ally);
|
||||
assert.equal(nexusPlayer(g, ally).ally, ecaz);
|
||||
g = finishNexusSpice(g);
|
||||
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('ecaz');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck.splice(index, 1);
|
||||
cards.deck.unshift('ecaz');
|
||||
g = applyAction(g, owner, {
|
||||
type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0,
|
||||
});
|
||||
assert.equal(g.nexusCards!.cards!.hands[owner], 'ecaz');
|
||||
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 < 10; step++) {
|
||||
if (g.decision?.kind === 'ecazPlacement')
|
||||
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
|
||||
else if (g.response) g = nexusAllow(g);
|
||||
else g = nexusReady(g);
|
||||
}
|
||||
assert.equal(g.phase, 5);
|
||||
assert.equal(g.active, g.order[0]);
|
||||
assert.deepEqual(g.movementRemaining, g.order);
|
||||
|
||||
// Move existing physical Emperor counters, including Sardaukar, not minted ones.
|
||||
const target = nexusPlayer(g, ally);
|
||||
target.reserves += Object.values(target.forces).reduce((sum, n) => sum + n, 0);
|
||||
target.elites!.reserves += Object.values(target.elites!.forces).reduce((sum, n) => sum + n, 0);
|
||||
target.forces = { 'wind_pass:14': 3, 'wind_pass:15': 2, 'carthag:11': 1 };
|
||||
target.elites!.forces = { 'wind_pass:14': 1, 'wind_pass:15': 1 };
|
||||
target.reserves -= 6;
|
||||
target.elites!.reserves -= 2;
|
||||
const native = nexusPlayer(g, ecaz);
|
||||
native.reserves -= 2;
|
||||
native.forces['wind_pass:14'] = (native.forces['wind_pass:14'] ?? 0) + 2;
|
||||
const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(karamaIndex >= 0);
|
||||
const karama = g.deck.splice(karamaIndex, 1)[0];
|
||||
native.hand.push(karama);
|
||||
nexusInventory(g);
|
||||
const offer = viewGame(g, owner).nexusEcazBetrayal!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.ally, ally);
|
||||
assert.deepEqual(offer.territories.find(row => row.territory === 'wind_pass'), {
|
||||
territory: 'wind_pass',
|
||||
sectors: {
|
||||
'wind_pass:14': { normal: 2, elite: 1 },
|
||||
'wind_pass:15': { normal: 1, elite: 1 },
|
||||
},
|
||||
total: { normal: 3, elite: 2 },
|
||||
});
|
||||
const action: Action = { type: 'nexusEcazBetrayal', event: offer.event, territory: 'wind_pass' };
|
||||
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;
|
||||
return { ...store, code, tokens, auths, ids, ecaz, ally, owner, karama: karama.id,
|
||||
action, initial: g };
|
||||
}
|
||||
interface Fixture {
|
||||
code: string;
|
||||
tokens: string[];
|
||||
auths: SeatAuth[];
|
||||
ids: [string, string, string];
|
||||
ecaz: string;
|
||||
ally: string;
|
||||
owner: string;
|
||||
karama: string;
|
||||
action: Action;
|
||||
initial: Game;
|
||||
sqlite: DatabaseSync;
|
||||
restart(): typeof Rooms;
|
||||
hooks: { beforeWrite?: () => Promise<void> };
|
||||
writes: { expected: number; changes: number }[];
|
||||
}
|
||||
|
||||
const row = (f: Fixture) => f.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?')
|
||||
.get(f.code);
|
||||
|
||||
async function restored(f: Fixture, g: Game) {
|
||||
const saved = row(f);
|
||||
for (const [i, token] of f.tokens.entries()) {
|
||||
const rooms = f.restart();
|
||||
const auth = await rooms.authenticate(f.code, token);
|
||||
const view = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(g, f.ids[i]));
|
||||
if (i === 2 && g.nexusCards!.cards!.hands[f.owner] === 'ecaz') {
|
||||
assert.equal(view.nexusEcazBetrayal?.blocked, null);
|
||||
assert.equal(view.nexusEcazBetrayal?.ally, f.ally);
|
||||
} else assert.equal(view.nexusEcazBetrayal, null);
|
||||
assert.equal(view.nexusCards?.card, i === 2 ? g.nexusCards!.cards!.hands[f.owner] : null);
|
||||
assert.equal(Object.hasOwn(view, 'nexusEcazBetrayalHistory'), false);
|
||||
for (const rival of view.players.filter(player => player.id !== auth.playerId)) {
|
||||
assert.equal('hand' in rival, false);
|
||||
assert.equal('traitors' in rival, false);
|
||||
}
|
||||
}
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), saved);
|
||||
nexusInventory(g);
|
||||
for (const p of g.players)
|
||||
if (p.elites) {
|
||||
const initial = nexusPlayer(f.initial, p.id).elites!;
|
||||
assert.equal(
|
||||
p.elites.reserves + p.elites.tanks +
|
||||
Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
||||
initial.reserves + initial.tanks +
|
||||
Object.values(initial.forces).reduce((sum, n) => sum + n, 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function act(f: Fixture, index: number, action: Action) {
|
||||
const current = await f.restart().readRoom(f.code);
|
||||
await f.restart().act(f.code, f.auths[index], current.version, action, clock);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
|
||||
async function passKaramaHolder(f: Fixture) {
|
||||
const current = await f.restart().readRoom(f.code);
|
||||
assert.equal(current.response?.kind, 'nexusEcazBetrayal');
|
||||
assert.ok(viewGame(current, f.ecaz).responseControls?.cancelCards.length);
|
||||
const done = await act(f, 0, { type: 'passResponse' });
|
||||
assert.equal(done.response, null);
|
||||
return { done, count: 1 };
|
||||
}
|
||||
|
||||
void test('SQLite concurrent Ecaz proposals spend one card; restart and allowance return both typed sectors once', async t => {
|
||||
const f = await fixture(t);
|
||||
await restored(f, f.initial);
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
// Both independent room-module instances yield on the actual SQL write hook.
|
||||
let results: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
results = await Promise.allSettled([0, 1].map(() =>
|
||||
f.restart().act(f.code, f.auths[2], f.initial.version, f.action, clock)));
|
||||
} finally {
|
||||
delete f.hooks.beforeWrite;
|
||||
}
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.version, f.initial.version + 1);
|
||||
assert.equal(pending.response?.kind, 'nexusEcazBetrayal');
|
||||
assert.equal(pending.nexusEcazBetrayalHistory?.length, 1);
|
||||
assert.equal(pending.nexusEcazBetrayalHistory?.[0].stage, 'pending');
|
||||
assert.equal(pending.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(pending.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
|
||||
assert.deepEqual(nexusPlayer(pending, f.ally).forces, nexusPlayer(f.initial, f.ally).forces);
|
||||
assert.deepEqual(nexusPlayer(pending, f.ally).elites, nexusPlayer(f.initial, f.ally).elites);
|
||||
await restored(f, pending);
|
||||
const saved = row(f);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[2], f.initial.version,
|
||||
f.action, clock), /table changed/i);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[2], pending.version,
|
||||
f.action, clock), /Ecaz|Nexus/);
|
||||
assert.deepEqual(row(f), saved);
|
||||
|
||||
const { done, count } = await passKaramaHolder(f);
|
||||
assert.equal(done.version, pending.version + count);
|
||||
const initialAlly = nexusPlayer(f.initial, f.ally);
|
||||
const returned = nexusPlayer(done, f.ally);
|
||||
for (const key of ['wind_pass:14', 'wind_pass:15']) {
|
||||
assert.equal(returned.forces[key], undefined);
|
||||
assert.equal(returned.elites!.forces[key], undefined);
|
||||
}
|
||||
assert.equal(returned.forces['carthag:11'], 1);
|
||||
assert.equal(returned.reserves, initialAlly.reserves + 5);
|
||||
assert.equal(returned.elites!.reserves, initialAlly.elites!.reserves + 2);
|
||||
assert.deepEqual(nexusPlayer(done, f.ecaz).forces, nexusPlayer(f.initial, f.ecaz).forces);
|
||||
assert.equal(done.nexusEcazBetrayalHistory?.[0].stage, 'complete');
|
||||
assert.equal(done.nexusEcazBetrayalHistory?.length, 1);
|
||||
assert.equal(done.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
|
||||
assert.equal(done.phase, 5);
|
||||
assert.equal(done.active, f.initial.active);
|
||||
assert.deepEqual(done.movementRemaining, f.initial.movementRemaining);
|
||||
assert.ok(done.players.every(player => !player.shipped && player.moved === 0));
|
||||
await restored(f, done);
|
||||
const settled = row(f);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[2], done.version,
|
||||
f.action, clock), /Ecaz|Nexus/);
|
||||
assert.deepEqual(row(f), settled);
|
||||
});
|
||||
|
||||
void test('SQLite Karama response after restart spends both physical cards but leaves allied forces in place', async t => {
|
||||
const f = await fixture(t);
|
||||
await f.restart().act(f.code, f.auths[2], f.initial.version, f.action, clock);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.version, f.initial.version + 1);
|
||||
assert.equal(pending.response?.kind, 'nexusEcazBetrayal');
|
||||
await restored(f, pending);
|
||||
let canceled = await act(f, 0, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
assert.equal(canceled.version, pending.version + 1);
|
||||
const afterCard = canceled.version;
|
||||
assert.equal(canceled.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
|
||||
assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1);
|
||||
await restored(f, canceled);
|
||||
let passes = 0;
|
||||
while (canceled.response) {
|
||||
assert.ok(passes++ < f.ids.length);
|
||||
const index = f.ids.indexOf(canceled.players.find(p =>
|
||||
!canceled.response!.passed.includes(p.id))!.id);
|
||||
canceled = await act(f, index, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(canceled.version, afterCard + passes);
|
||||
assert.equal(canceled.response, null);
|
||||
assert.equal(canceled.nexusEcazBetrayalHistory?.length, 1);
|
||||
assert.equal(canceled.nexusEcazBetrayalHistory?.[0].stage, 'complete');
|
||||
for (const id of [f.ally, f.ecaz]) {
|
||||
const source = nexusPlayer(f.initial, id);
|
||||
const actual = nexusPlayer(canceled, id);
|
||||
assert.deepEqual(actual.forces, source.forces);
|
||||
assert.deepEqual(actual.elites, source.elites);
|
||||
assert.equal(actual.reserves, source.reserves);
|
||||
}
|
||||
assert.equal(canceled.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(canceled.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
|
||||
assert.equal(canceled.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(canceled.phase, 5);
|
||||
assert.equal(canceled.active, f.initial.active);
|
||||
assert.deepEqual(canceled.movementRemaining, f.initial.movementRemaining);
|
||||
await restored(f, canceled);
|
||||
const saved = row(f);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[2], canceled.version,
|
||||
f.action, clock), /Ecaz|Nexus/);
|
||||
assert.deepEqual(row(f), saved);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, normalizeAutomaticGame, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import {
|
||||
finishNexusSpice, nexusAllow, nexusInventory, nexusPlayer, nexusReady,
|
||||
nexusReject, nexusReload, nexusTurnTwo, orderNexusSpice,
|
||||
} from './fixture-nexus-cards';
|
||||
|
||||
const owner = 'h';
|
||||
const ecaz = 'f';
|
||||
const ally = 'a';
|
||||
const location = 'wind_pass';
|
||||
|
||||
function setup(): { game: Game; action: Action } {
|
||||
let g = nexusTurnTwo({ hostFaction: 'ecaz', secondFaction: 'emperor', advanced: true });
|
||||
orderNexusSpice(g, ['worm', 'land', 'land']);
|
||||
g = nexusReady(nexusReady(g));
|
||||
assert.equal(g.nexus, true);
|
||||
// A reciprocal alliance is negotiated in the actual second-turn Nexus.
|
||||
g = applyAction(g, ecaz, { type: 'alliance', target: ally });
|
||||
g = applyAction(g, ally, { type: 'alliance', target: ecaz });
|
||||
assert.equal(nexusPlayer(g, ecaz).ally, ally);
|
||||
assert.equal(nexusPlayer(g, ally).ally, ecaz);
|
||||
g = finishNexusSpice(g);
|
||||
assert.equal(g.nexusCards!.phase!.stage, 'drawing');
|
||||
const cards = g.nexusCards!.cards!;
|
||||
assert.ok(cards.deck.includes('ecaz'));
|
||||
cards.deck = ['ecaz', ...cards.deck.filter(card => card !== 'ecaz')];
|
||||
g = applyAction(g, owner, {
|
||||
type: 'nexusCardChoice', turn: g.turn, card: null, choice: 'draw', ownRedraws: 0,
|
||||
});
|
||||
assert.equal(g.phase, 2);
|
||||
assert.equal(g.nexusCards!.cards!.hands[owner], 'ecaz');
|
||||
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, 'actual auction must reach revival');
|
||||
for (let step = 0; g.phase === 4 && step < 10; step++) {
|
||||
if (g.decision?.kind === 'ecazPlacement')
|
||||
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
|
||||
else if (g.response) g = nexusAllow(g);
|
||||
else g = nexusReady(g);
|
||||
}
|
||||
assert.equal(g.phase, 5, 'actual revival must reach shipment opening');
|
||||
assert.equal(g.active, g.order[0]);
|
||||
assert.deepEqual(g.movementRemaining, g.order);
|
||||
|
||||
// Reallocate only existing physical counters from the genuine Emperor seat.
|
||||
const target = nexusPlayer(g, ally);
|
||||
target.reserves += Object.values(target.forces).reduce((sum, n) => sum + n, 0);
|
||||
target.elites!.reserves += Object.values(target.elites!.forces).reduce((sum, n) => sum + n, 0);
|
||||
target.forces = { 'wind_pass:14': 3, 'wind_pass:15': 2, 'carthag:11': 1 };
|
||||
target.elites!.forces = { 'wind_pass:14': 1, 'wind_pass:15': 1 };
|
||||
target.reserves -= 6;
|
||||
target.elites!.reserves -= 2;
|
||||
const native = nexusPlayer(g, ecaz);
|
||||
native.reserves -= 2;
|
||||
native.forces['wind_pass:14'] = (native.forces['wind_pass:14'] ?? 0) + 2;
|
||||
nexusInventory(g);
|
||||
const offer = viewGame(g, owner).nexusEcazBetrayal!;
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.ally, ally);
|
||||
assert.deepEqual(offer.territories.find(row => row.territory === location), {
|
||||
territory: location,
|
||||
sectors: {
|
||||
'wind_pass:14': { normal: 2, elite: 1 },
|
||||
'wind_pass:15': { normal: 1, elite: 1 },
|
||||
},
|
||||
total: { normal: 3, elite: 2 },
|
||||
});
|
||||
return { game: g, action: { type: 'nexusEcazBetrayal', event: offer.event, territory: location } };
|
||||
}
|
||||
|
||||
function karama(g: Game): string {
|
||||
const index = g.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0, 'a physical Karama remains in the treachery deck');
|
||||
const card = g.deck.splice(index, 1)[0];
|
||||
nexusPlayer(g, ecaz).hand.push(card);
|
||||
nexusInventory(g);
|
||||
return card.id;
|
||||
}
|
||||
|
||||
void test('real Nexus card spends once and returns every typed sector without using shipment or movement', () => {
|
||||
const { game, action } = setup();
|
||||
karama(game); // Keep the ordinary response open until every player passes.
|
||||
const before = nexusReload(game);
|
||||
const target = nexusPlayer(before, ally);
|
||||
const projected = viewGame(before, owner).nexusEcazBetrayal!;
|
||||
for (const id of [ecaz, ally]) {
|
||||
assert.equal(viewGame(before, id).nexusEcazBetrayal, null);
|
||||
assert.equal(viewGame(before, id).nexusCards?.card, null);
|
||||
}
|
||||
assert.equal(projected.territories.length > 0, true);
|
||||
nexusReject(game, ecaz, action, /Ecaz|Nexus/);
|
||||
nexusReject(game, ally, action, /Ecaz|Nexus/);
|
||||
nexusReject(game, owner, { ...action, event: 'stale' }, /Ecaz|Nexus/);
|
||||
nexusReject(game, owner, { ...action, territory: 'polar_sink' }, /Ecaz|Nexus/);
|
||||
assert.deepEqual(game, before);
|
||||
let pending = applyAction(nexusReload(game), owner, action);
|
||||
assert.equal(pending.response?.kind, 'nexusEcazBetrayal');
|
||||
assert.equal(pending.nexusCards!.cards!.hands[owner], null);
|
||||
assert.deepEqual(pending.nexusCards!.cards!.discard, ['ecaz']);
|
||||
assert.equal(pending.nexusEcazBetrayalHistory?.[0].stage, 'pending');
|
||||
assert.deepEqual(nexusPlayer(pending, ally).forces, target.forces);
|
||||
nexusReject(pending, owner, action, /Ecaz|Nexus/);
|
||||
pending = nexusReload(pending);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(pending)), pending);
|
||||
for (const counter of ['reserves', 'eliteReserves'] as const) {
|
||||
const corrupted = nexusReload(pending);
|
||||
const affected = nexusPlayer(corrupted, ally);
|
||||
if (counter === 'reserves') affected.reserves = '14' as never;
|
||||
else affected.elites!.reserves = '3' as never;
|
||||
const saved = nexusReload(corrupted);
|
||||
assert.throws(() => nexusAllow(corrupted), /valid reserve and elite counters/);
|
||||
assert.deepEqual(corrupted, saved);
|
||||
}
|
||||
const result = nexusAllow(pending);
|
||||
const returned = nexusPlayer(result, ally);
|
||||
assert.equal(returned.forces['wind_pass:14'], undefined);
|
||||
assert.equal(returned.forces['wind_pass:15'], undefined);
|
||||
assert.equal(returned.elites!.forces['wind_pass:14'], undefined);
|
||||
assert.equal(returned.elites!.forces['wind_pass:15'], undefined);
|
||||
assert.equal(returned.reserves, target.reserves + 5);
|
||||
assert.equal(returned.elites!.reserves, target.elites!.reserves + 2);
|
||||
assert.equal(returned.forces['carthag:11'], 1);
|
||||
assert.deepEqual(nexusPlayer(result, ecaz).forces, nexusPlayer(before, ecaz).forces);
|
||||
assert.equal(result.phase, 5);
|
||||
assert.equal(result.active, before.active);
|
||||
assert.deepEqual(result.movementRemaining, before.movementRemaining);
|
||||
for (const p of result.players) {
|
||||
assert.equal(p.shipped, false);
|
||||
assert.equal(p.moved, 0);
|
||||
}
|
||||
assert.equal(result.nexusEcazBetrayalHistory?.[0].stage, 'complete');
|
||||
assert.equal(result.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
|
||||
nexusReject(result, owner, action, /Ecaz|Nexus/);
|
||||
nexusInventory(result);
|
||||
});
|
||||
|
||||
void test('Karama cancellation after saved JSON leaves both force groups in place but spends both physical cards', () => {
|
||||
const { game, action } = setup();
|
||||
const card = karama(game);
|
||||
const original = nexusReload(game);
|
||||
let pending = applyAction(nexusReload(game), owner, action);
|
||||
pending = nexusReload(pending);
|
||||
assert.equal(pending.nexusEcazBetrayalHistory?.[0].stage, 'pending');
|
||||
let canceled = applyAction(pending, ecaz, { type: 'card', card, mode: 'cancel' });
|
||||
canceled = nexusAllow(nexusReload(canceled));
|
||||
assert.equal(canceled.response, null);
|
||||
assert.equal(canceled.nexusEcazBetrayalHistory?.[0].stage, 'complete');
|
||||
for (const id of [ally, ecaz]) {
|
||||
assert.deepEqual(nexusPlayer(canceled, id).forces, nexusPlayer(original, id).forces);
|
||||
assert.deepEqual(nexusPlayer(canceled, id).elites, nexusPlayer(original, id).elites);
|
||||
assert.equal(nexusPlayer(canceled, id).reserves, nexusPlayer(original, id).reserves);
|
||||
}
|
||||
assert.equal(canceled.discard.filter(item => item.id === card).length, 1);
|
||||
assert.equal(canceled.nexusCards!.cards!.discard.filter(item => item === 'ecaz').length, 1);
|
||||
assert.equal(canceled.phase, 5);
|
||||
assert.equal(canceled.active, original.active);
|
||||
assert.deepEqual(canceled.movementRemaining, original.movementRemaining);
|
||||
nexusReject(canceled, owner, action, /Ecaz|Nexus/);
|
||||
nexusInventory(canceled);
|
||||
});
|
||||
|
||||
void test('a bot holding the real card proposes a legal opening action from its private view', () => {
|
||||
const { game } = setup();
|
||||
karama(game); // The bot's declaration opens a real Karama response.
|
||||
const view = viewGame(game, owner);
|
||||
view.players.find(p => p.id === owner)!.bot = 'Easy';
|
||||
const action = botActions(view).find(candidate => candidate.type === 'nexusEcazBetrayal');
|
||||
assert.ok(action, 'bot should act on a legal Ecaz Betrayal offer');
|
||||
assert.equal(action.territory, location);
|
||||
const pending = applyAction(game, owner, action);
|
||||
assert.equal(pending.response?.kind, 'nexusEcazBetrayal');
|
||||
nexusInventory(nexusAllow(pending));
|
||||
});
|
||||
|
||||
void test('an automated holder spends Betrayal before a seated-earlier opening movement bot', () => {
|
||||
const { game } = setup();
|
||||
game.order = [ecaz, ally, owner];
|
||||
game.movementRemaining = [...game.order];
|
||||
game.active = ecaz;
|
||||
nexusPlayer(game, ecaz).bot = 'Easy';
|
||||
nexusPlayer(game, owner).bot = 'Easy';
|
||||
karama(game);
|
||||
const next = runBots(game, 1);
|
||||
assert.equal(next.response?.kind, 'nexusEcazBetrayal');
|
||||
assert.equal(next.nexusEcazBetrayalHistory?.[0].stage, 'pending');
|
||||
assert.equal(next.nexusCards!.cards!.hands[owner], null);
|
||||
nexusInventory(next);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { type Action, type Game, viewGame } from '../game/engine';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { nexusInventory, nexusReady, nexusTurnTwo, orderNexusSpice } from './fixture-nexus-cards';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
async function fixture() {
|
||||
const store = unitStore();
|
||||
const made = await store.rooms.createRoom('Fremen Cunning SQL', 'fremen', true, []);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of ['atreides', 'harkonnen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
const ids = auths.map(auth => auth.playerId) as [string, string, string];
|
||||
let g = nexusTurnTwo({ seatIds: ids, advanced: true });
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('fremen');
|
||||
assert.ok(index >= 0);
|
||||
cards.hands[ids[0]] = cards.deck.splice(index, 1)[0];
|
||||
const sector = g.storm === 14 ? 15 : 14;
|
||||
g.players[0].reserves--;
|
||||
g.players[0].forces[`wind_pass:${sector}`] = 1;
|
||||
orderNexusSpice(g, ['worm', 'land']);
|
||||
const empty = g.spiceDeck.find(card => 'territory' in card &&
|
||||
g.players.every(player => presenceAt(player, card.territory) === 0));
|
||||
assert.ok(empty && 'territory' in empty);
|
||||
g.spiceDeck.splice(g.spiceDeck.indexOf(empty), 1);
|
||||
if (!('territory' in g.spiceDeck[1])) {
|
||||
const nextLand = g.spiceDeck.findIndex((card, i) => i > 1 && 'territory' in card);
|
||||
assert.ok(nextLand > 1);
|
||||
g.spiceDeck.splice(1, 0, g.spiceDeck.splice(nextLand, 1)[0]);
|
||||
}
|
||||
g.spiceDiscard[0].push(empty);
|
||||
const karama = g.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(karama >= 0);
|
||||
g.players[1].hand.push(g.deck.splice(karama, 1)[0]);
|
||||
g = nexusReady(g);
|
||||
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
||||
assert.ok('territory' in g.spiceDeck[0], 'The original worm must draw a land card next.');
|
||||
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;
|
||||
async function act(seat: number, action: Action) {
|
||||
const current = await store.restart().readRoom(code);
|
||||
await store.restart().act(code, auths[seat], current.version, action, clock);
|
||||
return store.restart().readRoom(code);
|
||||
}
|
||||
async function assertPrivateSeats(state: Game) {
|
||||
for (const [i, token] of tokens.entries()) {
|
||||
const rooms = store.restart();
|
||||
const auth = await rooms.authenticate(code, token);
|
||||
const view = await rooms.readSeatView(code, auth);
|
||||
assert.deepEqual(view, viewGame(state, ids[i]));
|
||||
if (i !== 0) assert.equal(view.nexusCards?.card, null);
|
||||
for (const rival of view.players.filter(player => player.id !== auth.playerId)) {
|
||||
assert.equal(rival.hand, undefined);
|
||||
assert.equal(rival.traitors, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function settleResponseAndReachRide() {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const current = await store.restart().readRoom(code);
|
||||
if (current.decision?.kind === 'nexusFremenCunningRide') return current;
|
||||
if (current.response) {
|
||||
const seat = ids.indexOf(current.players.find(player =>
|
||||
!current.response!.passed.includes(player.id))!.id);
|
||||
await act(seat, { type: 'passResponse' });
|
||||
} else {
|
||||
assert.equal(current.decision, null);
|
||||
const seat = ids.indexOf(current.players.find(player =>
|
||||
!current.ready.includes(player.id))!.id);
|
||||
await act(seat, { type: 'ready' });
|
||||
}
|
||||
}
|
||||
assert.fail('The saved accepted offer never reached its remote ride');
|
||||
}
|
||||
return { ...store, code, tokens, auths, ids, initial: g, sector, empty,
|
||||
act, assertPrivateSeats, settleResponseAndReachRide };
|
||||
}
|
||||
|
||||
void test('SQLite races one accepted Fremen offer, restores private Karama response and later rides exactly one force', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const event = f.initial.nexusFremenCunningOffer!.occurrence.event;
|
||||
const originalReserves = f.initial.players[0].reserves;
|
||||
const polarBefore = f.initial.players[0].forces['polar_sink:0'] ?? 0;
|
||||
await f.assertPrivateSeats(f.initial);
|
||||
assert.equal((await f.restart().readSeatView(f.code, f.auths[0])).nexusCards?.card, 'fremen');
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
|
||||
let results: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
results = await Promise.allSettled([0, 1].map(() =>
|
||||
f.restart().act(f.code, f.auths[0], f.initial.version,
|
||||
{ type: 'decision', event, accept: true }, clock)));
|
||||
} finally { delete f.hooks.beforeWrite; }
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
let g = await f.restart().readRoom(f.code);
|
||||
assert.equal(g.version, f.initial.version + 1);
|
||||
assert.equal(g.response?.kind, 'nexusFremenCunning');
|
||||
assert.equal(g.nexusFremenCunningRides?.length, 1);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.ids[0]], null);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.equal(g.players[0].forces[`wind_pass:${f.sector}`], 1);
|
||||
nexusInventory(g);
|
||||
await f.assertPrivateSeats(g);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[0], f.initial.version,
|
||||
{ type: 'decision', event, accept: true }, clock), /table changed/i);
|
||||
g = await f.settleResponseAndReachRide();
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'select');
|
||||
assert.ok(g.spiceDiscard[0].length > f.initial.spiceDiscard[0].length);
|
||||
await f.assertPrivateSeats(g);
|
||||
g = await f.act(0, {
|
||||
type: 'decision', event, accept: true, source: 'wind_pass',
|
||||
forces: { [`wind_pass:${f.sector}`]: { normal: 1, elite: 0 } },
|
||||
territory: 'polar_sink', sector: 0,
|
||||
});
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
assert.equal(g.players[0].forces[`wind_pass:${f.sector}`] ?? 0, 0);
|
||||
assert.equal(g.players[0].forces['polar_sink:0'], polarBefore + 1);
|
||||
assert.equal(g.players[0].reserves, originalReserves);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
nexusInventory(g);
|
||||
await f.assertPrivateSeats(g);
|
||||
const saved = JSON.stringify(g);
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.equal(JSON.stringify(await f.restart().readRoom(f.code)), saved);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('SQLite Karama cancellation of a spent remote ride preserves original worm and never moves the source', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const event = f.initial.nexusFremenCunningOffer!.occurrence.event;
|
||||
await f.act(0, { type: 'decision', event, accept: true });
|
||||
const response = await f.restart().readRoom(f.code);
|
||||
assert.equal(response.response?.kind, 'nexusFremenCunning');
|
||||
const card = response.players[1].hand.find(item => item.effect === 'karama');
|
||||
assert.ok(card);
|
||||
const g = await f.act(1, { type: 'card', card: card.id, mode: 'cancel' });
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
assert.equal(g.players[0].forces[`wind_pass:${f.sector}`], 1);
|
||||
assert.equal(g.players[0].forces[`${f.empty.territory}:${f.empty.sector}`] ?? 0, 0);
|
||||
assert.equal(g.players[0].forces['polar_sink:0'] ?? 0,
|
||||
f.initial.players[0].forces['polar_sink:0'] ?? 0);
|
||||
assert.equal(g.nexusFremenCunningOffer, null);
|
||||
assert.equal(g.spiceDiscard[0].length, f.initial.spiceDiscard[0].length + 1);
|
||||
const nextSpice = f.initial.spiceDeck[0];
|
||||
assert.ok('territory' in nextSpice);
|
||||
assert.equal(g.spiceWindow?.territory, nextSpice.territory);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(item => item === 'fremen').length, 1);
|
||||
nexusInventory(g);
|
||||
await f.assertPrivateSeats(g);
|
||||
const saved = JSON.stringify(g);
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.equal(JSON.stringify(await f.restart().readRoom(f.code)), saved);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { type Action, type Game, viewGame } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { nexusInventory, nexusReady, nexusTurnTwo, orderNexusSpice } from './fixture-nexus-cards';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
async function fixture() {
|
||||
const store = unitStore();
|
||||
try {
|
||||
const made = await store.rooms.createRoom('Summoned Fremen Cunning SQL', 'fremen', true, []);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of ['atreides', 'harkonnen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
const ids = auths.map(auth => auth.playerId) as [string, string, string];
|
||||
let g = nexusTurnTwo({ seatIds: ids, advanced: true });
|
||||
assert.deepEqual(g.expansions, []);
|
||||
assert.equal(g.turn, 2);
|
||||
assert.equal(g.phase, 1);
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('fremen');
|
||||
assert.ok(index >= 0);
|
||||
cards.hands[ids[0]] = cards.deck.splice(index, 1)[0];
|
||||
const sourceSector = g.storm === 14 ? 15 : 14;
|
||||
const source = `wind_pass:${sourceSector}`;
|
||||
const fremen = g.players[0];
|
||||
assert.ok(fremen.elites && fremen.elites.reserves > 0);
|
||||
fremen.reserves -= 3;
|
||||
fremen.forces[source] = (fremen.forces[source] ?? 0) + 3;
|
||||
fremen.elites.reserves--;
|
||||
fremen.elites.forces[source] = (fremen.elites.forces[source] ?? 0) + 1;
|
||||
orderNexusSpice(g, ['land', 'land']);
|
||||
g = nexusReady(g); // A real first blow is in progress, not an invented Nexus window.
|
||||
assert.ok(g.spiceWindow && g.spiceSequence);
|
||||
const target = TERRITORIES.find(row => row.type === 'sand' &&
|
||||
row.id !== g.spiceWindow!.territory && row.id !== 'wind_pass' &&
|
||||
g.players.every(player => presenceAt(player, row.id) === 0));
|
||||
assert.ok(target);
|
||||
const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(karamaIndex >= 0);
|
||||
const karama = g.deck.splice(karamaIndex, 1)[0];
|
||||
g.players[0].hand.push(karama);
|
||||
const cancelIndex = g.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(cancelIndex >= 0);
|
||||
const counterKarama = g.deck.splice(cancelIndex, 1)[0];
|
||||
g.players[1].hand.push(counterKarama);
|
||||
nexusInventory(g);
|
||||
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;
|
||||
async function read() { return store.restart().readRoom(code); }
|
||||
async function act(seat: number, action: Action) {
|
||||
const current = await read();
|
||||
await store.restart().act(code, auths[seat], current.version, action, clock);
|
||||
return read();
|
||||
}
|
||||
async function privateSeats(state: Game) {
|
||||
for (const [i, token] of tokens.entries()) {
|
||||
const rooms = store.restart();
|
||||
const auth = await rooms.authenticate(code, token);
|
||||
const view = await rooms.readSeatView(code, auth);
|
||||
assert.deepEqual(view, viewGame(state, ids[i]));
|
||||
assert.equal(view.nexusCards?.card, i === 0 &&
|
||||
state.nexusCards!.cards!.hands[ids[0]] === 'fremen' ? 'fremen' : null);
|
||||
for (const rival of view.players.filter(player => player.id !== auth.playerId)) {
|
||||
assert.equal(rival.hand, undefined);
|
||||
assert.equal(rival.traitors, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function passResponses() {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const current = await read();
|
||||
if (!current.response) return current;
|
||||
const owner = current.players.find(player => !current.response!.passed.includes(player.id));
|
||||
assert.ok(owner);
|
||||
await act(ids.indexOf(owner.id), { type: 'passResponse' });
|
||||
}
|
||||
assert.fail('Saved summon response did not settle');
|
||||
}
|
||||
async function readyEveryone() {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const state = await read();
|
||||
const id = ids.find(seat => !state.ready.includes(seat));
|
||||
if (!id) return state;
|
||||
await act(ids.indexOf(id), { type: 'ready' });
|
||||
}
|
||||
return read();
|
||||
}
|
||||
const parent = await act(1, { type: 'ready' });
|
||||
assert.deepEqual(parent.ready, [ids[1]]);
|
||||
const summoned = await act(0, {
|
||||
type: 'card', mode: 'special', card: karama.id, territory: target.id,
|
||||
});
|
||||
return { ...store, code, ids, auths, source, target: target.id, karama,
|
||||
counterKarama, parent, summoned, read, act, privateSeats, passResponses, readyEveryone };
|
||||
} catch (error) {
|
||||
store.sqlite.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function spiceInventory(state: Game) {
|
||||
return [...state.spiceDeck, ...state.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort();
|
||||
}
|
||||
function eliteInventory(state: Game) {
|
||||
return state.players.map(player => player.elites && (
|
||||
player.elites.reserves + player.elites.tanks +
|
||||
Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0)
|
||||
));
|
||||
}
|
||||
|
||||
void test('audited summoned empty worm: SQL CAS spends one Nexus card, restarts response and resumes the interrupted blow through a typed remote ride', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const before = f.summoned;
|
||||
assert.equal(before.version, f.parent.version + 1);
|
||||
assert.equal(before.players[0].specialKaramaUsed, true);
|
||||
assert.equal(before.decision?.kind, 'nexusFremenCunningOffer');
|
||||
assert.equal(before.summonedWorm?.territory, f.target);
|
||||
assert.ok(before.summonedWorm?.event);
|
||||
const occurrence = before.nexusFremenCunningOffer!.occurrence;
|
||||
assert.equal(occurrence.origin, 'summoned');
|
||||
assert.equal(occurrence.parent, before.summonedWorm.event);
|
||||
assert.notEqual(occurrence.event, occurrence.parent);
|
||||
assert.equal(occurrence.territory, f.target);
|
||||
assert.equal(occurrence.turn, 2);
|
||||
assert.equal(occurrence.initiallyEmpty, true);
|
||||
assert.deepEqual(before.summonedWorm.resume.spiceWindow, f.parent.spiceWindow);
|
||||
assert.deepEqual(before.summonedWorm.resume.spiceSequence, f.parent.spiceSequence);
|
||||
assert.deepEqual(before.summonedWorm.resume.spiceResolution, f.parent.spiceResolution);
|
||||
assert.deepEqual(before.summonedWorm.resume.ready, f.parent.ready);
|
||||
assert.equal(before.discard.filter(card => card.id === f.karama.id).length, 1);
|
||||
assert.equal(before.players[0].hand.some(card => card.id === f.karama.id), false);
|
||||
assert.deepEqual(spiceInventory(before), spiceInventory(f.parent));
|
||||
assert.deepEqual(eliteInventory(before), eliteInventory(f.parent));
|
||||
await f.privateSeats(before);
|
||||
for (const seat of [1, 2]) {
|
||||
const view = await f.restart().readSeatView(f.code, f.auths[seat]);
|
||||
assert.equal(view.nexusCards?.card, null);
|
||||
assert.equal(view.summonedWorm?.territory, f.target);
|
||||
assert.equal('resume' in view.summonedWorm!, false);
|
||||
assert.equal('event' in view.summonedWorm!, false);
|
||||
}
|
||||
f.writes.length = 0;
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
|
||||
let results: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
results = await Promise.allSettled([0, 1].map(() =>
|
||||
f.restart().act(f.code, f.auths[0], before.version,
|
||||
{ type: 'decision', event: occurrence.event, accept: true }, clock)
|
||||
.catch(error => { release(); throw error; })));
|
||||
} finally { delete f.hooks.beforeWrite; }
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
let g = await f.read();
|
||||
assert.equal(g.version, before.version + 1);
|
||||
assert.equal(g.response?.kind, 'nexusFremenCunning');
|
||||
assert.equal(g.response?.intent, occurrence.event);
|
||||
assert.equal(g.nexusFremenCunningRides?.length, 1);
|
||||
assert.equal(g.nexusFremenCunningRides![0].stage, 'pending');
|
||||
assert.deepEqual(g.nexusFremenCunningRides![0].occurrence, occurrence);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.ids[0]], null);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.equal(g.players[0].forces[f.source], before.players[0].forces[f.source]);
|
||||
assert.equal(g.players[0].elites!.forces[f.source], before.players[0].elites!.forces[f.source]);
|
||||
assert.deepEqual(eliteInventory(g), eliteInventory(f.parent));
|
||||
nexusInventory(g);
|
||||
await f.privateSeats(g);
|
||||
const spent = JSON.stringify(g);
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.equal(JSON.stringify(await f.read()), spent);
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[0], before.version,
|
||||
{ type: 'decision', event: occurrence.event, accept: true }, clock), /table changed/i);
|
||||
assert.equal(JSON.stringify(await f.read()), spent);
|
||||
|
||||
g = await f.passResponses();
|
||||
assert.equal(g.summonedWorm, null);
|
||||
assert.equal(g.nexus, true);
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
|
||||
const targetName = TERRITORIES.find(row => row.id === f.target)!.name;
|
||||
assert.equal(g.log.filter(line => line.text === `Shai-Hulud appeared in ${targetName}.`).length, 1);
|
||||
assert.deepEqual(g.spiceWindow, f.parent.spiceWindow);
|
||||
assert.deepEqual(g.spiceSequence, f.parent.spiceSequence);
|
||||
assert.deepEqual(g.spiceDeck, f.parent.spiceDeck);
|
||||
assert.deepEqual(g.spiceDiscard, f.parent.spiceDiscard);
|
||||
assert.deepEqual(spiceInventory(g), spiceInventory(f.parent));
|
||||
assert.deepEqual(g.wormRides, f.parent.wormRides);
|
||||
await f.privateSeats(g);
|
||||
g = await f.readyEveryone(); // Close the original blow, opening the summoned Nexus.
|
||||
assert.equal(g.spiceWindow, null);
|
||||
assert.equal(g.nexus, true);
|
||||
g = await f.readyEveryone(); // Close that Nexus before the Cunning remote ride.
|
||||
assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'select');
|
||||
assert.equal(g.spiceDeck.length, f.parent.spiceDeck.length);
|
||||
await f.privateSeats(g);
|
||||
const reserves = g.players[0].reserves;
|
||||
const eliteReserves = g.players[0].elites!.reserves;
|
||||
const polar = g.players[0].forces['polar_sink:0'] ?? 0;
|
||||
const polarElite = g.players[0].elites!.forces['polar_sink:0'] ?? 0;
|
||||
g = await f.act(0, {
|
||||
type: 'decision', event: occurrence.event, accept: true, source: 'wind_pass',
|
||||
forces: { [f.source]: { normal: 1, elite: 1 } },
|
||||
territory: 'polar_sink', sector: 0,
|
||||
});
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
assert.equal(g.players[0].forces[f.source], before.players[0].forces[f.source] - 2);
|
||||
assert.equal(g.players[0].elites!.forces[f.source] ?? 0, (before.players[0].elites!.forces[f.source] ?? 0) - 1);
|
||||
assert.equal(g.players[0].forces['polar_sink:0'], polar + 2);
|
||||
assert.equal(g.players[0].elites!.forces['polar_sink:0'], polarElite + 1);
|
||||
assert.equal(g.players[0].reserves, reserves);
|
||||
assert.equal(g.players[0].elites!.reserves, eliteReserves);
|
||||
assert.equal(g.discard.filter(card => card.id === f.karama.id).length, 1);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.deepEqual(spiceInventory(g), spiceInventory(f.parent));
|
||||
assert.deepEqual(eliteInventory(g), eliteInventory(f.parent));
|
||||
const nextBlow = f.parent.spiceDeck[0];
|
||||
assert.ok('territory' in nextBlow);
|
||||
assert.equal(g.spiceWindow?.territory, nextBlow.territory);
|
||||
nexusInventory(g);
|
||||
await f.privateSeats(g);
|
||||
const saved = JSON.stringify(g);
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.equal(JSON.stringify(await f.read()), saved);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('canceled summoned Cunning still resolves the physical worm and resumes the saved original blow', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const occurrence = f.summoned.nexusFremenCunningOffer!.occurrence;
|
||||
await f.act(0, { type: 'decision', event: occurrence.event, accept: true });
|
||||
const pending = await f.read();
|
||||
assert.equal(pending.response?.kind, 'nexusFremenCunning');
|
||||
assert.equal(pending.version, f.summoned.version + 1);
|
||||
let g = await f.act(1, { type: 'card', card: f.counterKarama.id, mode: 'cancel' });
|
||||
assert.equal(g.version, pending.version + 1);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.summonedWorm, null);
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
assert.equal(g.players[0].forces[f.source], f.parent.players[0].forces[f.source]);
|
||||
assert.equal(g.players[0].elites!.forces[f.source], f.parent.players[0].elites!.forces[f.source]);
|
||||
assert.deepEqual(g.spiceWindow, f.parent.spiceWindow);
|
||||
assert.deepEqual(g.spiceSequence, f.parent.spiceSequence);
|
||||
assert.deepEqual(spiceInventory(g), spiceInventory(f.parent));
|
||||
assert.deepEqual(eliteInventory(g), eliteInventory(f.parent));
|
||||
assert.equal(g.discard.filter(card => card.id === f.karama.id).length, 1);
|
||||
assert.equal(g.discard.filter(card => card.id === f.counterKarama.id).length, 1);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
nexusInventory(g);
|
||||
await f.privateSeats(g);
|
||||
g = await f.readyEveryone();
|
||||
assert.equal(g.nexus, true);
|
||||
g = await f.readyEveryone();
|
||||
assert.notEqual(g.decision?.kind, 'nexusFremenCunningRide');
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
assert.equal(g.players[0].forces[f.source], f.parent.players[0].forces[f.source]);
|
||||
assert.deepEqual(spiceInventory(g), spiceInventory(f.parent));
|
||||
assert.equal(g.log.filter(line => line.text ===
|
||||
`Shai-Hulud appeared in ${TERRITORIES.find(row => row.id === f.target)!.name}.`).length, 1);
|
||||
await f.privateSeats(g);
|
||||
const saved = JSON.stringify(g);
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.equal(JSON.stringify(await f.read()), saved);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import {
|
||||
nexusAllow, nexusInventory, nexusReady, nexusReload, nexusTurnTwo,
|
||||
orderNexusSpice,
|
||||
} from './fixture-nexus-cards';
|
||||
|
||||
const source = 'wind_pass';
|
||||
const destination = 'polar_sink:0';
|
||||
|
||||
function fixture(ordinaryRide = false) {
|
||||
// This is the audited three-seat game after a completed first turn, not a
|
||||
// synthetic spice pile or duplicate Nexus/Karama card.
|
||||
let g = nexusTurnTwo({ advanced: true });
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const nexusIndex = cards.deck.indexOf('fremen');
|
||||
assert.ok(nexusIndex >= 0);
|
||||
cards.hands.f = cards.deck.splice(nexusIndex, 1)[0];
|
||||
const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(karamaIndex >= 0);
|
||||
g.players[0].hand.push(g.deck.splice(karamaIndex, 1)[0]);
|
||||
const counterKarama = g.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(counterKarama >= 0);
|
||||
g.players[1].hand.push(g.deck.splice(counterKarama, 1)[0]);
|
||||
const previous = g.spiceDiscard[0].findLast(card => 'territory' in card);
|
||||
assert.ok(!ordinaryRide || (previous && 'territory' in previous));
|
||||
const rideTerritory = ordinaryRide && previous && 'territory' in previous
|
||||
? previous.territory : null;
|
||||
// The prior Spice Blow can be rock. Its worm still grants a native ride,
|
||||
// but the separate Cunning ride needs a physically occupied desert source.
|
||||
const sourceTerritory = rideTerritory &&
|
||||
TERRITORIES.find(row => row.id === rideTerritory)?.type === 'sand'
|
||||
? rideTerritory : source;
|
||||
const sourceSector = sourceTerritory === source
|
||||
? g.storm === 14 ? 15 : 14
|
||||
: TERRITORIES.find(row => row.id === sourceTerritory)!.sectors.find(sector => sector !== g.storm)!;
|
||||
const sourceKey = `${sourceTerritory}:${sourceSector}`;
|
||||
const rideSector = rideTerritory && rideTerritory !== sourceTerritory
|
||||
? TERRITORIES.find(row => row.id === rideTerritory)!.sectors.find(sector => sector !== g.storm)!
|
||||
: null;
|
||||
const fremen = g.players[0];
|
||||
assert.ok(fremen.reserves >= (rideSector === null ? 2 : 3));
|
||||
fremen.reserves -= rideSector === null ? 2 : 3;
|
||||
fremen.forces[sourceKey] = (fremen.forces[sourceKey] ?? 0) + 2;
|
||||
if (rideSector !== null) {
|
||||
const rideKey = `${rideTerritory}:${rideSector}`;
|
||||
fremen.forces[rideKey] = (fremen.forces[rideKey] ?? 0) + 1;
|
||||
}
|
||||
if (ordinaryRide) {
|
||||
// The preceding turn left a physical land card in pile zero. A real worm
|
||||
// drawn next attacks that land; Fremen there earn an ordinary ride.
|
||||
const wormIndex = g.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker);
|
||||
assert.ok(wormIndex >= 0);
|
||||
g.spiceDeck.unshift(g.spiceDeck.splice(wormIndex, 1)[0]);
|
||||
const landIndex = g.spiceDeck.findIndex((card, index) => index > 0 && 'territory' in card);
|
||||
assert.ok(landIndex > 0);
|
||||
g.spiceDeck.splice(1, 0, g.spiceDeck.splice(landIndex, 1)[0]);
|
||||
} else orderNexusSpice(g, ['land', 'land']);
|
||||
g = nexusReady(g);
|
||||
if (ordinaryRide) {
|
||||
g = nexusAllow(g);
|
||||
assert.deepEqual(g.wormRides, [rideTerritory]);
|
||||
}
|
||||
assert.ok(g.spiceWindow && g.spiceSequence);
|
||||
const target = TERRITORIES.find(row => row.type === 'sand' &&
|
||||
row.id !== sourceTerritory && row.id !== g.spiceWindow!.territory &&
|
||||
g.players.every(player => presenceAt(player, row.id) === 0));
|
||||
assert.ok(target, 'The audited setup must leave an empty sand territory.');
|
||||
assert.equal(g.phase, 1);
|
||||
return { g, target: target.id, sourceKey, sourceTerritory, rideTerritory };
|
||||
}
|
||||
|
||||
function summon(g: Game, target: string): Game {
|
||||
return applyAction(g, 'f', {
|
||||
type: 'card', mode: 'special', card: g.players[0].hand.find(card => card.effect === 'karama')!.id,
|
||||
territory: target,
|
||||
});
|
||||
}
|
||||
|
||||
function accepted(g: Game) {
|
||||
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
||||
const pending = g.nexusFremenCunningOffer!;
|
||||
const event = pending.occurrence.event;
|
||||
assert.equal(pending.owner, 'f');
|
||||
assert.equal(pending.occurrence.origin, 'summoned');
|
||||
assert.equal(pending.occurrence.turn, 2);
|
||||
assert.equal(pending.occurrence.initiallyEmpty, true);
|
||||
const parent = g.summonedWorm?.event;
|
||||
assert.ok(parent);
|
||||
assert.match(event, /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i);
|
||||
assert.match(parent, /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i);
|
||||
assert.equal(pending.occurrence.parent, parent);
|
||||
assert.notEqual(event, parent);
|
||||
assert.equal(viewGame(g, 'a').nexusCards?.card, null);
|
||||
assert.equal(viewGame(g, 'f').nexusCards?.card, 'fremen');
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'a', { type: 'decision', event, accept: true }));
|
||||
assert.deepEqual(g, before, 'Other seats cannot spend Fremen Cunning.');
|
||||
assert.throws(() => applyAction(g, 'f', {
|
||||
type: 'decision', event: 'stale-summon-occurrence', accept: true,
|
||||
}), /empty-worm Nexus opportunity/);
|
||||
assert.deepEqual(g, before, 'A stale event cannot change the summon or its card.');
|
||||
g = applyAction(nexusReload(g), 'f', { type: 'decision', event, accept: true });
|
||||
assert.equal(g.response?.kind, 'nexusFremenCunning');
|
||||
assert.equal(g.nexusCards!.cards!.hands.f, null);
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.equal(g.nexusFremenCunningRides?.length, 1);
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'pending');
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].occurrence.event, event);
|
||||
return { g, event };
|
||||
}
|
||||
|
||||
void test('special Karama accepts one real Fremen card, survives a response reload, and restores the Spice Blow and ordinary ride before its remote ride', () => {
|
||||
const { g: original, target, sourceKey, sourceTerritory, rideTerritory } = fixture(true);
|
||||
const parentSequence = structuredClone(original.spiceSequence);
|
||||
const parentWindow = structuredClone(original.spiceWindow);
|
||||
const originalSource = original.players[0].forces[sourceKey] ?? 0;
|
||||
let g = summon(original, target);
|
||||
assert.equal(g.summonedWorm?.territory, target);
|
||||
assert.equal(g.nexusFremenCunningOffer?.occurrence.territory, target);
|
||||
({ g } = accepted(nexusReload(g)));
|
||||
g = nexusAllow(nexusReload(g));
|
||||
assert.equal(g.summonedWorm, null);
|
||||
assert.equal(g.nexusFremenCunningOffer, null);
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
|
||||
assert.deepEqual(g.spiceSequence, parentSequence);
|
||||
assert.deepEqual(g.spiceWindow, parentWindow);
|
||||
assert.deepEqual(g.wormRides, [rideTerritory]);
|
||||
assert.equal(g.nexus, true);
|
||||
assert.equal(g.players[0].forces[sourceKey], originalSource);
|
||||
assert.equal(g.discard.filter(card => card.effect === 'karama').length, 1);
|
||||
g = nexusReady(g); // Finish the interrupted blow; then close the summoned Nexus.
|
||||
assert.equal(g.nexus, true);
|
||||
g = nexusReady(g);
|
||||
assert.equal(g.decision?.kind, 'wormRide');
|
||||
assert.equal(g.decision?.kind === 'wormRide' && g.decision.territory, rideTerritory);
|
||||
g = applyAction(g, 'f', { type: 'decision', accept: false });
|
||||
assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
|
||||
const event = g.nexusFremenCunningRides![0].occurrence.event;
|
||||
assert.equal(g.decision?.kind === 'nexusFremenCunningRide' && g.decision.event, event);
|
||||
const before = nexusReload(g);
|
||||
assert.throws(() => applyAction(g, 'a', { type: 'decision', event, accept: false }));
|
||||
assert.throws(() => applyAction(g, 'f', {
|
||||
type: 'decision', event: 'stale-summon-occurrence', accept: false,
|
||||
}), /pending Fremen Cunning remote worm ride/);
|
||||
assert.deepEqual(g, before);
|
||||
g = applyAction(nexusReload(g), 'f', {
|
||||
type: 'decision', event, accept: true, source: sourceTerritory,
|
||||
forces: { [sourceKey]: { normal: 1, elite: 0 } },
|
||||
territory: 'polar_sink', sector: 0,
|
||||
});
|
||||
assert.equal(g.players[0].forces[sourceKey], originalSource - 1);
|
||||
assert.equal(g.players[0].forces[destination],
|
||||
(original.players[0].forces[destination] ?? 0) + 1);
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
assert.equal(g.nexusFremenCunningRides?.length, 1);
|
||||
nexusInventory(g);
|
||||
});
|
||||
|
||||
void test('Karama cancellation stops only the accepted remote ride; declining leaves the card in hand', () => {
|
||||
const { g: before, target, sourceKey } = fixture();
|
||||
const offered = summon(before, target);
|
||||
const event = offered.nexusFremenCunningOffer!.occurrence.event;
|
||||
const declined = applyAction(nexusReload(offered), 'f', {
|
||||
type: 'decision', event, accept: false,
|
||||
});
|
||||
assert.equal(declined.nexusCards!.cards!.hands.f, 'fremen');
|
||||
assert.equal(declined.nexusFremenCunningRides?.length ?? 0, 0);
|
||||
assert.equal(declined.summonedWorm, null);
|
||||
assert.ok(declined.spiceWindow);
|
||||
const { g: acceptedGame } = accepted(offered);
|
||||
const canceled = applyAction(nexusReload(acceptedGame), 'a', {
|
||||
type: 'card', mode: 'cancel', card: acceptedGame.players[1].hand[0].id,
|
||||
});
|
||||
assert.equal(canceled.response, null);
|
||||
assert.equal(canceled.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
assert.equal(canceled.nexusCards!.cards!.hands.f, null);
|
||||
assert.equal(canceled.players[0].forces[sourceKey], before.players[0].forces[sourceKey]);
|
||||
assert.equal(canceled.summonedWorm, null);
|
||||
assert.deepEqual(canceled.spiceSequence, before.spiceSequence);
|
||||
assert.deepEqual(canceled.spiceWindow, before.spiceWindow);
|
||||
assert.ok(canceled.log.some(line => line.text.includes('Shai-Hulud appeared')));
|
||||
nexusInventory(canceled);
|
||||
});
|
||||
|
||||
void test('occupied appearance, absent desert source, or alliance has no Cunning offer; the summon still resolves', () => {
|
||||
for (const unavailable of ['occupied', 'source', 'allied'] as const) {
|
||||
const { g, target, sourceKey } = fixture();
|
||||
if (unavailable === 'occupied') {
|
||||
const other = g.players[1];
|
||||
const sector = TERRITORIES.find(row => row.id === target)!.sectors.find(s => s !== g.storm)!;
|
||||
other.reserves--;
|
||||
other.forces[`${target}:${sector}`] = (other.forces[`${target}:${sector}`] ?? 0) + 1;
|
||||
} else if (unavailable === 'source') {
|
||||
g.players[0].reserves += g.players[0].forces[sourceKey];
|
||||
delete g.players[0].forces[sourceKey];
|
||||
for (const [key, count] of Object.entries(g.players[0].forces)) {
|
||||
g.players[0].reserves += count;
|
||||
delete g.players[0].forces[key];
|
||||
}
|
||||
} else {
|
||||
g.players[0].ally = 'a';
|
||||
g.players[1].ally = 'f';
|
||||
g.nexusCards!.cards!.hands.f = null;
|
||||
g.nexusCards!.cards!.deck.push('fremen');
|
||||
}
|
||||
const summoned = summon(g, target);
|
||||
assert.notEqual(summoned.decision?.kind, 'nexusFremenCunningOffer', unavailable);
|
||||
assert.equal(summoned.nexusFremenCunningOffer ?? null, null);
|
||||
assert.equal(summoned.summonedWorm, null);
|
||||
assert.deepEqual(summoned.spiceSequence, g.spiceSequence);
|
||||
assert.ok(summoned.log.some(line => line.text.includes('Shai-Hulud appeared')));
|
||||
nexusInventory(summoned);
|
||||
}
|
||||
});
|
||||
|
||||
void test('card-blind empty appearance cannot spend a Fremen card it does not physically hold', () => {
|
||||
const { g, target } = fixture();
|
||||
g.nexusCards!.cards!.hands.f = null;
|
||||
g.nexusCards!.cards!.deck.push('fremen');
|
||||
const offered = summon(g, target);
|
||||
assert.equal(offered.decision?.kind, 'nexusFremenCunningOffer');
|
||||
const event = offered.nexusFremenCunningOffer!.occurrence.event;
|
||||
const before = structuredClone(offered);
|
||||
assert.throws(() => applyAction(offered, 'f', {
|
||||
type: 'decision', event, accept: true,
|
||||
}), /Fremen Cunning needs/);
|
||||
assert.deepEqual(offered, before);
|
||||
const passed = applyAction(offered, 'f', { type: 'decision', event, accept: false });
|
||||
assert.equal(passed.nexusFremenCunningOffer, null);
|
||||
assert.equal(passed.nexusCards!.cards!.hands.f, null);
|
||||
assert.equal(passed.summonedWorm, null);
|
||||
nexusInventory(passed);
|
||||
});
|
||||
|
||||
void test('restored summon offer and spent response reject a replaced parent before a card or force change', () => {
|
||||
const { g: parent, target } = fixture();
|
||||
const offered = summon(parent, target);
|
||||
const event = offered.nexusFremenCunningOffer!.occurrence.event;
|
||||
const wrongOffer = nexusReload(offered);
|
||||
wrongOffer.summonedWorm!.event = 'different-special-karama';
|
||||
const savedOffer = structuredClone(wrongOffer);
|
||||
assert.throws(() => applyAction(wrongOffer, 'f', {
|
||||
type: 'decision', event, accept: true,
|
||||
}), /original special Karama parent/);
|
||||
assert.deepEqual(wrongOffer, savedOffer);
|
||||
const pending = applyAction(offered, 'f', { type: 'decision', event, accept: true });
|
||||
assert.equal(pending.response?.kind, 'nexusFremenCunning');
|
||||
const wrongResponse = nexusReload(pending);
|
||||
wrongResponse.summonedWorm!.event = 'different-special-karama';
|
||||
const savedResponse = structuredClone(wrongResponse);
|
||||
assert.throws(() => applyAction(wrongResponse, 'a', { type: 'passResponse' }),
|
||||
/summoned worm parent/);
|
||||
assert.deepEqual(wrongResponse, savedResponse);
|
||||
assert.equal(pending.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
nexusInventory(pending);
|
||||
});
|
||||
|
||||
function naturalOfferWithUnusedSummon(secondFaction: 'atreides' | 'beneGesserit' = 'atreides') {
|
||||
let g = nexusTurnTwo({ advanced: true, secondFaction });
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const cardIndex = cards.deck.indexOf('fremen');
|
||||
assert.ok(cardIndex >= 0);
|
||||
cards.hands.f = cards.deck.splice(cardIndex, 1)[0];
|
||||
for (const seat of [0, 1]) {
|
||||
const index = g.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
g.players[seat].hand.push(g.deck.splice(index, 1)[0]);
|
||||
}
|
||||
const sourceSector = g.storm === 14 ? 15 : 14;
|
||||
g.players[0].reserves -= 2;
|
||||
g.players[0].forces[`wind_pass:${sourceSector}`] = 2;
|
||||
orderNexusSpice(g, ['worm', 'land']);
|
||||
const empty = g.spiceDeck.find((card, index) => index > 1 && 'territory' in card &&
|
||||
card.territory !== 'wind_pass' &&
|
||||
g.players.every(player => presenceAt(player, card.territory) === 0));
|
||||
assert.ok(empty && 'territory' in empty);
|
||||
g.spiceDeck.splice(g.spiceDeck.indexOf(empty), 1);
|
||||
g.spiceDiscard[0].push(empty);
|
||||
g = nexusReady(g);
|
||||
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
||||
nexusInventory(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('a real special summon suspends and restores an earlier natural Cunning offer across worm survival', () => {
|
||||
const original = naturalOfferWithUnusedSummon();
|
||||
const event = original.nexusFremenCunningOffer!.occurrence.event;
|
||||
const suspended = summon(original, 'wind_pass');
|
||||
assert.equal(suspended.response?.kind, 'wormSurvival');
|
||||
assert.equal(suspended.summonedWorm?.resume.decision?.kind, 'nexusFremenCunningOffer');
|
||||
assert.equal(suspended.nexusFremenCunningOffer?.occurrence.event, event);
|
||||
let g = nexusAllow(nexusReload(suspended));
|
||||
assert.equal(g.summonedWorm, null);
|
||||
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
||||
assert.equal(g.nexusFremenCunningOffer?.occurrence.event, event);
|
||||
g = applyAction(g, 'f', { type: 'decision', event, accept: true });
|
||||
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
nexusInventory(g);
|
||||
});
|
||||
|
||||
void test('a summon during a selected remote ride reopens its Nexus before resuming that ride', () => {
|
||||
let g = naturalOfferWithUnusedSummon();
|
||||
const event = g.nexusFremenCunningOffer!.occurrence.event;
|
||||
g = applyAction(g, 'f', { type: 'decision', event, accept: true });
|
||||
g = nexusAllow(g);
|
||||
for (let i = 0; g.phase === 1 && !g.decision && i < 6; i++)
|
||||
g = g.response ? nexusAllow(g) : nexusReady(g);
|
||||
assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
|
||||
const target = TERRITORIES.find(row => row.type === 'sand' && row.id !== 'wind_pass' &&
|
||||
g.players.every(player => presenceAt(player, row.id) === 0));
|
||||
assert.ok(target);
|
||||
g = summon(g, target.id);
|
||||
assert.equal(g.summonedWorm, null);
|
||||
assert.equal(g.nexus, true);
|
||||
assert.equal(g.decision, null, 'The new Nexus must precede the interrupted ride.');
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
|
||||
g = nexusReady(g);
|
||||
assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].occurrence.event, event);
|
||||
g = applyAction(g, 'f', { type: 'decision', event, accept: false });
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
nexusInventory(g);
|
||||
});
|
||||
|
||||
void test('a summoned survival response preserves the earlier pending Cunning Karama response', () => {
|
||||
let g = naturalOfferWithUnusedSummon();
|
||||
const event = g.nexusFremenCunningOffer!.occurrence.event;
|
||||
g = applyAction(g, 'f', { type: 'decision', event, accept: true });
|
||||
assert.equal(g.response?.kind, 'nexusFremenCunning');
|
||||
g = summon(g, 'wind_pass');
|
||||
assert.equal(g.response?.kind, 'wormSurvival');
|
||||
assert.equal(g.summonedWorm?.resume.response?.kind, 'nexusFremenCunning');
|
||||
g = nexusReload(g);
|
||||
for (let i = 0; g.summonedWorm && i < 6; i++) {
|
||||
assert.equal(g.response?.kind, 'wormSurvival');
|
||||
const id = g.players.find(player => !g.response!.passed.includes(player.id))!.id;
|
||||
g = applyAction(g, id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(g.summonedWorm, null);
|
||||
assert.equal(g.response?.kind, 'nexusFremenCunning');
|
||||
assert.equal(g.response.intent, event);
|
||||
g = nexusAllow(g);
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
|
||||
nexusInventory(g);
|
||||
});
|
||||
|
||||
void test('nested BG Karama conversions preserve the suspended Cunning cancellation', () => {
|
||||
let g = naturalOfferWithUnusedSummon('beneGesserit');
|
||||
const event = g.nexusFremenCunningOffer!.occurrence.event;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const index = g.deck.findIndex(card => card.kind === 'worthless');
|
||||
assert.ok(index >= 0);
|
||||
g.players[1].hand.push(g.deck.splice(index, 1)[0]);
|
||||
}
|
||||
const counterKarama = g.players[1].hand.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(counterKarama >= 0);
|
||||
g.players[2].hand.push(g.players[1].hand.splice(counterKarama, 1)[0]);
|
||||
g = applyAction(g, 'f', { type: 'decision', event, accept: true });
|
||||
assert.equal(g.response?.kind, 'nexusFremenCunning');
|
||||
g = applyAction(g, 'a', {
|
||||
type: 'card', mode: 'cancel', card: g.players[1].hand.find(card => card.kind === 'worthless')!.id,
|
||||
});
|
||||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||||
assert.equal(g.pendingKarama?.use.kind, 'cancel');
|
||||
g = summon(g, 'wind_pass');
|
||||
assert.equal(g.response?.kind, 'wormSurvival');
|
||||
assert.equal(g.summonedWorm?.resume.pendingKarama?.use.kind, 'cancel');
|
||||
g = applyAction(g, 'a', {
|
||||
type: 'card', mode: 'cancel', card: g.players[1].hand.find(card => card.kind === 'worthless')!.id,
|
||||
});
|
||||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||||
assert.equal(g.pendingKarama?.use.kind, 'cancel');
|
||||
assert.equal(g.summonedWorm?.resume.pendingKarama?.use.kind, 'cancel');
|
||||
g = applyAction(nexusReload(g), 'h', { type: 'passResponse' });
|
||||
assert.equal(g.summonedWorm, null);
|
||||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||||
assert.equal(g.pendingKarama?.use.kind, 'cancel');
|
||||
if (g.pendingKarama?.use.kind === 'cancel')
|
||||
assert.equal(g.pendingKarama.use.response.kind === 'nexusFremenCunning' &&
|
||||
g.pendingKarama.use.response.intent, event);
|
||||
g = nexusAllow(g);
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
nexusInventory(g);
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { test } from 'node:test';
|
||||
import { captureFremenCunningOccurrence, fremenCunningDestinationBlock,
|
||||
quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection,
|
||||
type FremenCunningOccurrence, type FremenCunningRideAuthorization,
|
||||
type FremenCunningSelection } from '../game/nexus-fremen-cunning';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { nexusFixture, nexusTurnTwo, nexusReady, nexusAllow, nexusInventory, nexusReload, orderNexusSpice } from './fixture-nexus-cards';
|
||||
|
||||
function fixture() {
|
||||
const g = nexusFixture({ advanced: true });
|
||||
g.turn = 2;
|
||||
g.phase = 1;
|
||||
g.storm = 0;
|
||||
for (const p of g.players) {
|
||||
p.forces = {};
|
||||
if (p.elites) p.elites.forces = {};
|
||||
}
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.deck = cards.deck.filter(card => card !== 'fremen');
|
||||
cards.discard = cards.discard.filter(card => card !== 'fremen');
|
||||
for (const id of Object.keys(cards.hands))
|
||||
if (cards.hands[id] === 'fremen') cards.hands[id] = null;
|
||||
cards.hands[g.players[0].id] = 'fremen';
|
||||
const fremen = g.players[0];
|
||||
fremen.forces['wind_pass:14'] = 3;
|
||||
fremen.forces['wind_pass:15'] = 2;
|
||||
fremen.elites!.forces['wind_pass:14'] = 1;
|
||||
fremen.elites!.forces['wind_pass:15'] = 2;
|
||||
return { g, fremen, other: g.players[1] };
|
||||
}
|
||||
|
||||
function appearance(g: Game, territory = 'hagga_basin', event = 'worm-pile-0') {
|
||||
return captureFremenCunningOccurrence(g, {
|
||||
event, territory, origin: 'natural', pile: 0,
|
||||
});
|
||||
}
|
||||
function accepted(g: Game, occurrence: FremenCunningOccurrence): FremenCunningRideAuthorization {
|
||||
const owner = g.players[0].id;
|
||||
g.nexusCards!.cards!.hands[owner] = null;
|
||||
g.nexusCards!.cards!.discard.push('fremen');
|
||||
return { event: occurrence.event, turn: occurrence.turn, owner };
|
||||
}
|
||||
const selection: FremenCunningSelection = {
|
||||
source: 'wind_pass',
|
||||
forces: { 'wind_pass:14': { normal: 1, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 2 } },
|
||||
destination: { territory: 'carthag', sector: 11 },
|
||||
};
|
||||
|
||||
void test('initial public emptiness belongs to an individual appearance, not its post-devour board', () => {
|
||||
const { g, other } = fixture();
|
||||
const empty = appearance(g);
|
||||
assert.equal(empty.initiallyEmpty, true);
|
||||
other.forces['hagga_basin:12'] = 2;
|
||||
const occupied = appearance(g, 'hagga_basin', 'worm-pile-1');
|
||||
assert.equal(occupied.initiallyEmpty, false);
|
||||
delete other.forces['hagga_basin:12']; // worm devoured the defenders
|
||||
assert.equal(quoteFremenCunning(g, g.players[0].id, occupied), null);
|
||||
const quote = quoteFremenCunning(g, g.players[0].id, empty)!;
|
||||
assert.equal(quote.event, 'worm-pile-0');
|
||||
assert.deepEqual(quote.sources, [{ territory: 'wind_pass', sectors: {
|
||||
'wind_pass:14': { normal: 2, elite: 1 }, 'wind_pass:15': { normal: 0, elite: 2 },
|
||||
} }]);
|
||||
const receipt = accepted(g, empty);
|
||||
assert.deepEqual(validateFremenCunningSelection(g, g.players[0].id, empty, selection, receipt),
|
||||
{ total: 4, elite: 3 });
|
||||
assert.equal(g.players[0].forces['wind_pass:14'], 3); // pure quote, no movement
|
||||
assert.equal(quoteFremenCunning(g, g.players[0].id, { ...empty, turn: 1 }), null);
|
||||
assert.equal(quoteFremenCunningRide(g, g.players[0].id, occupied, receipt), null);
|
||||
assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
|
||||
{ ...receipt, event: 'worm-pile-1' }), null);
|
||||
assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
|
||||
{ ...receipt, owner: other.id }), null);
|
||||
assert.equal(quoteFremenCunningRide(g, g.players[0].id, empty,
|
||||
{ ...receipt, turn: empty.turn - 1 }), null);
|
||||
assert.equal(quoteFremenCunningRide(g, g.players[0].id, { ...empty, turn: 1 }, receipt), null);
|
||||
});
|
||||
|
||||
void test('advisers and a public No-Field count as initial force presence without reading marker denomination', () => {
|
||||
const { g, other } = fixture();
|
||||
other.faction = 'beneGesserit';
|
||||
other.forces['hagga_basin:13'] = 1;
|
||||
other.advisors = { hagga_basin: {} };
|
||||
assert.equal(appearance(g).initiallyEmpty, false);
|
||||
delete other.forces['hagga_basin:13'];
|
||||
other.faction = 'richese';
|
||||
const marker = { deployed: { location: { territory: 'hagga_basin', sector: 12 } } };
|
||||
Object.defineProperty(marker, 'tokens', { get() { throw new Error('private denomination read'); } });
|
||||
other.noField = marker as typeof other.noField;
|
||||
assert.equal(appearance(g).initiallyEmpty, false);
|
||||
assert.equal(appearance(g, 'carthag').initiallyEmpty, true);
|
||||
});
|
||||
|
||||
void test('typed one-desert-source subset and ordinary destination occupancy/storm restrictions', () => {
|
||||
const { g, fremen, other } = fixture();
|
||||
const occurrence = appearance(g);
|
||||
const receipt = accepted(g, occurrence);
|
||||
const valid = (move: FremenCunningSelection) =>
|
||||
validateFremenCunningSelection(g, fremen.id, occurrence, move, receipt);
|
||||
const reject = (move: FremenCunningSelection, reason: RegExp) => assert.throws(() => valid(move), reason);
|
||||
reject({ ...selection, forces: { ...selection.forces, 'hagga_basin:12': { normal: 1, elite: 0 } } }, /physical forces/);
|
||||
reject({ ...selection, forces: { 'wind_pass:14': { normal: 3, elite: 0 } } }, /physical forces/);
|
||||
reject({ ...selection, forces: { 'wind_pass:15': { normal: 1, elite: 0 } } }, /physical forces/);
|
||||
reject({ ...selection, forces: { constructor: { normal: 20, elite: 0 } } },
|
||||
/physical forces/);
|
||||
reject({ ...selection, forces: {} }, /at least one/);
|
||||
reject({ ...selection, source: 'carthag' }, /desert/);
|
||||
assert.equal(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source,
|
||||
{ territory: 'hagga_basin', sector: 12 }), null);
|
||||
reject({ ...selection, destination: { territory: 'wind_pass', sector: 14 } }, /another destination/);
|
||||
reject({ ...selection, destination: { territory: 'carthag', sector: 12 } }, /Arrakis board sector/);
|
||||
g.storm = 11;
|
||||
reject(selection, /storm/);
|
||||
g.storm = 14;
|
||||
assert.equal(quoteFremenCunningRide(g, fremen.id, occurrence, receipt)!.sources[0].sectors['wind_pass:14'], undefined);
|
||||
reject(selection, /physical forces/);
|
||||
g.storm = 0;
|
||||
fremen.ally = other.id;
|
||||
other.ally = fremen.id;
|
||||
other.forces['carthag:11'] = 1;
|
||||
assert.notEqual(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source, selection.destination), null);
|
||||
fremen.ally = null;
|
||||
other.ally = null;
|
||||
const third = g.players[2];
|
||||
third.forces['carthag:11'] = 1;
|
||||
assert.notEqual(fremenCunningDestinationBlock(g, fremen.id, occurrence, selection.source, selection.destination), null);
|
||||
});
|
||||
|
||||
void test('physical Fremen card ownership and native entitlement are required', () => {
|
||||
const { g, fremen, other } = fixture();
|
||||
const occurrence = appearance(g);
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.hands[fremen.id] = null;
|
||||
cards.hands[other.id] = 'fremen';
|
||||
assert.equal(quoteFremenCunning(g, fremen.id, occurrence), null);
|
||||
assert.equal(quoteFremenCunning(g, other.id, occurrence), null);
|
||||
assert.throws(() => validateFremenCunningSelection(g, fremen.id, occurrence, selection,
|
||||
{ event: occurrence.event, turn: occurrence.turn, owner: fremen.id }), /not available/);
|
||||
cards.hands[other.id] = null;
|
||||
cards.hands[fremen.id] = 'fremen';
|
||||
cards.deck.push('fremen'); // duplicate physical card cannot establish custody
|
||||
assert.throws(() => quoteFremenCunning(g, fremen.id, occurrence), /Nexus/);
|
||||
cards.deck.pop();
|
||||
fremen.ally = other.id;
|
||||
other.ally = fremen.id;
|
||||
assert.equal(quoteFremenCunning(g, fremen.id, occurrence), null);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} empty worm offers privately, spends once and rides remotely after the Nexus`, () => {
|
||||
let g = nexusTurnTwo({ advanced });
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('fremen');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck.splice(index, 1);
|
||||
cards.hands.f = 'fremen';
|
||||
const sourceSector = g.storm === 14 ? 15 : 14;
|
||||
g.players[0].reserves--;
|
||||
g.players[0].forces[`wind_pass:${sourceSector}`] = 1;
|
||||
orderNexusSpice(g, ['worm', 'land']);
|
||||
const empty = g.spiceDeck.find(card => 'territory' in card &&
|
||||
g.players.every(player => presenceAt(player, card.territory) === 0));
|
||||
assert.ok(empty && 'territory' in empty);
|
||||
g.spiceDeck.splice(g.spiceDeck.indexOf(empty), 1);
|
||||
if (!('territory' in g.spiceDeck[1])) {
|
||||
const nextLand = g.spiceDeck.findIndex((card, index) =>
|
||||
index > 1 && 'territory' in card);
|
||||
assert.ok(nextLand > 1);
|
||||
g.spiceDeck.splice(1, 0, g.spiceDeck.splice(nextLand, 1)[0]);
|
||||
}
|
||||
g.spiceDiscard[0].push(empty);
|
||||
g = nexusReady(g);
|
||||
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
||||
const event = g.nexusFremenCunningOffer!.occurrence.event;
|
||||
assert.equal(viewGame(g, 'a').nexusCards?.card, null);
|
||||
assert.equal(viewGame(g, 'f').nexusCards?.card, 'fremen');
|
||||
const karamaIndex = g.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(karamaIndex >= 0);
|
||||
g.players[1].hand.push(g.deck.splice(karamaIndex, 1)[0]);
|
||||
const noCard = nexusReload(g);
|
||||
noCard.nexusCards!.cards!.hands.f = null;
|
||||
noCard.nexusCards!.cards!.deck.push('fremen');
|
||||
assert.equal(viewGame(noCard, 'a').decision?.kind, 'nexusFremenCunningOffer');
|
||||
assert.throws(() => applyAction(noCard, 'f', {
|
||||
type: 'decision', event, accept: true,
|
||||
}), /Fremen Cunning needs/);
|
||||
const passed = applyAction(noCard, 'f', { type: 'decision', event, accept: false });
|
||||
assert.equal(passed.nexusFremenCunningOffer, null);
|
||||
assert.equal(passed.nexusCards!.cards!.hands.f, null);
|
||||
assert.throws(() => applyAction(g, 'f', {
|
||||
type: 'decision', event: 'not-this-worm', accept: true,
|
||||
}), /empty-worm Nexus opportunity/);
|
||||
g = applyAction(nexusReload(g), 'f', { type: 'decision', event, accept: true });
|
||||
assert.equal(g.response?.kind, 'nexusFremenCunning');
|
||||
assert.equal(g.nexusCards!.cards!.hands.f, null);
|
||||
assert.ok(g.nexusCards!.cards!.discard.includes('fremen'));
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
|
||||
const canceled = applyAction(nexusReload(g), 'a', {
|
||||
type: 'card', card: g.players[1].hand[0].id, mode: 'cancel',
|
||||
});
|
||||
assert.equal(canceled.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
assert.ok(canceled.log.some(line => line.text.includes('Shai-Hulud appeared')));
|
||||
assert.equal(canceled.players[0].forces[`wind_pass:${sourceSector}`], 1);
|
||||
nexusInventory(canceled);
|
||||
g = nexusAllow(g);
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
|
||||
for (let i = 0; !g.decision && g.phase === 1 && i < 6; i++) {
|
||||
if (g.response) g = nexusAllow(g);
|
||||
else g = nexusReady(g);
|
||||
}
|
||||
assert.equal(g.decision?.kind, 'nexusFremenCunningRide');
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'select');
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
|
||||
const botView = viewGame(g, 'f');
|
||||
botView.players.find(player => player.id === 'f')!.bot = 'Easy';
|
||||
const botRide = botActions(botView).find(candidate => candidate.accept === true);
|
||||
assert.ok(botRide);
|
||||
const botResult = applyAction(nexusReload(g), 'f', botRide);
|
||||
assert.equal(botResult.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
nexusInventory(botResult);
|
||||
const action = {
|
||||
type: 'decision' as const, event, accept: true, source: 'wind_pass',
|
||||
forces: { [`wind_pass:${sourceSector}`]: { normal: 1, elite: 0 } },
|
||||
territory: 'polar_sink', sector: 0,
|
||||
};
|
||||
assert.throws(() => applyAction(g, 'f', { ...action, source: 'sietch_tabr' }),
|
||||
/desert territory/);
|
||||
assert.throws(() => applyAction(g, 'f', {
|
||||
...action, forces: { constructor: { normal: 20, elite: 0 } },
|
||||
}), /physical forces/);
|
||||
const before = nexusReload(g);
|
||||
assert.deepEqual(g, before);
|
||||
g = applyAction(g, 'f', action);
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'complete');
|
||||
assert.equal(g.players[0].forces[`wind_pass:${sourceSector}`] ?? 0, 0);
|
||||
assert.equal(g.players[0].forces['polar_sink:0'], 1);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
|
||||
nexusInventory(g);
|
||||
});
|
||||
|
||||
void test('an accepted Advanced additional-worm placement offers the distinct empty appearance', () => {
|
||||
let g = nexusTurnTwo({ advanced: true });
|
||||
const cards = g.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('fremen');
|
||||
assert.ok(index >= 0);
|
||||
cards.hands.f = cards.deck.splice(index, 1)[0];
|
||||
const sourceSector = g.storm === 14 ? 15 : 14;
|
||||
g.players[0].reserves--;
|
||||
g.players[0].forces[`wind_pass:${sourceSector}`] = 1;
|
||||
const empty = TERRITORIES.find(row => row.type === 'sand' &&
|
||||
g.players.every(player => presenceAt(player, row.id) === 0));
|
||||
assert.ok(empty);
|
||||
orderNexusSpice(g, ['worm', 'land']);
|
||||
const priorWorm = g.spiceDeck.findIndex((card, i) => i > 1 && 'worm' in card);
|
||||
assert.ok(priorWorm > 1);
|
||||
g.spiceDiscard[0].push(g.spiceDeck.splice(priorWorm, 1)[0]);
|
||||
g = nexusReady(g);
|
||||
assert.equal(g.decision?.kind, 'wormPlacement');
|
||||
g = applyAction(g, 'f', { type: 'decision', accept: true, territory: empty.id });
|
||||
g = nexusAllow(g);
|
||||
assert.equal(g.decision?.kind, 'nexusFremenCunningOffer');
|
||||
const occurrence = g.nexusFremenCunningOffer!.occurrence;
|
||||
assert.equal(occurrence.origin, 'additional');
|
||||
assert.equal(occurrence.territory, empty.id);
|
||||
assert.equal(occurrence.initiallyEmpty, true);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
|
||||
g = applyAction(g, 'f', { type: 'decision', event: occurrence.event, accept: true });
|
||||
g = nexusAllow(g);
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].stage, 'queued');
|
||||
assert.equal(g.nexusFremenCunningRides?.[0].occurrence.event, occurrence.event);
|
||||
assert.equal(g.nexusCards!.cards!.hands.f, null);
|
||||
nexusInventory(g);
|
||||
});
|
||||
Reference in new issue
Block a user