280 lines
14 KiB
TypeScript
280 lines
14 KiB
TypeScript
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(); }
|
|
});
|