340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { unitStore } from './fixture-nexus-room-store';
|
|
import {
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import type { RoomsClock } from '../db/rooms';
|
|
import {
|
|
nexusGuildSecretAllyFixture,
|
|
nexusGuildSecretAllyInventory,
|
|
nexusGuildSecretAllyRequest,
|
|
nexusGuildSecretAllianceFixture,
|
|
} from './fixture-nexus-guild-secret-ally';
|
|
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
|
async function fixture(homeworlds = false, alliance = false) {
|
|
const store = unitStore();
|
|
const made = await store.rooms.createRoom(
|
|
'Guild Secret Ally SQL',
|
|
'emperor',
|
|
true,
|
|
[],
|
|
);
|
|
const code = made.view.code,
|
|
tokens = [made.token];
|
|
for (const faction of ['harkonnen', 'fremen'] as const)
|
|
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
|
const auths = await Promise.all(
|
|
tokens.map((t) => store.rooms.authenticate(code, t)),
|
|
);
|
|
const ids = auths.map((a) => a.playerId) as [string, string, string];
|
|
const f = alliance
|
|
? nexusGuildSecretAllianceFixture(ids)
|
|
: nexusGuildSecretAllyFixture({
|
|
seatIds: ids,
|
|
ownerFaction: 'emperor',
|
|
advanced: true,
|
|
homeworlds,
|
|
});
|
|
f.g.code = code;
|
|
f.g.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(f.g);
|
|
store.writes.length = 0;
|
|
return { ...store, ...f, code, tokens, auths, ids, save };
|
|
}
|
|
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
|
const row = (f: Fixture) =>
|
|
f.sqlite
|
|
.prepare('SELECT state,version FROM rooms WHERE code = ?')
|
|
.get(f.code);
|
|
async function restored(f: Fixture, g: Game) {
|
|
const before = row(f),
|
|
rooms = f.restart();
|
|
for (const [i, token] of f.tokens.entries()) {
|
|
const auth = await rooms.authenticate(f.code, token),
|
|
v = await rooms.readSeatView(f.code, auth);
|
|
assert.deepEqual(v, viewGame(g, f.ids[i]));
|
|
assert.equal(Object.hasOwn(v, 'nexusGuildSecretHistory'), false);
|
|
if (i !== 0) assert.equal(v.nexusGuildSecretAlly, null);
|
|
}
|
|
await rooms.continueRoomAutomatic(f.code, clock);
|
|
assert.deepEqual(row(f), before);
|
|
nexusGuildSecretAllyInventory(g);
|
|
}
|
|
async function act(f: Fixture, action: Action, seat = 0) {
|
|
const g = await f.restart().readRoom(f.code);
|
|
await f.restart().act(f.code, f.auths[seat], g.version, action, clock);
|
|
return f.restart().readRoom(f.code);
|
|
}
|
|
|
|
void test('SQLite saves the genuine post-shipment Moritani alliance reply and cannot replay its shipment when accepting', async () => {
|
|
const f = await fixture(false, true);
|
|
try {
|
|
let g = await act(f, nexusGuildSecretAllyRequest(f.g, f.owner));
|
|
assert.equal(g.pendingTerrorEntry?.stage, 'offer');
|
|
const spice = g.players[0].spice,
|
|
reserves = g.players[0].reserves;
|
|
g = await act(f, { type: 'decision', alliance: true }, 1);
|
|
while (g.response)
|
|
g = await act(
|
|
f,
|
|
{ type: 'passResponse' },
|
|
f.ids.indexOf(
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
),
|
|
);
|
|
await restored(f, g);
|
|
const accepted = await act(f, { type: 'decision', accept: true });
|
|
assert.equal(accepted.players[0].ally, f.target);
|
|
assert.equal(accepted.players[1].ally, f.owner);
|
|
assert.equal(accepted.players[0].spice, spice);
|
|
assert.equal(accepted.players[0].reserves, reserves);
|
|
assert.equal(accepted.nexusGuildSecretHistory!.length, 1);
|
|
await restored(f, accepted);
|
|
const before = row(f);
|
|
await assert.rejects(
|
|
f
|
|
.restart()
|
|
.act(
|
|
f.code,
|
|
f.auths[0],
|
|
accepted.version,
|
|
{ type: 'decision', accept: true },
|
|
clock,
|
|
),
|
|
);
|
|
assert.deepEqual(row(f), before);
|
|
const moved = await act(f, {
|
|
type: 'move',
|
|
from: 'arrakeen:10',
|
|
territory: 'hagga_basin',
|
|
sector: 12,
|
|
amount: 5,
|
|
});
|
|
assert.equal(moved.players[0].spice, spice);
|
|
assert.equal(moved.players[0].reserves, reserves);
|
|
assert.equal(moved.players[0].forces['hagga_basin:12'], 5);
|
|
await restored(f, moved);
|
|
const ended = await act(f, { type: 'endMovement' });
|
|
assert.notEqual(ended.active, f.owner);
|
|
await restored(f, ended);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('SQLite racing Guild Secret Ally commits one paid typed shipment and lost acknowledgment cannot repeat it', async () => {
|
|
for (const homeworlds of [false, true]) {
|
|
const f = await fixture(homeworlds);
|
|
try {
|
|
const other = await f.rooms.createRoom(
|
|
'Unrelated SQL room',
|
|
'atreides',
|
|
false,
|
|
[],
|
|
);
|
|
const otherRow = () =>
|
|
f.sqlite
|
|
.prepare('SELECT state,version FROM rooms WHERE code = ?')
|
|
.get(other.view.code);
|
|
const unrelated = otherRow();
|
|
const action = nexusGuildSecretAllyRequest(
|
|
f.g,
|
|
f.owner,
|
|
homeworlds
|
|
? {
|
|
type: 'homeworldShip',
|
|
event: viewGame(f.g, f.owner).homeworldShipment!.event,
|
|
destination: 'homeworld:harkonnen',
|
|
sources: {
|
|
'homeworld:emperor': { normal: 3, elite: 0 },
|
|
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
|
|
},
|
|
}
|
|
: {
|
|
type: 'ship',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
amount: 5,
|
|
elite: 2,
|
|
},
|
|
);
|
|
let arrivals = 0,
|
|
release!: () => void;
|
|
const gate = new Promise<void>((r) => {
|
|
release = r;
|
|
});
|
|
f.hooks.beforeWrite = async () => {
|
|
if (++arrivals === 2) release();
|
|
await gate;
|
|
};
|
|
const timer = setTimeout(release, 2000);
|
|
const results = await Promise.allSettled(
|
|
[0, 1].map(() =>
|
|
f.restart().act(f.code, f.auths[0], f.g.version, action, clock),
|
|
),
|
|
);
|
|
clearTimeout(timer);
|
|
delete f.hooks.beforeWrite;
|
|
assert.equal(arrivals, 2);
|
|
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
|
assert.deepEqual(
|
|
f.writes.map((w) => w.changes).sort((a, b) => a - b),
|
|
[0, 1],
|
|
);
|
|
const done = await f.restart().readRoom(f.code);
|
|
assert.equal(done.players[0].spice, 17);
|
|
assert.equal(done.players[0].reserves, 15);
|
|
assert.equal(done.players[0].elites!.reserves, 3);
|
|
assert.equal(done.nexusGuildSecretHistory!.length, 1);
|
|
assert.equal(
|
|
done.nexusCards!.cards!.discard.filter((c) => c === 'guild').length,
|
|
1,
|
|
);
|
|
if (homeworlds)
|
|
assert.deepEqual(
|
|
done.homeworlds!.custody!.visitors['homeworld:harkonnen'][f.owner],
|
|
{ normal: 3, elite: 2 },
|
|
);
|
|
else assert.equal(done.players[0].elites!.forces['arrakeen:10'], 2);
|
|
await restored(f, done);
|
|
const before = row(f);
|
|
await assert.rejects(
|
|
f.restart().act(f.code, f.auths[0], done.version, action, clock),
|
|
);
|
|
assert.deepEqual(row(f), before);
|
|
assert.deepEqual(otherRow(), unrelated);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
void test('SQLite saved Guild Secret Ally completion preserves native movement and does not reread held Nexus identity', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const shipped = await act(
|
|
f,
|
|
nexusGuildSecretAllyRequest(f.g, f.owner, {
|
|
type: 'ship',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
amount: 5,
|
|
elite: 2,
|
|
}),
|
|
);
|
|
await restored(f, shipped);
|
|
const moved = await act(f, {
|
|
type: 'move',
|
|
from: 'arrakeen:10',
|
|
territory: 'hagga_basin',
|
|
sector: 12,
|
|
amount: 5,
|
|
elite: 2,
|
|
});
|
|
assert.equal(moved.players[0].spice, 17);
|
|
assert.equal(moved.players[0].reserves, 15);
|
|
assert.equal(moved.players[0].elites!.forces['hagga_basin:12'], 2);
|
|
assert.equal(moved.players[0].moved, 1);
|
|
await restored(f, moved);
|
|
const ended = await act(f, { type: 'endMovement' });
|
|
assert.notEqual(ended.active, f.owner);
|
|
await restored(f, ended);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('SQLite damaged Guild Secret Ally terminal route proofs reject all projections and actions without writes', async () => {
|
|
const f = await fixture(true);
|
|
try {
|
|
const done = await act(
|
|
f,
|
|
nexusGuildSecretAllyRequest(f.g, f.owner, {
|
|
type: 'ship',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
amount: 5,
|
|
elite: 2,
|
|
homeworldSources: {
|
|
'homeworld:emperor': { normal: 3, elite: 0 },
|
|
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
|
|
},
|
|
}),
|
|
);
|
|
const editFrame = (
|
|
g: Game,
|
|
edit: (frame: Record<string, unknown>) => void,
|
|
) => {
|
|
const record = g.nexusGuildSecretHistory![0];
|
|
const frame = JSON.parse(record.frame) as Record<string, unknown>;
|
|
edit(frame);
|
|
record.frame = JSON.stringify(frame);
|
|
};
|
|
for (const edit of [
|
|
(g: Game) => {
|
|
delete g.nexusGuildSecretHistory;
|
|
},
|
|
(g: Game) => {
|
|
delete g.nexusGuildSecretLast;
|
|
},
|
|
(g: Game) => {
|
|
g.nexusGuildSecretHistory![0].signature = 'changed';
|
|
},
|
|
(g: Game) =>
|
|
editFrame(g, (frame) => {
|
|
delete frame.guildSecretEvent;
|
|
}),
|
|
(g: Game) =>
|
|
editFrame(g, (frame) => {
|
|
frame.amount = 4;
|
|
}),
|
|
(g: Game) =>
|
|
editFrame(g, (frame) => {
|
|
frame.cost = 1;
|
|
}),
|
|
(g: Game) =>
|
|
editFrame(g, (frame) => {
|
|
frame.elite = 1;
|
|
}),
|
|
(g: Game) =>
|
|
editFrame(g, (frame) => {
|
|
frame.homeworldSources = {
|
|
'homeworld:emperor': { normal: 5, elite: 0 },
|
|
};
|
|
}),
|
|
]) {
|
|
const bad = structuredClone(done);
|
|
edit(bad);
|
|
f.save(bad);
|
|
f.writes.length = 0;
|
|
const before = row(f),
|
|
rooms = f.restart();
|
|
for (const auth of f.auths)
|
|
await assert.rejects(rooms.readSeatView(f.code, auth));
|
|
assert.throws(() => normalizeAutomaticGame(bad));
|
|
await Promise.allSettled([rooms.continueRoomAutomatic(f.code, clock)]);
|
|
await assert.rejects(
|
|
rooms.act(
|
|
f.code,
|
|
f.auths[0],
|
|
bad.version,
|
|
{ type: 'endMovement' },
|
|
clock,
|
|
),
|
|
);
|
|
assert.deepEqual(row(f), before);
|
|
assert.equal(f.writes.length, 0);
|
|
}
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|