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 { TERRITORIES, gameDistance, splitLocation } from '../game/board'; 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(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 }); // The winner's cheap hero and the loser's used defense form separate frames: // the mandatory battle batch settles first, then the winner's own compulsory // discard. The Semuta claim under test targets that second frame. assert.equal(heroOffer.pendingTreacheryDiscard?.continuation.kind, 'battleResolved'); assert.equal(heroOffer.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory'); assert.equal(heroOffer.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(heroOffer.discard.filter(card => card.id === nextDefense.id).length, 1); let heroFrame = heroOffer; { const mandatoryEvent = heroOffer.pendingTreacheryDiscard!.batch.event; for (const index of [2, 1, 0]) heroFrame = await act(index, { type: 'semutaPass', event: mandatoryEvent }); } assert.equal(heroFrame.pendingTreacheryDiscard?.continuation.kind, 'winnerMandatoryDiscard'); assert.equal(heroFrame.pendingTreacheryDiscard?.batch.cause, 'battle:winnerMandatory'); assert.equal(heroFrame.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(heroFrame.discard.filter(card => card.id === hero.id).length, 1); await f.restart().continueRoomAutomatic(code, clock); assert.equal((await f.restart().readRoom(code)).version, heroFrame.version); assert.equal((await f.restart().readSeatView(code, auths[0])).semutaReaction?.canCommit, true); const heroClaim = await act(0, { type: 'semutaCommit', event: heroFrame.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 proactive Baliset Semuta claim restores only the selected movement restriction', async () => { const f = unitStore(); try { const made = await f.rooms.createRoom('Semuta Baliset', '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[1].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 === 'Baliset'); assert.ok(index >= 0); const used = g.deck.splice(index, 1)[0]; const key = TERRITORIES.flatMap(t => t.sectors.map(s => `${t.id}:${s}`)) .find(key => splitLocation(key).territory !== 'red_chasm' && splitLocation(key).sector !== 18 && gameDistance(g, 'red_chasm:7', key, k => splitLocation(k).sector === 18) === 1)!; assert.ok(key); const location = splitLocation(key); g.players[0].hand.push(used); g.players[0].forces = { [key]: 1 }; g.players[0].reserves = 19; g.players[1].hand.push(semuta); g.players[1].forces = { 'red_chasm:7': 3 }; g.players[1].reserves = 17; 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, territory: location.territory, }); 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, 'choamBalisetDiscard'); assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(current.choamBaliset, undefined); assert.equal(current.discard.filter(card => card.id === used.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.choamBaliset, [{ turn: 2, player: auths[1].playerId, territory: location.territory }]); 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 before = await f.restart().readRoom(code); await assert.rejects(f.restart().act(code, auths[1], before.version, { type: 'move', from: 'red_chasm:7', amount: 1, ...location, }, clock), /Baliset prevents/); assert.deepEqual(await f.restart().readRoom(code), before); } finally { f.sqlite.close(); } }); void test('authenticated full-hand Trip Semuta exchange returns one force after saved offer', async () => { const f = unitStore(); try { const made = await f.rooms.createRoom('Semuta Trip', '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: 8, turn: 2, storm: 18, active: auths[0].playerId, order: 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 === 'Trip to Gamont'); 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[1].hand.push(...g.deck.splice(0, 3)); g.players[1].forces = { 'arrakeen:10': 1 }; g.players[1].reserves = 19; 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, from: 'arrakeen:10', elite: 0, }); 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, 'choamGamontDiscard'); assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(current.players[1].forces['arrakeen:10'], 1); assert.equal(current.players[1].reserves, 19); assert.equal(current.discard.filter(card => card.id === used.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.equal(claimed.players[1].forces['arrakeen:10'], undefined); assert.equal(claimed.players[1].reserves, 20); assert.equal(claimed.players[1].hand.filter(card => card.id === used.id).length, 1); assert.equal(claimed.players[1].hand.length, 4); 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 Jubba Semuta claim protects CHOAM before one saved storm', async () => { const f = unitStore(); try { const made = await f.rooms.createRoom('Semuta Jubba', '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: 0, turn: 2, storm: 5, stormPending: 3, active: auths[0].playerId, order: auths.map(auth => auth.playerId), deck: baseDeck(), richeseCache: richeseCards(), spice: { 'red_chasm:7': 8 }, semutaPreview: true }); for (const p of g.players) { p.spice = 10; p.hand = []; p.forces = {}; p.reserves = 20; 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 === 'Jubba Cloak'); assert.ok(index >= 0); const used = g.deck.splice(index, 1)[0]; g.players[0].hand.push(used); g.players[0].forces = { 'red_chasm:7': 4 }; g.players[0].reserves = 16; g.players[1].hand.push(semuta); g.players[1].forces = { 'red_chasm:7': 3 }; g.players[1].reserves = 17; 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 f.restart().readRoom(code); for (let i = 0; i < auths.length; i++) current = await act(i, { type: 'ready' }); assert.equal(current.decision?.kind, 'choamStorm'); current = await act(0, { type: 'card', mode: 'choam', card: used.id, territory: 'red_chasm' }); 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, 'choamJubbaDiscard'); assert.equal(current.pendingTreacheryDiscard?.reaction?.stage, 'offer'); assert.equal(current.storm, 5); assert.equal(current.stormResolution?.choamProtected, undefined); assert.equal(current.players[0].forces['red_chasm:7'], 4); assert.equal(current.players[1].forces['red_chasm:7'], 3); 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.equal(claimed.stormResolution, null); assert.equal(claimed.storm, 8); assert.equal(claimed.players[0].forces['red_chasm:7'], 4); assert.equal(claimed.players[1].forces['red_chasm:7'], undefined); assert.equal(claimed.players[1].tanks, 3); 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)); } 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(); } });