Files
Dune/tests/nexus-richese-betrayal-recovery.test.ts

501 lines
24 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
import { viewGame, type Action, type Game } from '../game/engine';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import type { RoomsClock, SeatAuth } from '../db/rooms';
import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import {
createRicheseBetrayalFixture,
RICHESE_BETRAYAL_FIXTURE_FACTIONS,
type RicheseBetrayalFixture,
type RicheseBetrayalFixtureOptions,
} from './fixture-richese-betrayal';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
type Kind = 'purchase' | 'sale';
type Options = RicheseBetrayalFixtureOptions;
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
const player = (g: Game, id: string) => {
const found = g.players.find(p => p.id === id);
assert.ok(found);
return found;
};
function physical(g: Game) {
const ids = [
...g.deck, ...g.discard, ...(g.richeseCache ?? []),
...(g.richeseRemoved ?? []), ...g.players.flatMap(p => p.hand),
...(g.auction?.cards.slice(g.auction.index) ?? []),
].map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'physical Treachery custody is unique');
return ids;
}
function nexusInventory(g: Game) {
assert.ok(g.nexusCards?.cards);
const cards = g.nexusCards.cards;
validateNexusCards(cards, g.players);
assert.deepEqual([
...cards.deck, ...cards.discard,
...Object.values(cards.hands).filter(card => card !== null),
].sort(), [...NEXUS_FACTIONS].sort());
}
function credentials(sqlite: DatabaseSync) {
return {
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(),
receipts: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(),
};
}
async function fixture(t: test.TestContext, kind: Kind, options: Options = {}): Promise<Fixture> {
const store = unitStore();
t.after(() => store.sqlite.close());
const [host, ...rest] = RICHESE_BETRAYAL_FIXTURE_FACTIONS;
const made = await store.rooms.createRoom('Richese Betrayal SQL', host, options.advanced ?? true, ['choam']);
const code = made.view.code;
const tokens = [made.token];
for (const faction of rest)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const seatIds = auths.map(auth => auth.playerId);
const madeGame = createRicheseBetrayalFixture(kind, { ...options, seatIds });
const initial = madeGame.game;
initial.code = code;
initial.version = (await store.rooms.readRoom(code)).version;
const save = (g: Game) => store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code);
save(initial);
const unrelated = await store.rooms.createRoom('Unrelated preserved room', 'atreides', false, []);
const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(unrelated.view.code);
const credentialRows = credentials(store.sqlite);
const stock = physical(initial);
nexusInventory(initial);
store.writes.length = 0;
return { ...store, ...madeGame, code, tokens, auths, initial, save, stock, credentialRows,
unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token, unrelatedRow };
}
type SqlRow = Record<string, SQLOutputValue>;
interface Fixture extends RicheseBetrayalFixture {
code: string;
tokens: string[];
auths: SeatAuth[];
initial: Game;
save(g: Game): void;
stock: string[];
credentialRows: { seats: SqlRow[]; keys: SqlRow[]; receipts: SqlRow[] };
unrelatedCode: string;
unrelatedToken: string;
unrelatedRow: SqlRow | undefined;
sqlite: DatabaseSync;
restart(): typeof Rooms;
hooks: { beforeWrite?: () => Promise<void> };
writes: { expected: number; changes: number }[];
}
const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.code);
function protectedRows(f: Fixture) {
assert.deepEqual(credentials(f.sqlite), f.credentialRows);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.unrelatedCode), f.unrelatedRow);
}
function inventory(f: Fixture, g: Game) {
assert.deepEqual(physical(g), f.stock);
nexusInventory(g);
protectedRows(f);
}
async function authenticate(f: Fixture, id: string) {
const index = f.auths.findIndex(auth => auth.playerId === id);
assert.ok(index >= 0);
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
// Authorization is the credential's values, not its realm/prototype or brand.
assert.equal(auth.playerId, id);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
return auth;
}
async function act(f: Fixture, id: string, action: Action) {
const rooms = f.restart();
const current = await rooms.readRoom(f.code);
await rooms.act(f.code, await authenticate(f, id), current.version, action, clock);
return f.restart().readRoom(f.code);
}
/** Original Emperor-retention seller payment: the winning buyer explicitly pays. */
async function paySeller(f: Fixture, g: Game) {
const frame = g.pendingNexusEmperorSellerPurchase;
return frame ? act(f, frame.winner, { type: 'nexusEmperorSellerPurchase', event: frame.event, use: false }) : g;
}
const pass = (event: string): Action => ({ type: 'richeseBetrayalPass', event });
const use = (event: string): Action => ({ type: 'richeseBetrayalUse', event });
function publicReaction(g: Game, id: string) {
const reaction = viewGame(g, id).richeseBetrayalReaction;
assert.ok(reaction);
const { event, kind, target, buyer, source, price } = reaction;
return { event, kind, target, buyer, source, price };
}
async function restored(f: Fixture, g: Game) {
const before = row(f);
const writeCount = f.writes.length;
for (const [index, token] of f.tokens.entries()) {
const rooms = f.restart();
const auth = await rooms.authenticate(f.code, token);
assert.equal(auth.playerId, f.auths[index].playerId);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId)));
assert.equal(view.richeseBetrayalPreview, true);
for (const key of ['pendingRicheseBetrayal', 'richeseBetrayal'])
assert.equal(Object.hasOwn(view, key), false, 'receipt/cursor stays private');
for (const rival of view.players.filter(p => p.id !== auth.playerId)) {
assert.equal(Object.hasOwn(rival, 'hand'), false);
assert.equal(Object.hasOwn(rival, 'traitors'), false);
assert.equal(Object.hasOwn(rival, 'spice'), false);
}
if (view.richeseBetrayalReaction) {
assert.deepEqual(publicReaction(g, auth.playerId), publicReaction(g, f.holder));
assert.deepEqual(Object.keys(view.richeseBetrayalReaction).sort(), [
'event', 'kind', 'target', 'buyer', 'source', 'price',
'canPass', 'hasPassed', 'canUse', 'blocked',
].sort());
assert.equal(view.richeseBetrayalReaction.canUse,
auth.playerId === f.holder && g.nexusCards!.cards!.hands[f.holder] === 'richese' &&
!view.richeseBetrayalReaction.hasPassed);
if (view.richeseBetrayalReaction.source === 'blackMarket' && auth.playerId !== f.target) {
assert.equal(view.richeseAuction?.card ?? null, null);
assert.equal(JSON.stringify(view).includes(f.card), false, 'concealed lot identity stays unentitled');
}
}
}
await Promise.all([f.restart().continueRoomAutomatic(f.code, clock),
f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), before, 'refresh/recovery cannot acknowledge a human opportunity');
assert.equal(f.writes.length, writeCount);
inventory(f, g);
}
async function passOthers(f: Fixture, g: Game) {
for (const p of g.players) {
if (p.id === f.holder) continue;
const reaction = viewGame(g, p.id).richeseBetrayalReaction;
if (reaction?.canPass) g = await act(f, p.id, pass(reaction.event));
}
assert.equal(viewGame(g, f.holder).richeseBetrayalReaction?.canPass, true);
return g;
}
async function allPass(f: Fixture, g: Game) {
g = await passOthers(f, g);
await restored(f, g);
return paySeller(f, await act(f, f.holder, pass(f.event)));
}
function spentOnce(f: Fixture, g: Game) {
assert.equal(g.nexusCards!.cards!.hands[f.holder], null);
assert.equal(g.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
assert.equal(g.richeseBetrayal!.completed.length, 1);
assert.equal(g.richeseBetrayal!.completed[0].stage, 'used');
assert.equal(viewGame(g, f.holder).richeseBetrayalReaction, null);
}
function vetoed(f: Fixture, g: Game) {
spentOnce(f, g);
assert.deepEqual(g.players.map(p => [p.id, p.spice]), f.initial.players.map(p => [p.id, p.spice]));
for (const p of g.players) assert.deepEqual(p.hand, player(f.initial, p.id).hand);
assert.equal(g.discard.filter(card => card.id === f.card).length, 1);
assert.equal(g.richeseCache!.some(card => card.id === f.card), false);
assert.notEqual(g.richeseAuction?.event, f.initial.richeseAuction!.event);
inventory(f, g);
}
function sold(f: Fixture, g: Game, diverted: boolean, allyPayment = 0) {
const invoice = publicReaction(f.initial, f.holder);
assert.equal(player(g, f.buyer).spice, player(f.initial, f.buyer).spice - invoice.price + allyPayment);
assert.equal(player(g, f.target).spice, player(f.initial, f.target).spice + (diverted ? 0 : invoice.price));
if (allyPayment) {
const ally = player(f.initial, f.buyer).ally!;
assert.equal(player(g, ally).spice, player(f.initial, ally).spice);
assert.equal(g.aid[ally].amount, f.initial.aid[ally].amount - allyPayment);
}
for (const p of g.players) {
if ([f.buyer, f.target, player(f.initial, f.buyer).ally].includes(p.id)) continue;
assert.equal(p.spice, player(f.initial, p.id).spice);
}
const total = (game: Game) => game.players.reduce((sum, p) => sum + p.spice, 0) +
Object.values(game.aid).reduce((sum, credit) => sum + credit.amount, 0);
assert.equal(total(g), total(f.initial) - (diverted ? invoice.price : 0),
'bank receives the original payment, not a second charge');
const buyer = player(g, f.buyer);
const bonus = f.initial.deck[0].id;
assert.equal(buyer.faction, 'harkonnen');
assert.deepEqual(buyer.hand.map(card => card.id).sort(),
[...player(f.initial, f.buyer).hand.map(card => card.id), f.card, bonus].sort());
assert.equal(g.deck.some(card => card.id === bonus), false);
assert.equal(player(g, f.target).hand.some(card => card.id === f.card), false);
assert.notEqual(g.richeseAuction?.event, f.initial.richeseAuction!.event);
inventory(f, g);
}
for (const advanced of [false, true]) {
void test(`SQLite saved ${advanced ? 'Advanced' : 'Basic'} cache veto discards its exact lot without debit or acquisition`, async t => {
const f = await fixture(t, 'purchase', { advanced });
await restored(f, f.initial);
assert.equal(player(f.initial, f.target).hand.some(card => card.id === f.card), false);
assert.equal(f.initial.richeseCache!.some(card => card.id === f.card), true);
const done = await act(f, f.holder, use(f.event));
assert.equal(done.version, f.initial.version + 1);
vetoed(f, done);
await restored(f, done);
const before = row(f), writes = f.writes.length;
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), done.version, use(f.event), clock));
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, writes);
});
}
for (const scenario of [
{ source: 'cache', advanced: false, allyPayment: 0 },
{ source: 'cache', advanced: true, allyPayment: 2 },
{ source: 'blackMarket', advanced: true, allyPayment: 2 },
] as const) {
void test(`SQLite saved ${scenario.advanced ? 'Advanced' : 'Basic'} ${scenario.source} sale diversion preserves original payers, card and one Harkonnen bonus`, async t => {
const f = await fixture(t, 'sale', scenario);
await restored(f, f.initial);
const done = await paySeller(f, await act(f, f.holder, use(f.event)));
spentOnce(f, done);
sold(f, done, true, scenario.allyPayment);
await restored(f, done);
const before = row(f), writes = f.writes.length;
for (const action of [use(f.event), pass(f.event)])
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), done.version, action, clock));
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, writes);
});
}
for (const kind of ['purchase', 'sale'] as const) {
void test(`SQLite saved all-pass ${kind} performs the original quoted settlement once without consuming Nexus`, async t => {
const f = await fixture(t, kind);
const invoice = publicReaction(f.initial, f.holder);
const done = await allPass(f, f.initial);
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
assert.equal(done.nexusCards!.cards!.discard.includes('richese'), false);
assert.equal(viewGame(done, f.holder).richeseBetrayalReaction, null);
assert.equal(done.richeseBetrayal!.completed.length, 1);
assert.equal(done.richeseBetrayal!.completed[0].stage, 'passed');
if (kind === 'sale') sold(f, done, false);
else {
assert.equal(player(done, f.target).spice, player(f.initial, f.target).spice - invoice.price);
assert.deepEqual(player(done, f.target).hand.map(card => card.id).sort(),
[...player(f.initial, f.target).hand.map(card => card.id), f.card].sort());
assert.equal(done.discard.some(card => card.id === f.card), false);
assert.equal(done.richeseCache!.some(card => card.id === f.card), false);
for (const p of done.players.filter(p => p.id !== f.target))
assert.equal(p.spice, player(f.initial, p.id).spice);
inventory(f, done);
}
await restored(f, done);
const before = row(f), writes = f.writes.length;
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder), done.version, pass(f.event), clock));
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, writes);
});
}
for (const kind of ['purchase', 'sale'] as const) {
void test(`SQLite competing final pass/use ${kind} CAS commits one original settlement after restart`, async t => {
const f = await fixture(t, kind);
const pending = await passOthers(f, f.initial);
await restored(f, pending);
f.writes.length = 0;
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
let results: PromiseSettledResult<unknown>[];
try {
const auth = await authenticate(f, f.holder);
results = await Promise.allSettled([pass(f.event), use(f.event)].map(action =>
f.restart().act(f.code, auth, pending.version, action, clock).finally(release)));
} finally {
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);
assert.deepEqual(f.writes.map(write => write.changes).sort((a,b) => a - b), [0, 1]);
const committed = await f.restart().readRoom(f.code);
assert.equal(committed.version, pending.version + 1);
const done = await paySeller(f, committed);
const used = results[1].status === 'fulfilled';
assert.equal(done.nexusCards!.cards!.hands[f.holder], used ? null : 'richese');
assert.equal(done.richeseBetrayal!.completed.length, 1);
assert.equal(done.richeseBetrayal!.completed[0].stage, used ? 'used' : 'passed');
if (used) spentOnce(f, done);
if (kind === 'sale') sold(f, done, used);
else if (used) vetoed(f, done);
else {
assert.equal(player(done, f.target).spice,
player(f.initial, f.target).spice - publicReaction(f.initial, f.holder).price);
assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
}
await restored(f, done);
});
}
void test('SQLite stale, foreign-seat, forged invoice and wrong-event reactions reject before writes and preserve real credentials', async t => {
const f = await fixture(t, 'purchase');
const rooms = f.restart();
const before = row(f);
const holder = await authenticate(f, f.holder);
const foreign = await rooms.authenticate(f.unrelatedCode, f.unrelatedToken);
await assert.rejects(rooms.authenticate(f.code, f.unrelatedToken));
await assert.rejects(rooms.readSeatView(f.code, foreign));
await assert.rejects(rooms.readSeatView(f.code, { playerId: f.holder, tokenHash: foreign.tokenHash }));
const requests: { auth: SeatAuth; version: number; action: Action }[] = [
{ auth: holder, version: f.initial.version - 1, action: use(f.event) },
{ auth: foreign, version: f.initial.version, action: use(f.event) },
{ auth: await authenticate(f, f.target), version: f.initial.version, action: use(f.event) },
{ auth: holder, version: f.initial.version, action: use(`${f.event}:stale`) },
{ auth: holder, version: f.initial.version, action: pass(`${f.event}:stale`) },
{ auth: holder, version: f.initial.version,
action: { ...use(f.event), card: f.card, price: 0, recipient: f.holder } as Action },
];
for (const request of requests) {
await assert.rejects(rooms.act(f.code, request.auth, request.version, request.action, clock));
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
protectedRows(f);
}
const pending = await passOthers(f, f.initial);
const saved = row(f), writes = f.writes.length;
if (pending.version !== f.initial.version)
await assert.rejects(rooms.act(f.code, holder, f.initial.version, use(f.event), clock));
const otherReaction = viewGame(pending, f.other).richeseBetrayalReaction;
if (otherReaction?.hasPassed)
await assert.rejects(rooms.act(f.code, await authenticate(f, f.other), pending.version, pass(f.event), clock));
assert.deepEqual(row(f), saved);
assert.equal(f.writes.length, writes);
await restored(f, pending);
});
void test('SQLite neutral acknowledgement and all-seat public invoice survive hidden relevant/nonrelevant Nexus substitution', async t => {
const f = await fixture(t, 'sale');
const irrelevant = structuredClone(f.initial);
const cards = irrelevant.nexusCards!.cards!;
const replacement = cards.deck.indexOf('choam');
assert.ok(replacement >= 0);
assert.equal(cards.hands[f.holder], 'richese');
cards.hands[f.holder] = 'choam';
cards.deck[replacement] = 'richese';
f.save(irrelevant);
f.writes.length = 0;
await restored(f, irrelevant);
for (const auth of f.auths) {
const original = viewGame(f.initial, auth.playerId).richeseBetrayalReaction!;
const changed = (await f.restart().readSeatView(f.code,
await authenticate(f, auth.playerId))).richeseBetrayalReaction!;
assert.deepEqual(publicReaction(irrelevant, auth.playerId), publicReaction(f.initial, auth.playerId));
assert.equal(changed.canPass, original.canPass);
assert.equal(changed.hasPassed, original.hasPassed);
assert.equal(changed.canUse, false);
if (auth.playerId !== f.holder) assert.deepEqual(plain(changed), plain(original));
}
const saved = row(f);
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder),
irrelevant.version, use(f.event), clock));
assert.deepEqual(row(f), saved);
assert.equal(f.writes.length, 0);
const done = await allPass(f, irrelevant);
sold(f, done, false);
assert.equal(done.nexusCards!.cards!.hands[f.holder], 'choam');
await restored(f, done);
});
for (const kind of ['purchase', 'sale'] as const) {
void test(`SQLite corrupt/orphaned saved ${kind} receipts reject all-seat disclosure and settlement before writes`, async t => {
const f = await fixture(t, kind);
const mutations: { name: string; change(this: void, g: Game): void }[] = [
{ name: 'deleted frame with independent pending cursor', change: g => { delete g.pendingRicheseBetrayal; } },
{ name: 'deleted cursor with orphan frame', change: g => { delete g.richeseBetrayal; } },
{ name: 'detached cursor event', change: g => { g.richeseBetrayal!.current = null; } },
{ name: 'lost pending sequence', change: g => { g.richeseBetrayal!.sequence = 0; } },
{ name: 'changed public price', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.price++; } },
{ name: 'changed payer split', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.ownPayment++; } },
{ name: 'changed physical face', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.card.name += ' forged'; } },
{ name: 'foreign buyer', change: g => { g.pendingRicheseBetrayal!.receipt.invoice.buyer = f.holder; } },
{ name: 'changed source quote', change: g => { const frame=g.pendingRicheseBetrayal!;assert.ok('quote' in frame);frame.quote.amount++; } },
{ name: 'changed original lot', change: g => { const frame=g.pendingRicheseBetrayal!;assert.ok('lot' in frame);frame.lot.cardId = g.deck[0].id; } },
{ name: 'foreign required acknowledgement', change: g => { g.pendingRicheseBetrayal!.receipt.required.push(f.target); } },
{ name: 'forged pass', change: g => { g.pendingRicheseBetrayal!.receipt.passed.push(f.holder); } },
{ name: 'lost exact source custody', change: g => {
if (kind === 'purchase') {
const index = g.richeseCache!.findIndex(card => card.id === f.card);
assert.ok(index >= 0);
player(g, f.other).hand.push(...g.richeseCache!.splice(index, 1));
} else {
const owner = player(g, f.target);
const index = owner.hand.findIndex(card => card.id === f.card);
assert.ok(index >= 0);
player(g, f.other).hand.push(...owner.hand.splice(index, 1));
}
} },
];
for (const { name, change } of mutations) {
const corrupt = structuredClone(f.initial);
change(corrupt);
f.save(corrupt);
f.writes.length = 0;
const saved = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code,
await authenticate(f, auth.playerId)), name);
for (const action of [pass(f.event), use(f.event)])
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder),
corrupt.version, action, clock), name);
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), saved, name);
assert.equal(f.writes.length, 0, name);
protectedRows(f);
}
});
}
void test('SQLite completed veto history survives later physical card movement but not deleted or corrupted completion evidence', async t => {
const f = await fixture(t, 'purchase');
const done = await act(f, f.holder, use(f.event));
vetoed(f, done);
const later = structuredClone(done);
// Model later component recycling without recreating or copying a component.
const treacheryIndex = later.discard.findIndex(card => card.id === f.card);
assert.ok(treacheryIndex >= 0);
later.deck.push(...later.discard.splice(treacheryIndex, 1));
const cards = later.nexusCards!.cards!;
const nexusIndex = cards.discard.indexOf('richese');
assert.ok(nexusIndex >= 0);
cards.deck.push(...cards.discard.splice(nexusIndex, 1));
f.save(later);
f.writes.length = 0;
await restored(f, later);
assert.equal(later.richeseBetrayal!.completed[0].stage, 'used');
const saved = row(f);
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder),
later.version, use(f.event), clock));
assert.deepEqual(row(f), saved);
assert.equal(f.writes.length, 0);
for (const change of [
(g: Game) => { g.richeseBetrayal!.completed = []; },
(g: Game) => { g.richeseBetrayal!.completed[0].invoice.price++; },
(g: Game) => { delete g.richeseBetrayal; },
]) {
const corrupt = structuredClone(later);
change(corrupt);
f.save(corrupt);
f.writes.length = 0;
const before = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)));
await assert.rejects(f.restart().act(f.code, await authenticate(f, f.holder),
corrupt.version, use(f.event), clock));
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
protectedRows(f);
}
});