import test from 'node:test'; import assert from 'node:assert/strict'; import { startPrototypeRoom } from '../tools/prototype-room'; import { botActions } from '../game/bots'; import { viewGame, type Game, type Action } from '../game/engine'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { unitStore } from './fixture-nexus-room-store'; 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 }); 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(); } });