Files
Dune/tests/semuta-recovery.test.ts
T

1361 lines
79 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { startPrototypeRoom } from '../tools/prototype-room';
import { botActions } from '../game/bots';
import { applyAction, createGame, newPlayer, viewGame, type Game, type Action } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { baseDeck, spiceDeck, treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { newRevivalRules } from '../game/revival';
import { unitStore } from './fixture-nexus-room-store';
import { saphoAggressorGame, aggressorAction } from './fixture-sapho-aggressor';
import { takeSaphoBattleCard } from './fixture-sapho-battle-order';
const clock = { now: () => 10000, sleep: async () => {} };
const seat = (g: Game, id: string) => g.players.find(p => p.id === id)!;
function take(g: Game, identity: string) {
for (const zone of [g.deck, g.discard, g.richeseCache!, ...g.players.map(p => p.hand)]) {
const index = zone.findIndex(card => card.id === identity || card.effect === identity);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw new Error('The genuine setup lost a physical card.');
}
void test('authenticated clean Semuta producers claim once through restart and room CAS', async () => {
const f = unitStore();
try {
const created = await f.rooms.createRoom('Semuta QA', 'richese', false, ['choam']);
const code = created.view.code;
const second = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
const third = await f.rooms.joinRoom(code, 'Emperor', 'emperor');
const auths = await Promise.all([created.token!, second.token!, third.token!]
.map(token => f.restart().authenticate(code, token)));
const act = async (index: number, action: Action) => {
const before = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], before.version, action, clock);
return f.restart().readRoom(code);
};
for (let i = 0; i < auths.length; i++) await act(i, { type: 'ready' });
let game = await f.restart().readRoom(code);
startPrototypeRoom(f.sqlite, code, game.version, 'semuta');
game = await f.restart().readRoom(code);
for (let step = 0; game.status === 'setup' && step < 80; step++) {
let moved = false;
for (let i = 0; i < auths.length; i++) {
const view = viewGame(game, auths[i].playerId);
view.players.find(p => p.id === view.me)!.bot = 'Medium';
const choice = botActions(view)[0];
if (choice) { game = await act(i, choice); moved = true; break; }
}
assert.ok(moved, 'genuine setup must retain a legal choice');
}
assert.equal(game.status, 'playing');
const ids = auths.map(auth => auth.playerId), originalSeats = f.sqlite.prepare('SELECT * FROM seats').all();
const semuta = take(game, SEMUTA_DRUG_ID), hajr = take(game, 'hajr');
game.deck.push(...game.players.flatMap(p => p.hand));
for (const p of game.players) p.hand = [];
seat(game, ids[0]).hand.push(semuta);
seat(game, ids[1]).hand.push(hajr);
Object.assign(game, { turn: 2, phase: 5, active: ids[1], storm: 18, ready: [],
decision: null, response: null, phaseOpening: null, stormPending: null,
movementRemaining: [ids[1], ids[0], ids[2]] });
game.version++;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(game), game.version, code, game.version - 1).changes, 1);
game = await act(1, { type: 'card', card: hajr.id });
const event = game.pendingTreacheryDiscard?.batch.event;
assert.ok(event);
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const foreign = await f.restart().readSeatView(code, auths[2]);
const owner = await f.restart().readSeatView(code, auths[0]);
assert.equal(foreign.semutaReaction?.event, owner.semutaReaction?.event);
assert.deepEqual(foreign.semutaReaction?.candidates, []);
assert.deepEqual(owner.semutaReaction?.candidates, []);
assert.equal(owner.semutaReaction?.canCommit, true);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, game.version);
game = await act(1, { type: 'semutaPass', event });
const version = game.version;
let arrivals = 0; let release!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
const results = await Promise.allSettled([
f.rooms.act(code, auths[0], version, { type: 'semutaCommit', event }, clock),
f.restart().act(code, auths[0], version, { type: 'semutaCommit', event }, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
const done = await f.restart().readRoom(code);
assert.equal(done.version, version + 1);
assert.equal(done.pendingTreacheryDiscard, null);
assert.equal(seat(done, ids[0]).hand.filter(card => card.id === hajr.id).length, 1);
assert.equal(done.discard.filter(card => card.id === semuta.id).length, 1);
assert.equal(done.discard.some(card => card.id === hajr.id), false);
assert.equal(done.hajr.filter(id => id === ids[1]).length, 1);
const cards = [...done.deck, ...done.discard, ...done.richeseCache!, ...done.players.flatMap(p => p.hand)];
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths) assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(done, auth.playerId));
const next = structuredClone(done);
const usedSemuta = take(next, SEMUTA_DRUG_ID);
const box = take(next, 'richese-nullentropy-box');
seat(next, ids[0]).hand.push(usedSemuta);
seat(next, ids[1]).hand.push(box);
const older = [next.deck.shift(), next.deck.shift()];
assert.ok(older[0] && older[1]);
next.discard.push(older[0], older[1]);
Object.assign(next, { turn: 3, phase: 4, active: null, ready: [], decision: null,
response: null, phaseOpening: null, stormPending: null });
next.version = done.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(next), next.version, code, done.version).changes, 1);
const beforeSpice = seat(next, ids[1]).spice;
const paid = await act(1, { type: 'card', card: box.id });
assert.ok(paid.pendingNullentropy);
const selected = paid.discard[0].id;
const offered = await act(1, { type: 'decision', event: paid.pendingNullentropy.event, card: selected });
assert.equal(offered.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, offered.version);
const claimedBox = await act(0, { type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event });
assert.equal(claimedBox.pendingTreacheryDiscard, null);
assert.equal(seat(claimedBox, ids[1]).spice, beforeSpice - 2);
assert.equal(seat(claimedBox, ids[1]).hand.filter(card => card.id === selected).length, 1);
assert.equal(seat(claimedBox, ids[0]).hand.filter(card => card.id === box.id).length, 1);
assert.equal(claimedBox.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(claimedBox.discard.some(card => card.id === box.id), false);
const finalCards = [...claimedBox.deck, ...claimedBox.discard, ...claimedBox.richeseCache!,
...claimedBox.players.flatMap(p => p.hand)];
assert.equal(new Set(finalCards.map(card => card.id)).size, finalCards.length);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimedBox, auth.playerId));
const flight = structuredClone(claimedBox);
const reusableSemuta = take(flight, SEMUTA_DRUG_ID);
const ornithopter = take(flight, 'richese-ornithopter');
seat(flight, ids[0]).hand.push(reusableSemuta);
seat(flight, ids[1]).hand.push(ornithopter);
const pilot = seat(flight, ids[1]);
pilot.reserves += Object.values(pilot.forces).reduce((sum, n) => sum + n, 0) - 3;
pilot.forces = { 'imperial_basin:10': 3 };
pilot.shipped = true;
pilot.moved = 0;
flight.hajr = [];
Object.assign(flight, { turn: 4, phase: 5, active: ids[1], storm: 18,
ready: [], decision: null, response: null, phaseOpening: null,
stormPending: null, movementRemaining: [ids[1], ids[0], ids[2]] });
flight.version = claimedBox.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(flight), flight.version, code, claimedBox.version).changes, 1);
const moved = await act(1, { type: 'move', movementCard: ornithopter.id,
ornithopter: 'twoGroups', forces: { 'imperial_basin:10': 1 },
territory: 'arrakeen', sector: 10 });
assert.equal(moved.ornithopter?.completed, 1);
const retired = await act(1, { type: 'endMovement' });
const flightEvent = retired.pendingTreacheryDiscard?.batch.event;
assert.equal(retired.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.ok(flightEvent);
assert.equal(retired.active, ids[1]);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, retired.version);
const flightOwner = await f.restart().readSeatView(code, auths[0]);
const flightObserver = await f.restart().readSeatView(code, auths[2]);
assert.equal(flightOwner.semutaReaction?.canCommit, true);
assert.equal(flightObserver.semutaReaction?.canCommit, false);
const flown = await act(0, { type: 'semutaCommit', event: flightEvent });
assert.equal(flown.pendingTreacheryDiscard, null);
assert.equal(flown.active, ids[0]);
assert.deepEqual(flown.movementRemaining, [ids[0], ids[2]]);
assert.equal(seat(flown, ids[1]).moved, 1);
assert.equal(seat(flown, ids[1]).forces['arrakeen:10'], 1);
assert.equal(seat(flown, ids[0]).hand.filter(card => card.id === ornithopter.id).length, 1);
assert.equal(flown.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(flown.discard.some(card => card.id === ornithopter.id), false);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(flown, auth.playerId));
const transfer = structuredClone(flown);
const finalSemuta = take(transfer, SEMUTA_DRUG_ID);
const distrans = take(transfer, 'richese-distrans');
transfer.richeseCache!.push(take(transfer, 'richese-nullentropy-box'));
seat(transfer, ids[0]).hand.push(finalSemuta);
seat(transfer, ids[1]).hand.push(distrans);
const given = seat(transfer, ids[1]).hand.find(card => card.id === selected)!;
assert.ok(given);
Object.assign(transfer, { turn: 5, phase: 5, active: ids[1], ready: [],
decision: null, response: null, phaseOpening: null, stormPending: null,
movementRemaining: [ids[1], ids[0], ids[2]], hajr: [] });
transfer.version = flown.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(transfer), transfer.version, code, flown.version).changes, 1);
const handed = await act(1, { type: 'card', card: distrans.id,
target: ids[2], give: given.id });
assert.equal(handed.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(seat(handed, ids[2]).hand.filter(card => card.id === given.id).length, 1);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, handed.version);
const observer = await f.restart().readSeatView(code, auths[0]);
assert.equal(observer.semutaReaction?.canCommit, true);
assert.equal(observer.players.find(p => p.id === ids[2])?.hand, undefined);
const final = await act(0, { type: 'semutaCommit',
event: handed.pendingTreacheryDiscard!.batch.event });
assert.equal(final.pendingTreacheryDiscard, null);
assert.equal(seat(final, ids[2]).hand.filter(card => card.id === given.id).length, 1);
assert.equal(seat(final, ids[0]).hand.filter(card => card.id === distrans.id).length, 1);
assert.equal(final.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(final.discard.some(card => card.id === distrans.id), false);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(final, auth.playerId));
const finalAnswer = structuredClone(final);
const lastSemuta = take(finalAnswer, SEMUTA_DRUG_ID);
finalAnswer.richeseCache!.push(take(finalAnswer, distrans.id));
seat(finalAnswer, ids[0]).hand.push(lastSemuta);
const truth = take(finalAnswer, 'truthtrance');
seat(finalAnswer, ids[1]).hand.push(truth);
Object.assign(finalAnswer, { turn: 6, phase: 5, active: ids[1], ready: [],
decision: null, response: null, phaseOpening: null, stormPending: null,
movementRemaining: [ids[1], ids[0], ids[2]], hajr: [] });
finalAnswer.version = final.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(finalAnswer), finalAnswer.version, code, final.version).changes, 1);
let question = await act(1, { type: 'card', card: truth.id });
while (question.truthtrance?.stage === 'priority') {
const responder = question.players.find(p => !question.truthtrance!.passed.includes(p.id))!;
question = await act(ids.indexOf(responder.id), { type: 'truthPass' });
}
const targetSpice = seat(question, ids[2]).spice;
question = await act(1, { type: 'truthAsk',
question: { kind: 'fact', target: ids[2],
fact: { kind: 'spice', compare: 'eq', value: targetSpice } } });
const answered = await act(2, { type: 'truthAnswer', answer: 'yes' });
assert.equal(answered.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(answered.truthHistory?.at(-1)?.answer, 'yes');
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, answered.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const recovered = await act(0, { type: 'semutaCommit',
event: answered.pendingTreacheryDiscard!.batch.event });
assert.equal(recovered.pendingTreacheryDiscard, null);
assert.equal(recovered.truthtrance, null);
assert.deepEqual(recovered.truthHistory, answered.truthHistory);
assert.equal(seat(recovered, ids[0]).hand.filter(card => card.id === truth.id).length, 1);
assert.equal(recovered.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(recovered.discard.some(card => card.id === truth.id), false);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(recovered, auth.playerId));
const ordered = structuredClone(recovered);
const again = take(ordered, SEMUTA_DRUG_ID);
ordered.deck.push(take(ordered, truth.id));
seat(ordered, ids[0]).hand.push(again);
const sapho = take(ordered, 'richese-juice-of-sapho');
seat(ordered, ids[2]).hand.push(sapho);
for (const p of ordered.players) { p.moved = 0; p.shipped = false; }
Object.assign(ordered, { turn: 7, phase: 5, active: ids[1], ready: [],
decision: null, response: null, phaseOpening: null, stormPending: null,
movementRemaining: [ids[1], ids[0], ids[2]], hajr: [] });
ordered.version = recovered.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(ordered), ordered.version, code, recovered.version).changes, 1);
const reordered = await act(2, { type: 'card', card: sapho.id, scope: 'movement',
mode: 'first', event: `movement:${ordered.turn}` });
assert.equal(reordered.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(reordered.movementRemaining, [ids[2], ids[1], ids[0]]);
assert.equal(reordered.active, ids[1]);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, reordered.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const reorderedClaim = await act(0, { type: 'semutaCommit',
event: reordered.pendingTreacheryDiscard!.batch.event });
assert.equal(reorderedClaim.pendingTreacheryDiscard, null);
assert.equal(reorderedClaim.active, ids[2]);
assert.deepEqual(reorderedClaim.movementRemaining, [ids[2], ids[1], ids[0]]);
assert.equal(seat(reorderedClaim, ids[0]).hand.filter(card => card.id === sapho.id).length, 1);
assert.equal(reorderedClaim.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(reorderedClaim.discard.some(card => card.id === sapho.id), false);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(reorderedClaim, auth.playerId));
const arrival = structuredClone(reorderedClaim);
const arrivalSemuta = take(arrival, SEMUTA_DRUG_ID);
const finalFlight = take(arrival, 'richese-ornithopter');
seat(arrival, ids[0]).hand.push(arrivalSemuta);
seat(arrival, ids[1]).hand.push(finalFlight);
const mover = seat(arrival, ids[1]);
mover.reserves += Object.values(mover.forces).reduce((sum, n) => sum + n, 0) - 3;
mover.forces = { 'imperial_basin:10': 3 };
mover.moved = 0;
mover.shipped = true;
Object.assign(arrival, { turn: 8, phase: 5, active: ids[1], storm: 18,
ready: [], decision: null, response: null, phaseOpening: null,
stormPending: null, movementRemaining: [ids[1], ids[0], ids[2]], hajr: [] });
arrival.version = reorderedClaim.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(arrival), arrival.version, code, reorderedClaim.version).changes, 1);
const arrived = await act(1, { type: 'move', movementCard: finalFlight.id,
ornithopter: 'range3', forces: { 'imperial_basin:10': 1 },
territory: 'hagga_basin', sector: 12 });
assert.equal(arrived.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(seat(arrived, ids[1]).forces['hagga_basin:12'], 1);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, arrived.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const finalArrival = await act(0, { type: 'semutaCommit',
event: arrived.pendingTreacheryDiscard!.batch.event });
assert.equal(finalArrival.pendingTreacheryDiscard, null);
assert.equal(finalArrival.active, ids[1]);
assert.equal(seat(finalArrival, ids[1]).moved, 1);
assert.equal(seat(finalArrival, ids[1]).forces['hagga_basin:12'], 1);
assert.equal(seat(finalArrival, ids[0]).hand.filter(card => card.id === finalFlight.id).length, 1);
assert.equal(finalArrival.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(finalArrival.discard.some(card => card.id === finalFlight.id), false);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(finalArrival, auth.playerId));
const battleStart = structuredClone(finalArrival);
const battleSemuta = take(battleStart, SEMUTA_DRUG_ID);
battleStart.richeseCache!.push(take(battleStart, sapho.id));
seat(battleStart, ids[0]).hand.push(battleSemuta);
const weapon = battleStart.deck.splice(
battleStart.deck.findIndex(card => card.kind === 'projectile'), 1)[0];
const shield = battleStart.deck.splice(
battleStart.deck.findIndex(card => card.kind === 'shield'), 1)[0];
const defense = battleStart.deck.splice(
battleStart.deck.findIndex(card => card.kind === 'snooper'), 1)[0];
assert.ok(weapon && shield && defense);
seat(battleStart, ids[1]).hand.push(weapon, shield);
seat(battleStart, ids[2]).hand.push(defense);
for (const id of [ids[1], ids[2]]) {
const p = seat(battleStart, id);
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0) - 5;
p.forces = { 'arrakeen:10': 5 };
for (const leader of p.leaders) leader.strength = 0;
}
Object.assign(battleStart, { turn: 9, phase: 6, active: ids[1], order: [ids[1], ids[2], ids[0]],
ready: [], decision: null, response: null, phaseOpening: null, stormPending: null });
battleStart.version = finalArrival.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(battleStart), battleStart.version, code, finalArrival.version).changes, 1);
let duel = await act(1, { type: 'chooseBattle', territory: 'arrakeen', target: ids[2] });
for (let step = 0; step < 40; step++) {
if (duel.response) {
const p = duel.players.find(p => !duel.response!.passed.includes(p.id))!;
duel = await act(ids.indexOf(p.id), { type: 'passResponse' });
} else if (duel.battle?.preparation)
duel = await act(ids.indexOf(duel.battle.preparation.owner), { type: 'declineBattlePower' });
else break;
}
if (duel.battle?.preLeader && !duel.battle.preLeader.closed)
for (const index of [1, 2])
duel = await act(index, { type: 'battlePreparationReady', event: duel.battle!.preLeader!.event });
duel = await act(1, { type: 'battlePlan', dial: 2, support: 2,
leader: seat(duel, ids[1]).leaders[0].id,
weapon: weapon.id, defense: shield.id });
duel = await act(2, { type: 'battlePlan', dial: 0, support: 0,
leader: seat(duel, ids[2]).leaders[0].id, defense: defense.id });
duel = await act(1, { type: 'traitorCall', call: false });
duel = await act(2, { type: 'traitorCall', call: false });
assert.equal(duel.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
assert.equal(duel.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const mandatoryEvent = duel.pendingTreacheryDiscard!.batch.event;
for (const index of [2, 1, 0])
duel = await act(index, { type: 'semutaPass', event: mandatoryEvent });
assert.equal(duel.decision?.kind, 'battleCards');
const chosen = await act(1, { type: 'decision', discard: [weapon.id, shield.id] });
assert.equal(chosen.pendingTreacheryDiscard?.batch.cause, 'battle:winner');
assert.equal(chosen.pendingTreacheryDiscard?.reaction?.stage, 'offer');
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, chosen.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const committed = await act(0, { type: 'semutaCommit',
event: chosen.pendingTreacheryDiscard!.batch.event });
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, committed.version);
const ownerSelection = await f.restart().readSeatView(code, auths[0]);
const rivalSelection = await f.restart().readSeatView(code, auths[2]);
assert.deepEqual(ownerSelection.semutaReaction?.candidates.map(card => card.id),
[weapon.id, shield.id]);
assert.deepEqual(rivalSelection.semutaReaction?.candidates, []);
const battleClaim = await act(0, { type: 'semutaSelect',
event: chosen.pendingTreacheryDiscard!.batch.event, card: shield.id });
assert.equal(battleClaim.pendingTreacheryDiscard, null);
assert.equal(seat(battleClaim, ids[0]).hand.filter(card => card.id === shield.id).length, 1);
assert.equal(battleClaim.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(battleClaim.discard.filter(card => card.id === weapon.id).length, 1);
assert.equal(battleClaim.discard.some(card => card.id === shield.id), false);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(battleClaim, auth.playerId));
const mandatoryStart = structuredClone(battleClaim);
const heroSemuta = take(mandatoryStart, SEMUTA_DRUG_ID);
mandatoryStart.deck.push(take(mandatoryStart, shield.id));
seat(mandatoryStart, ids[0]).hand.push(heroSemuta);
const hero = mandatoryStart.deck.splice(
mandatoryStart.deck.findIndex(card => card.kind === 'hero'), 1)[0];
const nextWeapon = mandatoryStart.deck.splice(
mandatoryStart.deck.findIndex(card => card.kind === 'projectile'), 1)[0];
const nextDefense = mandatoryStart.deck.splice(
mandatoryStart.deck.findIndex(card => card.kind === 'snooper'), 1)[0];
assert.ok(hero && nextWeapon && nextDefense);
seat(mandatoryStart, ids[1]).hand.push(hero, nextWeapon);
seat(mandatoryStart, ids[2]).hand.push(nextDefense);
for (const id of [ids[1], ids[2]]) {
const p = seat(mandatoryStart, id);
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0) - 5;
p.forces = { 'arrakeen:10': 5 };
for (const leader of p.leaders) leader.strength = 0;
}
Object.assign(mandatoryStart, { turn: 10, phase: 6, active: ids[1],
order: [ids[1], ids[2], ids[0]], ready: [],
decision: null, response: null, phaseOpening: null, stormPending: null,
lastBattle: [], lastBattleContext: null });
mandatoryStart.version = battleClaim.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(mandatoryStart), mandatoryStart.version, code, battleClaim.version).changes, 1);
let mandatoryBattle = await act(1, { type: 'chooseBattle', territory: 'arrakeen', target: ids[2] });
for (let step = 0; step < 40; step++) {
if (mandatoryBattle.response) {
const p = mandatoryBattle.players.find(p => !mandatoryBattle.response!.passed.includes(p.id))!;
mandatoryBattle = await act(ids.indexOf(p.id), { type: 'passResponse' });
} else if (mandatoryBattle.battle?.preparation)
mandatoryBattle = await act(ids.indexOf(mandatoryBattle.battle.preparation.owner),
{ type: 'declineBattlePower' });
else break;
}
if (mandatoryBattle.battle?.preLeader && !mandatoryBattle.battle.preLeader.closed)
for (const index of [1, 2])
mandatoryBattle = await act(index, {
type: 'battlePreparationReady', event: mandatoryBattle.battle!.preLeader!.event,
});
mandatoryBattle = await act(1, { type: 'battlePlan', dial: 2, support: 2,
leader: hero.id, weapon: nextWeapon.id });
mandatoryBattle = await act(2, { type: 'battlePlan', dial: 0, support: 0,
leader: seat(mandatoryBattle, ids[2]).leaders.find(leader => !leader.dead)!.id,
defense: nextDefense.id });
mandatoryBattle = await act(1, { type: 'traitorCall', call: false });
const heroOffer = await act(2, { type: 'traitorCall', call: false });
assert.equal(heroOffer.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard');
assert.equal(heroOffer.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(heroOffer.discard.filter(card => card.id === hero.id).length, 1);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, heroOffer.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const heroClaim = await act(0, { type: 'semutaCommit',
event: heroOffer.pendingTreacheryDiscard!.batch.event });
assert.equal(heroClaim.pendingTreacheryDiscard, null);
assert.equal(heroClaim.decision?.kind, 'battleCards');
assert.equal(seat(heroClaim, ids[1]).hand.some(card => card.id === nextWeapon.id), true);
assert.equal(seat(heroClaim, ids[0]).hand.filter(card => card.id === hero.id).length, 1);
assert.equal(heroClaim.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(heroClaim.discard.some(card => card.id === hero.id), false);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(heroClaim, auth.playerId));
let optional = await act(1, { type: 'decision', discard: [nextWeapon.id] });
assert.equal(optional.pendingTreacheryDiscard?.batch.cause, 'battle:winner');
assert.equal(optional.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const optionalEvent = optional.pendingTreacheryDiscard!.batch.event;
for (const index of [2, 0, 1])
optional = await act(index, { type: 'semutaPass', event: optionalEvent });
assert.equal(optional.pendingTreacheryDiscard, null);
assert.equal(optional.discard.filter(card => card.id === nextWeapon.id).length, 1);
assert.equal(optional.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(seat(optional, ids[0]).hand.filter(card => card.id === hero.id).length, 1);
const mutual = structuredClone(optional);
const mutualSemuta = take(mutual, SEMUTA_DRUG_ID);
mutual.deck.push(take(mutual, hero.id));
seat(mutual, ids[0]).hand.push(mutualSemuta);
const mixedShield = mutual.deck.splice(
mutual.deck.findIndex(card => card.kind === 'shield'), 1)[0];
const mixedSnooper = mutual.deck.splice(
mutual.deck.findIndex(card => card.kind === 'snooper'), 1)[0];
assert.ok(mixedShield && mixedSnooper);
const attacker = seat(mutual, ids[1]), defender = seat(mutual, ids[2]);
attacker.hand.push(mixedShield);
defender.hand.push(mixedSnooper);
for (const p of [attacker, defender]) {
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0) - 5;
p.forces = { 'arrakeen:10': 5 };
for (const leader of p.leaders) leader.strength = 0;
}
const attackerLeader = attacker.leaders.find(leader => !leader.dead)!;
const defenderLeader = defender.leaders.find(leader => !leader.dead)!;
attacker.traitors = [defenderLeader.id];
defender.traitors = [attackerLeader.id];
Object.assign(mutual, { turn: 11, phase: 6, active: ids[1],
order: [ids[1], ids[2], ids[0]], ready: [],
decision: null, response: null, phaseOpening: null, stormPending: null,
lastBattle: [], lastBattleContext: null });
mutual.version = optional.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(mutual), mutual.version, code, optional.version).changes, 1);
let mutualBattle = await act(1, { type: 'chooseBattle', territory: 'arrakeen', target: ids[2] });
for (let step = 0; step < 40; step++) {
if (mutualBattle.response) {
const p = mutualBattle.players.find(p => !mutualBattle.response!.passed.includes(p.id))!;
mutualBattle = await act(ids.indexOf(p.id), { type: 'passResponse' });
} else if (mutualBattle.battle?.preparation)
mutualBattle = await act(ids.indexOf(mutualBattle.battle.preparation.owner),
{ type: 'declineBattlePower' });
else break;
}
if (mutualBattle.battle?.preLeader && !mutualBattle.battle.preLeader.closed)
for (const index of [1, 2])
mutualBattle = await act(index, {
type: 'battlePreparationReady', event: mutualBattle.battle!.preLeader!.event,
});
mutualBattle = await act(1, { type: 'battlePlan', dial: 2, support: 2,
leader: attackerLeader.id, defense: mixedShield.id });
mutualBattle = await act(2, { type: 'battlePlan', dial: 0, support: 0,
leader: defenderLeader.id, defense: mixedSnooper.id });
mutualBattle = await act(1, { type: 'traitorCall', call: true });
const mixedOffer = await act(2, { type: 'traitorCall', call: true });
assert.equal(mixedOffer.pendingTreacheryDiscard?.continuation.kind, 'battleResolved');
assert.equal(mixedOffer.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(mixedOffer.pendingTreacheryDiscard?.batch.entries.map(entry => [
entry.card.id, entry.discardedBy,
]), [[mixedShield.id, ids[1]], [mixedSnooper.id, ids[2]]]);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, mixedOffer.version);
const mixedEvent = mixedOffer.pendingTreacheryDiscard!.batch.event;
const mixedCommit = await act(0, { type: 'semutaCommit', event: mixedEvent });
assert.equal(mixedCommit.pendingTreacheryDiscard?.reaction?.stage, 'select');
assert.deepEqual((await f.restart().readSeatView(code, auths[0])).semutaReaction?.candidates.map(card => card.id),
[mixedShield.id, mixedSnooper.id]);
assert.deepEqual((await f.restart().readSeatView(code, auths[1])).semutaReaction?.candidates, []);
const mixedClaim = await act(0, { type: 'semutaSelect', event: mixedEvent, card: mixedSnooper.id });
assert.equal(mixedClaim.pendingTreacheryDiscard, null);
assert.equal(mixedClaim.lastBattleContext?.winner, null);
assert.equal(seat(mixedClaim, ids[0]).hand.filter(card => card.id === mixedSnooper.id).length, 1);
assert.equal(mixedClaim.discard.filter(card => card.id === mixedShield.id).length, 1);
assert.equal(mixedClaim.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(mixedClaim, auth.playerId));
} finally { f.sqlite.close(); }
});
void test('an authenticated Ix-deck Thumper pause retains the undrawn blow through restart', async () => {
const f = unitStore();
try {
const created = await f.rooms.createRoom('Semuta Thumper QA', 'richese', false, ['choam', 'ix']);
const code = created.view.code;
const second = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
const third = await f.rooms.joinRoom(code, 'Emperor', 'emperor');
const auths = await Promise.all([created.token!, second.token!, third.token!]
.map(token => f.restart().authenticate(code, token)));
const ids = auths.map(auth => auth.playerId);
const act = async (index: number, action: Action) => {
const before = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], before.version, action, clock);
return f.restart().readRoom(code);
};
for (let index = 0; index < auths.length; index++) await act(index, { type: 'ready' });
let game = await f.restart().readRoom(code);
startPrototypeRoom(f.sqlite, code, game.version, 'semuta');
game = await f.restart().readRoom(code);
for (let step = 0; game.status === 'setup' && step < 80; step++) {
let moved = false;
for (let index = 0; index < auths.length; index++) {
const view = viewGame(game, ids[index]);
view.players.find(p => p.id === view.me)!.bot = 'Medium';
const choice = botActions(view)[0];
if (choice) { game = await act(index, choice); moved = true; break; }
}
assert.ok(moved, 'genuine Ix-deck setup needs a legal AI choice');
}
assert.equal(game.status, 'playing');
const originalSeats = f.sqlite.prepare('SELECT * FROM seats').all();
const semuta = take(game, SEMUTA_DRUG_ID), thumper = take(game, 'thumper');
game.deck.push(...game.players.flatMap(p => p.hand));
for (const p of game.players) p.hand = [];
seat(game, ids[0]).hand.push(semuta);
seat(game, ids[1]).hand.push(thumper);
const lands = spiceDeck().filter(card => 'territory' in card);
const prior = lands[0], next = lands[1];
if (!('territory' in prior) || !('territory' in next))
throw Error('Missing real spice territories');
seat(game, ids[2]).forces = { [`${prior.territory}:${prior.sector}`]: 3 };
seat(game, ids[2]).reserves = 17;
Object.assign(game, { turn: 2, phase: 1, active: null, ready: [],
decision: null, response: null, phaseOpening: null,
spiceWindow: null, spiceResolution: null, spiceSequence: null, nexus: false,
spice: { [`${prior.territory}:${prior.sector}`]: 8 },
spiceDiscard: [[prior], []], spiceDeck: [next, ...spiceDeck().filter(card => 'worm' in card)] });
const beforeDeck = structuredClone(game.spiceDeck);
game.version++;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(game), game.version, code, game.version - 1).changes, 1);
const pending = await act(1, { type: 'card', card: thumper.id });
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(pending.spiceDeck, beforeDeck);
assert.equal(seat(pending, ids[2]).tanks, 0);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, pending.version);
const holder = await f.restart().readSeatView(code, auths[0]);
const rival = await f.restart().readSeatView(code, auths[2]);
assert.equal(holder.semutaReaction?.canCommit, true);
assert.equal(rival.semutaReaction?.canCommit, false);
const claimed = await act(0, { type: 'semutaCommit',
event: pending.pendingTreacheryDiscard!.batch.event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(seat(claimed, ids[2]).tanks, 3);
assert.equal(claimed.spiceWindow?.territory, next.territory);
assert.equal(claimed.spiceDeck.length, beforeDeck.length - 1);
assert.equal(seat(claimed, ids[0]).hand.filter(card => card.id === thumper.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(claimed.discard.some(card => card.id === thumper.id), false);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
const bidding = structuredClone(claimed);
const again = take(bidding, SEMUTA_DRUG_ID);
bidding.deck.push(take(bidding, thumper.id));
seat(bidding, ids[0]).hand.push(again);
const amal = take(bidding, 'amal');
seat(bidding, ids[1]).hand.push(amal);
seat(bidding, ids[0]).spice = 11;
seat(bidding, ids[1]).spice = 9;
seat(bidding, ids[2]).spice = 7;
Object.assign(bidding, { turn: 3, phase: 2, active: null, ready: [],
decision: null, response: null, phaseOpening: null, auction: null,
spiceWindow: null, spiceResolution: null, spiceSequence: null, nexus: false });
bidding.version = claimed.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(bidding), bidding.version, code, claimed.version).changes, 1);
for (let index = 0; index < auths.length; index++)
await act(index, { type: 'ready' });
const opening = await f.restart().readRoom(code);
assert.equal(opening.phase, 3);
assert.ok(opening.phaseOpening);
assert.deepEqual((await act(2, { type: 'ready' })).phaseOpening?.passed, [ids[2]]);
const deckBefore = structuredClone(opening.deck);
const offeredAmal = await act(1, { type: 'card', card: amal.id });
assert.equal(offeredAmal.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.deepEqual(offeredAmal.players.map(p => p.spice), [5, 4, 3]);
assert.deepEqual(offeredAmal.deck, deckBefore);
assert.equal(offeredAmal.phaseOpening, null);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, offeredAmal.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const recoveredAmal = await act(0, { type: 'semutaCommit',
event: offeredAmal.pendingTreacheryDiscard!.batch.event });
assert.equal(recoveredAmal.pendingTreacheryDiscard, null);
assert.deepEqual(recoveredAmal.phaseOpening?.passed, []);
assert.deepEqual(recoveredAmal.players.map(p => p.spice), [5, 4, 3]);
assert.equal(seat(recoveredAmal, ids[0]).hand.filter(card => card.id === amal.id).length, 1);
assert.equal(recoveredAmal.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
for (let index = 0; index < auths.length; index++)
await act(index, { type: 'ready' });
const resumedAmal = await f.restart().readRoom(code);
assert.equal(resumedAmal.phaseOpening, null);
assert.equal(resumedAmal.richeseBidding?.turn, resumedAmal.turn);
assert.deepEqual(resumedAmal.players.map(p => p.spice), [5, 4, 3]);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(resumedAmal, auth.playerId));
} finally { f.sqlite.close(); }
});
void test('authenticated pre-plan Sapho discard restores one aggressor choice before battle plans', async () => {
const f = unitStore();
try {
const created = await f.rooms.createRoom('Semuta Sapho QA', 'emperor', false, ['choam']);
const code = created.view.code;
const joined = [
await f.rooms.joinRoom(code, 'Guild', 'guild'),
await f.rooms.joinRoom(code, 'Atreides', 'atreides'),
await f.rooms.joinRoom(code, 'Richese', 'richese'),
];
const auths = await Promise.all([created.token!, ...joined.map(row => row.token!)]
.map(token => f.restart().authenticate(code, token)));
const ids = auths.map(auth => auth.playerId);
const originalSeats = f.sqlite.prepare('SELECT * FROM seats').all();
const initial = await f.restart().readRoom(code);
const battle = saphoAggressorGame({
semutaPreview: true, seatIds: ids as [string, string, string, string],
});
takeSaphoBattleCard(battle, ids[3], SEMUTA_DRUG_ID);
battle.code = code;
battle.version = initial.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(battle), battle.version, code, initial.version).changes, 1);
const act = async (index: number, action: Action) => {
const before = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], before.version, action, clock);
return f.restart().readRoom(code);
};
const option = aggressorAction(battle, ids[1]);
const offered = await act(1, option);
assert.equal(offered.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(offered.battle?.preLeader?.closed, false);
assert.equal(offered.battle?.saphoAggressor?.uses.length, 1);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, offered.version);
const holder = await f.restart().readSeatView(code, auths[3]);
const rival = await f.restart().readSeatView(code, auths[0]);
assert.equal(holder.semutaReaction?.canCommit, true);
assert.equal(rival.semutaReaction?.canCommit, false);
const claimed = await act(3, { type: 'semutaCommit',
event: offered.pendingTreacheryDiscard!.batch.event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.battle?.event, battle.battle?.event);
assert.equal(claimed.battle?.saphoAggressor?.uses.length, 1);
assert.equal(seat(claimed, ids[3]).hand.filter(card => card.id === option.card).length, 1);
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
let prepared = claimed;
for (const index of [0, 1])
prepared = await act(index, { type: 'battlePreparationReady', event: claimed.battle!.event });
assert.equal(prepared.battle?.preLeader?.closed, true);
assert.equal(prepared.battle?.saphoAggressor?.uses.length, 1);
} finally { f.sqlite.close(); }
});
void test('authenticated private Ixian ally discard waits for Semuta before its replacement', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Ix ally', 'richese', false, ['choam', 'ix']);
const code = made.view.code;
const buyer = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
const ixians = await f.rooms.joinRoom(code, 'Ixians', 'ixians');
const auths = await Promise.all([made.token!, buyer.token!, ixians.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
const g = createGame(code, newPlayer(auths[0].playerId, 'Richese', 'richese'),
false, ['choam', 'ix']);
g.players.push(newPlayer(auths[1].playerId, 'Atreides', 'atreides'),
newPlayer(auths[2].playerId, 'Ixians', 'ixians'));
Object.assign(g, { status: 'playing', phase: 3, active: auths[1].playerId,
order: auths.map(auth => auth.playerId), deck: treacheryDeck(['ix']),
richeseCache: richeseCards(), semutaPreview: true });
for (const p of g.players) {
p.hand = [];
p.spice = 20;
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
p.traitorChoices = [];
}
const semuta = take(g, SEMUTA_DRUG_ID), lot = g.deck.shift()!;
seat(g, auths[0].playerId).hand.push(semuta);
seat(g, auths[1].playerId).ally = auths[2].playerId;
seat(g, auths[2].playerId).ally = auths[1].playerId;
g.auction = { cards: [lot], index: 0, bid: 0, bidder: null,
active: auths[1].playerId, passed: [], opener: 0 };
g.richeseBidding = { owner: auths[0].playerId, event: 'richese-normal:1',
turn: g.turn, stage: 'normal', position: 'last', normalCount: 1,
blackMarketSold: false, cacheCanceled: false, opener: 0 };
g.richeseFunding = {};
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const current = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], current.version, action, clock);
return f.restart().readRoom(code);
};
await act(1, { type: 'bid', amount: 2 });
await act(2, { type: 'passBid' });
let next = await act(0, { type: 'passBid' });
if (next.decision?.kind === 'auctionPayment')
next = await act(1, { type: 'decision', karama: false });
assert.equal(next.decision?.kind, 'ixAllyCard');
next = await act(1, { type: 'decision', accept: true });
for (let i = 0; next.response?.kind === 'ixAllyCard' && i < 3; i++) {
const index = auths.findIndex(auth => !next.response!.passed.includes(auth.playerId));
next = await act(index, { type: 'passResponse' });
}
const event = next.pendingTreacheryDiscard!.batch.event;
assert.equal(next.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(next.pendingTreacheryDiscard?.batch.entries[0].publicFace, false);
assert.equal(next.discard.filter(card => card.id === lot.id).length, 1);
const deckTop = next.deck[0]?.id;
const buyerView = await f.restart().readSeatView(code, auths[1]);
assert.equal(buyerView.semutaReaction?.canCommit, false);
assert.deepEqual(buyerView.semutaReaction?.candidates, []);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, next.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const claimed = await act(0, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.auction, null);
assert.equal(claimed.richeseBidding?.stage, 'cacheOffer');
assert.equal(claimed.decision?.kind, 'richeseCache');
assert.equal(seat(claimed, auths[0].playerId).hand.filter(card => card.id === lot.id).length, 1);
assert.equal(seat(claimed, auths[1].playerId).hand.filter(card => card.id === deckTop).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
await assert.rejects(f.restart().act(code, auths[0], next.version,
{ type: 'semutaCommit', event }, clock));
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
} finally { f.sqlite.close(); }
});
void test('authenticated proactive La La La Semuta offer retains the paid-revival block', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta La La La', 'choam', false, ['choam']);
const code = made.view.code;
const richese = await f.rooms.joinRoom(code, 'Richese', 'richese');
const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit');
const auths = await Promise.all([made.token!, richese.token!, bg.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
const g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), false, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'),
newPlayer(auths[2].playerId, 'BG', 'beneGesserit'));
Object.assign(g, { status: 'playing', phase: 4, turn: 2, active: auths[0].playerId,
order: auths.map(auth => auth.playerId), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true,
revivalRules: newRevivalRules() });
for (const p of g.players) {
p.spice = 10;
p.tanks = 6;
p.reserves = 14;
p.hand = [];
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
p.traitorChoices = [];
}
const semuta = take(g, SEMUTA_DRUG_ID), karama = take(g, 'karama');
const index = g.deck.findIndex(card => card.name === 'La La La');
assert.ok(index >= 0);
const used = g.deck.splice(index, 1)[0];
g.players[0].hand.push(used);
g.players[1].hand.push(semuta);
g.players[2].hand.push(karama);
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const current = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], current.version, action, clock);
return f.restart().readRoom(code);
};
let current = await act(0, {
type: 'card', mode: 'choam', card: used.id, target: auths[1].playerId,
});
assert.equal(current.response?.kind, 'choamWorthless');
for (let i = 0; current.response?.kind === 'choamWorthless' && i < 3; i++) {
const index = auths.findIndex(auth =>
!viewGame(current, auth.playerId).responseControls?.hasPassed &&
!!viewGame(current, auth.playerId).responseControls?.cancelCards.length);
assert.ok(index >= 0);
current = await act(index, { type: 'passResponse' });
}
const event = current.pendingTreacheryDiscard!.batch.event;
assert.equal(current.pendingTreacheryDiscard?.continuation.kind, 'choamLaLaLaDiscard');
assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.ok(!current.revivalRules?.freeBlocked?.includes(auths[1].playerId));
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, current.version);
assert.equal((await f.restart().readSeatView(code, auths[1])).semutaReaction?.canCommit, true);
const claimed = await act(1, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.deepEqual(claimed.revivalRules?.freeBlocked, [auths[1].playerId]);
assert.equal(claimed.players[1].hand.filter(card => card.id === used.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
await assert.rejects(f.restart().act(code, auths[1], current.version,
{ type: 'semutaCommit', event }, clock));
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
const revived = await act(1, { type: 'revive', amount: 1 });
assert.equal(revived.players[1].spice, 8);
assert.equal(revived.players[1].revived, 1);
} finally { f.sqlite.close(); }
});
void test('authenticated Kulon Semuta offer restores one movement bonus across restart', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Kulon', 'choam', false, ['choam']);
const code = made.view.code;
const richese = await f.rooms.joinRoom(code, 'Richese', 'richese');
const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit');
const auths = await Promise.all([made.token!, richese.token!, bg.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
const g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), false, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'),
newPlayer(auths[2].playerId, 'BG', 'beneGesserit'));
Object.assign(g, { status: 'playing', phase: 5, turn: 2, storm: 18,
active: auths[0].playerId, order: auths.map(auth => auth.playerId),
movementRemaining: auths.map(auth => auth.playerId), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true });
for (const p of g.players) {
p.spice = 10;
p.hand = [];
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
p.traitorChoices = [];
}
const semuta = take(g, SEMUTA_DRUG_ID), karama = take(g, 'karama');
const index = g.deck.findIndex(card => card.name === 'Kulon');
assert.ok(index >= 0);
const kulon = g.deck.splice(index, 1)[0];
g.players[0].hand.push(kulon);
g.players[0].forces = { 'red_chasm:7': 3 };
g.players[0].reserves = 11;
g.players[1].hand.push(semuta);
g.players[2].hand.push(karama);
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const current = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], current.version, action, clock);
return f.restart().readRoom(code);
};
let current = await act(0, { type: 'card', mode: 'choam', card: kulon.id });
assert.equal(current.response?.kind, 'choamWorthless');
for (let i = 0; current.response?.kind === 'choamWorthless' && i < 3; i++) {
const index = auths.findIndex(auth =>
!viewGame(current, auth.playerId).responseControls?.hasPassed &&
!!viewGame(current, auth.playerId).responseControls?.cancelCards.length);
assert.ok(index >= 0);
current = await act(index, { type: 'passResponse' });
}
const event = current.pendingTreacheryDiscard!.batch.event;
assert.equal(current.pendingTreacheryDiscard?.continuation.kind, 'choamKulonDiscard');
assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(current.choamMovement, undefined);
assert.equal(current.discard.filter(card => card.id === kulon.id).length, 1);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, current.version);
assert.equal((await f.restart().readSeatView(code, auths[1])).semutaReaction?.canCommit, true);
const claimed = await act(1, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.deepEqual(claimed.choamMovement, { turn: 2, bonus: 1 });
assert.equal(claimed.players[1].hand.filter(card => card.id === kulon.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
await assert.rejects(f.restart().act(code, auths[1], current.version,
{ type: 'semutaCommit', event }, clock));
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
} finally { f.sqlite.close(); }
});
void test('authenticated CHOAM sale retains paid spice and market after a saved Semuta claim', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Sale', 'choam', false, ['choam']);
const code = made.view.code;
const richese = await f.rooms.joinRoom(code, 'Richese', 'richese');
const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit');
const auths = await Promise.all([made.token!, richese.token!, bg.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
let g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), false, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'),
newPlayer(auths[2].playerId, 'BG', 'beneGesserit'));
Object.assign(g, { status: 'playing', phase: 2, order: auths.map(auth => auth.playerId),
deck: baseDeck(), richeseCache: richeseCards(), semutaPreview: true,
choamCharity: { turn: 1, canceled: false } });
for (const p of g.players) {
p.spice = 20;
p.hand = [];
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
p.traitorChoices = [];
}
const semuta = take(g, SEMUTA_DRUG_ID), karama = take(g, 'karama');
const index = g.deck.findIndex(card => card.name === 'Baliset');
assert.ok(index >= 0);
const sold = g.deck.splice(index, 1)[0];
g.players[0].hand.push(sold);
g.players[1].hand.push(semuta);
g.players[2].hand.push(karama);
for (const p of g.players)
g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'choamMarket');
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const current = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], current.version, action, clock);
return f.restart().readRoom(code);
};
let current = await act(0, { type: 'decision', mode: 'sell', card: sold.id });
assert.equal(current.response?.kind, 'choamSale');
for (let i = 0; current.response?.kind === 'choamSale' && i < 3; i++) {
const index = auths.findIndex(auth =>
!viewGame(current, auth.playerId).responseControls?.hasPassed &&
!!viewGame(current, auth.playerId).responseControls?.cancelCards.length);
assert.ok(index >= 0);
current = await act(index, { type: 'passResponse' });
}
const event = current.pendingTreacheryDiscard!.batch.event;
assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(current.players[0].spice, 22);
assert.equal(current.choamMarket?.sale, undefined);
assert.equal(current.decision, null);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, current.version);
const ownerView = await f.restart().readSeatView(code, auths[1]);
assert.equal(ownerView.semutaReaction?.canCommit, true);
assert.deepEqual((await f.restart().readSeatView(code, auths[0])).semutaReaction?.candidates, []);
const claimed = await act(1, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.decision?.kind, 'choamMarket');
assert.equal(claimed.players[0].spice, 22);
assert.equal(claimed.players[1].hand.filter(card => card.id === sold.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
await assert.rejects(f.restart().act(code, auths[1], current.version,
{ type: 'semutaCommit', event }, clock));
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
const resumed = await act(0, { type: 'decision', done: true });
assert.equal(resumed.phase, 3);
assert.equal(resumed.players[0].spice, 22);
} finally { f.sqlite.close(); }
});
void test('authenticated printed Karama auction payment resumes one saved Richese cache offer', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Payment', 'richese', false, ['choam']);
const code = made.view.code;
const buyer = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
const third = await f.rooms.joinRoom(code, 'Emperor', 'emperor');
const auths = await Promise.all([made.token!, buyer.token!, third.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
const g = createGame(code, newPlayer(auths[0].playerId, 'Richese', 'richese'), false, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Atreides', 'atreides'),
newPlayer(auths[2].playerId, 'Emperor', 'emperor'));
Object.assign(g, { status: 'playing', phase: 3, active: auths[1].playerId,
order: auths.map(auth => auth.playerId), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true });
for (const p of g.players) {
p.hand = [];
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
p.traitorChoices = [];
}
const karama = take(g, 'karama'), semuta = take(g, SEMUTA_DRUG_ID);
const lot = g.deck.shift()!;
seat(g, auths[1].playerId).hand.push(karama);
seat(g, auths[0].playerId).hand.push(semuta);
g.auction = { cards: [lot], index: 0, bid: 0, bidder: null,
active: auths[1].playerId, passed: [], opener: 0 };
g.richeseBidding = { owner: auths[0].playerId, event: 'richese-normal:1',
turn: g.turn, stage: 'normal', position: 'last', normalCount: 1,
blackMarketSold: false, cacheCanceled: false, opener: 0 };
g.richeseFunding = {};
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const state = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], state.version, action, clock);
return f.restart().readRoom(code);
};
const initialSpice = seat(g, auths[1].playerId).spice;
await act(1, { type: 'bid', amount: initialSpice + 2 });
await act(2, { type: 'passBid' });
const payment = await act(0, { type: 'passBid' });
assert.equal(payment.decision?.kind, 'auctionPayment');
const pending = await act(1, { type: 'decision', karama: true, card: karama.id });
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'karamaPaymentDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.auction?.cards[0].id, lot.id);
assert.equal(seat(pending, auths[1].playerId).spice, initialSpice);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, pending.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const event = pending.pendingTreacheryDiscard!.batch.event;
const claimed = await act(0, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.auction, null);
assert.equal(claimed.richeseBidding?.stage, 'cacheOffer');
assert.equal(claimed.decision?.kind, 'richeseCache');
assert.equal(seat(claimed, auths[1].playerId).spice, initialSpice);
assert.equal(seat(claimed, auths[1].playerId).hand.filter(card => card.id === lot.id).length, 1);
assert.equal(seat(claimed, auths[0].playerId).hand.filter(card => card.id === karama.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
await assert.rejects(f.restart().act(code, auths[0], pending.version,
{ type: 'semutaCommit', event }, clock));
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
} finally { f.sqlite.close(); }
});
void test('authenticated printed Karama purchase preserves the lot across a saved Semuta claim', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Purchase', 'richese', false, ['choam']);
const code = made.view.code;
const buyer = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
const third = await f.rooms.joinRoom(code, 'Emperor', 'emperor');
const auths = await Promise.all([made.token!, buyer.token!, third.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
const g = createGame(code, newPlayer(auths[0].playerId, 'Richese', 'richese'), false, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Atreides', 'atreides'),
newPlayer(auths[2].playerId, 'Emperor', 'emperor'));
Object.assign(g, { status: 'playing', phase: 3, active: auths[1].playerId,
order: auths.map(auth => auth.playerId), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true });
for (const p of g.players) {
p.hand = [];
p.traitors = p.leaders.length ? [p.leaders[0].id] : [];
p.traitorChoices = [];
}
const karama = take(g, 'karama'), semuta = take(g, SEMUTA_DRUG_ID);
const lot = g.deck.shift()!;
seat(g, auths[1].playerId).hand.push(karama);
seat(g, auths[0].playerId).hand.push(semuta);
g.auction = { cards: [lot], index: 0, bid: 0, bidder: null,
active: auths[1].playerId, passed: [], opener: 0 };
g.richeseBidding = { owner: auths[0].playerId, event: 'richese-normal:1',
turn: g.turn, stage: 'normal', position: 'last', normalCount: 1,
blackMarketSold: false, cacheCanceled: false, opener: 0 };
g.richeseFunding = {};
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
await f.restart().act(code, auths[1], g.version,
{ type: 'card', card: karama.id, mode: 'purchase' }, clock);
const pending = await f.restart().readRoom(code);
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.auction?.cards[0].id, lot.id);
assert.equal(seat(pending, auths[1].playerId).hand.some(card => card.id === lot.id), false);
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, pending.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
const event = pending.pendingTreacheryDiscard!.batch.event;
await f.restart().act(code, auths[0], pending.version,
{ type: 'semutaCommit', event }, clock);
const claimed = await f.restart().readRoom(code);
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(seat(claimed, auths[1].playerId).hand.filter(card => card.id === lot.id).length, 1);
assert.equal(seat(claimed, auths[0].playerId).hand.filter(card => card.id === karama.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
assert.equal(claimed.auction, null);
assert.equal(claimed.richeseBidding?.stage, 'cacheOffer');
assert.equal(claimed.decision?.kind, 'richeseCache');
await assert.rejects(f.restart().act(code, auths[0], pending.version,
{ type: 'semutaCommit', event }, clock));
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
} finally { f.sqlite.close(); }
});
void test('authenticated printed Karama cost pauses and restores one canceled CHOAM Charity response', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Charity', 'choam', false, ['choam']);
const code = made.view.code;
const richese = await f.rooms.joinRoom(code, 'Richese', 'richese');
const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit');
const auths = await Promise.all([made.token!, richese.token!, bg.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
let g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), false, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'),
newPlayer(auths[2].playerId, 'BG', 'beneGesserit'));
Object.assign(g, {
status: 'playing', phase: 1, nexus: true,
order: g.players.map(p => p.id), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true,
});
for (const player of g.players) {
player.spice = 0;
player.hand = [];
player.traitors = player.leaders.length ? [player.leaders[0].id] : [];
player.traitorChoices = [];
}
const karama = take(g, 'karama');
g.players[1].hand.push(karama);
const semuta = take(g, SEMUTA_DRUG_ID);
g.players[2].hand.push(semuta);
for (const player of g.players)
g = applyAction(g, player.id, { type: 'ready' });
while (g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
assert.equal(g.response?.kind, 'choamCharity');
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const before = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], before.version, action, clock);
return f.restart().readRoom(code);
};
const pending = await act(1, { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.choamCharity, undefined);
const event = pending.pendingTreacheryDiscard!.batch.event;
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, pending.version);
assert.equal((await f.restart().readSeatView(code, auths[2])).semutaReaction?.canCommit, true);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, false);
const claimed = await act(2, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.deepEqual(claimed.choamCharity, { turn: 1, canceled: true });
assert.equal(claimed.players[0].spice, 0);
assert.equal(claimed.players[2].hand.filter(card => card.id === karama.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
} finally { f.sqlite.close(); }
});
void test('authenticated printed Karama cost suspends CHOAM Inflation placement across restart', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta Inflation', 'choam', false, ['choam']);
const code = made.view.code;
const richese = await f.rooms.joinRoom(code, 'Richese', 'richese');
const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit');
const auths = await Promise.all([made.token!, richese.token!, bg.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
let g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), false, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'),
newPlayer(auths[2].playerId, 'BG', 'beneGesserit'));
Object.assign(g, {
status: 'playing', phase: 8, turn: 2,
order: g.players.map(p => p.id), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true,
});
for (const player of g.players) {
player.spice = 20;
player.hand = [];
player.traitorChoices = [];
}
const karama = take(g, 'karama');
g.players[1].hand.push(karama);
const semuta = take(g, SEMUTA_DRUG_ID);
g.players[2].hand.push(semuta);
g = applyAction(g, auths[0].playerId, { type: 'choamInflation', side: 'double' });
assert.equal(g.response?.kind, 'choamInflation');
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const before = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], before.version, action, clock);
return f.restart().readRoom(code);
};
const pending = await act(1, { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(pending.inflation, undefined);
assert.equal(pending.inflationAttemptTurn, 2);
const event = pending.pendingTreacheryDiscard!.batch.event;
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, pending.version);
assert.equal((await f.restart().readSeatView(code, auths[2])).semutaReaction?.canCommit, true);
const claimed = await act(2, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.equal(claimed.inflation, undefined);
assert.equal(claimed.inflationAttemptTurn, 2);
assert.equal(claimed.players[2].hand.filter(card => card.id === karama.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
} finally { f.sqlite.close(); }
});
void test('authenticated BG Charity Karama cost restores one canceled extra allowance', async () => {
const f = unitStore();
try {
const made = await f.rooms.createRoom('Semuta BG Charity', 'choam', true, ['choam']);
const code = made.view.code;
const richese = await f.rooms.joinRoom(code, 'Richese', 'richese');
const bg = await f.rooms.joinRoom(code, 'BG', 'beneGesserit');
const auths = await Promise.all([made.token!, richese.token!, bg.token!]
.map(token => f.restart().authenticate(code, token)));
const existing = await f.restart().readRoom(code);
let g = createGame(code, newPlayer(auths[0].playerId, 'CHOAM', 'choam'), true, ['choam']);
g.players.push(newPlayer(auths[1].playerId, 'Richese', 'richese'),
newPlayer(auths[2].playerId, 'BG', 'beneGesserit'));
Object.assign(g, {
status: 'playing', phase: 1, nexus: true,
order: g.players.map(p => p.id), deck: baseDeck(),
richeseCache: richeseCards(), semutaPreview: true,
});
for (const player of g.players) {
player.spice = 0;
player.hand = [];
player.traitors = player.leaders.length ? [player.leaders[0].id] : [];
player.traitorChoices = [];
}
const karama = take(g, 'karama');
g.players[1].hand.push(karama);
const semuta = take(g, SEMUTA_DRUG_ID);
g.players[0].hand.push(semuta);
for (const player of g.players)
g = applyAction(g, player.id, { type: 'ready' });
while (g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
while (g.response) {
const owner = g.players.find(p => {
const controls = viewGame(g, p.id).responseControls;
return controls?.cancelCards.length && !controls.hasPassed;
});
assert.ok(owner);
g = applyAction(g, owner.id, { type: 'passResponse' });
}
assert.deepEqual(g.choamCharity, { turn: 1, canceled: false });
g.players[2].spice = 12;
g = applyAction(g, auths[2].playerId, { type: 'charity' });
assert.equal(g.response?.kind, 'bgCharity');
const balances = g.players.map(p => p.spice);
g.version = existing.version + 1;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(g), g.version, code, existing.version).changes, 1);
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const act = async (index: number, action: Action) => {
const before = await f.restart().readRoom(code);
await f.restart().act(code, auths[index], before.version, action, clock);
return f.restart().readRoom(code);
};
const pending = await act(1, { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const event = pending.pendingTreacheryDiscard!.batch.event;
await f.restart().continueRoomAutomatic(code, clock);
assert.equal((await f.restart().readRoom(code)).version, pending.version);
assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true);
assert.equal((await f.restart().readSeatView(code, auths[2])).semutaReaction?.canCommit, false);
const claimed = await act(0, { type: 'semutaCommit', event });
assert.equal(claimed.pendingTreacheryDiscard, null);
assert.deepEqual(claimed.players.map(p => p.spice), balances);
assert.equal(claimed.players[0].hand.filter(card => card.id === karama.id).length, 1);
assert.equal(claimed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
assert.equal(viewGame(claimed, auths[2].playerId).players[2].charityClaimed, true);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of auths)
assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(claimed, auth.playerId));
} finally { f.sqlite.close(); }
});