Add room-creation and host lobby rules selectors, readiness reset, explicit preview start and a persistent warning. Admit the six classic factions, Tech Tokens with 3+ players and optional Stronghold Cards through genuine setup. Keep expansion/module gates and complete-rules/publication criteria. Persist the preview marker and enforce fresh native leaders, unchanged piece custody and clean runtime/pacing state. Preserve private projections, JSON continuation, exact room-entry retries and version/CAS protection. Independent review resolved malformed leader, extra card-zone, missing required-key and stale bot-deadline admission gaps. Validation on the shared development tree: types, lint, 5169 offline tests, build and 50 HTTP tests passed. The tree also contained the separate deployment task's changes; this commit stages only preview scope. All 57 base rosters completed real setup with each legal AI profile. Five normal-preview-start games (2–6 seats) completed 1950 actions and 51 JSON restores without rejected actions; no strategy calibration. Browser: actual create, host switch/readiness/Stronghold reset, explicit start, exact private refresh, desktop and mobile warning checked. Retained dev process stopped; backed up 1,629 saved games, restored server with all unchanged, and resumed the same traitor choice into Storm. Baseline all-table preservation retained 1626 rooms and 3442 seats; totals now 1669 rooms and 3536 seats. No reset or automation. Preview remains incomplete. Legacy unsupported expansion creation payloads remain accepted for exact retry compatibility but cannot start. Existing rules ambiguities and full visual acceptance remain open.
169 lines
5.8 KiB
TypeScript
169 lines
5.8 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { unitStore } from './fixture-nexus-room-store';
|
|
|
|
async function fixture() {
|
|
const store = unitStore();
|
|
assert.ok(store.sqlite instanceof DatabaseSync);
|
|
const host = await store.rooms.createRoom(
|
|
'Preview host',
|
|
'atreides',
|
|
true,
|
|
[],
|
|
);
|
|
const code = host.view.code;
|
|
const peer = await store.rooms.joinRoom(code, 'Preview peer', 'emperor');
|
|
assert.ok(peer.token);
|
|
const hostAuth = await store.rooms.authenticate(code, host.token);
|
|
const peerAuth = await store.rooms.authenticate(code, peer.token);
|
|
for (const auth of [hostAuth, peerAuth]) {
|
|
const before = await store.rooms.readRoom(code);
|
|
await store.rooms.act(code, auth, before.version, { type: 'ready' });
|
|
}
|
|
return {
|
|
...store,
|
|
code,
|
|
hostAuth,
|
|
peerAuth,
|
|
current: await store.rooms.readRoom(code),
|
|
};
|
|
}
|
|
|
|
void test('Advanced preview survives room restart without redealing or exposing rival secrets', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
|
|
const count = f.writes.length;
|
|
await assert.rejects(
|
|
f.rooms.act(f.code, f.hostAuth, f.current.version, { type: 'start' }),
|
|
/preview/i,
|
|
);
|
|
await assert.rejects(
|
|
f.rooms.act(f.code, f.peerAuth, f.current.version, {
|
|
type: 'start',
|
|
advancedPreview: true,
|
|
}),
|
|
);
|
|
assert.equal(f.writes.length, count);
|
|
assert.deepEqual(await f.rooms.readRoom(f.code), f.current);
|
|
await f.rooms.act(f.code, f.hostAuth, f.current.version, {
|
|
type: 'start',
|
|
advancedPreview: true,
|
|
});
|
|
const started = await f.rooms.readRoom(f.code);
|
|
assert.equal(started.status, 'setup');
|
|
assert.equal(started.advancedPreview, true);
|
|
const restarted = f.restart();
|
|
for (const auth of [f.hostAuth, f.peerAuth]) {
|
|
const view = await restarted.readSeatView(f.code, auth);
|
|
assert.equal(view.advanced, true);
|
|
assert.equal(view.advancedPreview, true);
|
|
const own = view.players.find((p) => p.id === auth.playerId)!;
|
|
assert.ok(own.traitorChoices?.length);
|
|
for (const rival of view.players.filter((p) => p.id !== auth.playerId)) {
|
|
for (const field of ['hand', 'traitors', 'traitorChoices', 'spice'])
|
|
assert.equal(Object.hasOwn(rival, field), false);
|
|
}
|
|
}
|
|
assert.deepEqual(await restarted.readRoom(f.code), started);
|
|
const writes = f.writes.length;
|
|
for (const action of [
|
|
{ type: 'start', advancedPreview: true },
|
|
{ type: 'rules', advanced: false },
|
|
])
|
|
await assert.rejects(
|
|
restarted.act(f.code, f.hostAuth, started.version, action),
|
|
);
|
|
assert.equal(f.writes.length, writes);
|
|
assert.deepEqual(await restarted.readRoom(f.code), started);
|
|
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('a rules change survives restart and requires new readiness before an ordinary Basic start', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
await f.rooms.act(f.code, f.hostAuth, f.current.version, {
|
|
type: 'strongholdCards',
|
|
enabled: true,
|
|
});
|
|
for (const auth of [f.hostAuth, f.peerAuth]) {
|
|
const before = await f.rooms.readRoom(f.code);
|
|
await f.rooms.act(f.code, auth, before.version, { type: 'ready' });
|
|
}
|
|
let before = await f.rooms.readRoom(f.code);
|
|
await assert.rejects(
|
|
f.rooms.act(f.code, f.peerAuth, before.version, {
|
|
type: 'rules',
|
|
advanced: false,
|
|
}),
|
|
);
|
|
await f.rooms.act(f.code, f.hostAuth, before.version, {
|
|
type: 'rules',
|
|
advanced: false,
|
|
});
|
|
const restarted = f.restart();
|
|
before = await restarted.readRoom(f.code);
|
|
assert.equal(before.advanced, false);
|
|
assert.equal(before.strongholdCards, null);
|
|
assert.ok(before.players.every((p) => !p.ready));
|
|
await assert.rejects(
|
|
restarted.act(f.code, f.hostAuth, before.version, { type: 'start' }),
|
|
/ready/i,
|
|
);
|
|
for (const auth of [f.hostAuth, f.peerAuth]) {
|
|
before = await restarted.readRoom(f.code);
|
|
await restarted.act(f.code, auth, before.version, { type: 'ready' });
|
|
}
|
|
before = await restarted.readRoom(f.code);
|
|
await restarted.act(f.code, f.hostAuth, before.version, { type: 'start' });
|
|
const started = await restarted.readRoom(f.code);
|
|
assert.equal(started.advanced, false);
|
|
assert.equal(started.advancedPreview, undefined);
|
|
assert.equal(started.status, 'setup');
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('racing Advanced start and rules changes preserve exactly one committed setup or lobby', async () => {
|
|
for (const startWins of [true, false]) {
|
|
const f = await fixture();
|
|
try {
|
|
const start = { type: 'start', advancedPreview: true };
|
|
const rules = { type: 'rules', advanced: false };
|
|
let winning: unknown;
|
|
f.hooks.beforeWrite = async () => {
|
|
f.hooks.beforeWrite = undefined;
|
|
await f.rooms.act(
|
|
f.code,
|
|
f.hostAuth,
|
|
f.current.version,
|
|
startWins ? start : rules,
|
|
);
|
|
winning = await f.rooms.readRoom(f.code);
|
|
};
|
|
await assert.rejects(
|
|
f.rooms.act(
|
|
f.code,
|
|
f.hostAuth,
|
|
f.current.version,
|
|
startWins ? rules : start,
|
|
),
|
|
/changed|again|stale/i,
|
|
);
|
|
const restored = await f.restart().readRoom(f.code);
|
|
assert.deepEqual(restored, winning);
|
|
assert.equal(restored.version, f.current.version + 1);
|
|
assert.equal(restored.status, startWins ? 'setup' : 'lobby');
|
|
assert.equal(restored.advancedPreview, startWins ? true : undefined);
|
|
assert.equal(f.writes.at(-1)!.changes, 0);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
}
|
|
});
|