Expose an explicitly unfinished Advanced preview in normal lobbies
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.
This commit is contained in:
1 parent
0f79f28ccb
commit
c5508b727a
16 files changed
+998
-115
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
RulesetControls,
|
||||
AdvancedPreviewNotice,
|
||||
} from '../components/ruleset-controls';
|
||||
import { createRoomEntry, parseRoomEntry } from '../lib/room-entry';
|
||||
|
||||
void test('rules controls identify the unfinished selection before readiness and restrict peer editing', () => {
|
||||
const render = (advanced: boolean, host: boolean) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(RulesetControls, {
|
||||
advanced,
|
||||
host,
|
||||
lobby: true,
|
||||
onChange() {},
|
||||
}),
|
||||
);
|
||||
assert.match(render(false, true), /value="basic" selected=""/);
|
||||
const preview = render(true, true);
|
||||
assert.match(preview, /value="advanced" selected=""/);
|
||||
assert.match(preview, /Rules and card interactions are still/);
|
||||
assert.match(preview, /six classic factions/);
|
||||
assert.match(preview, /Changing rules clears player readiness/);
|
||||
assert.match(preview, /Switching to Basic removes Stronghold Cards/);
|
||||
assert.match(render(true, false), /<select disabled=""/);
|
||||
assert.match(render(true, false), /The host chooses the rules/);
|
||||
const ongoing = renderToStaticMarkup(
|
||||
createElement(AdvancedPreviewNotice, { compact: true }),
|
||||
);
|
||||
assert.match(ongoing, /Advanced preview/);
|
||||
assert.match(ongoing, /Some play may need fixes/);
|
||||
});
|
||||
|
||||
void test('an Advanced creation attempt retains the exact selection and proof through a tab retry', () => {
|
||||
const attempt = createRoomEntry({
|
||||
name: 'Preview host',
|
||||
faction: 'fremen',
|
||||
advanced: true,
|
||||
expansions: [],
|
||||
});
|
||||
const restored = parseRoomEntry(JSON.stringify(attempt));
|
||||
assert.equal(restored.bodyText, attempt.bodyText);
|
||||
const body = JSON.parse(restored.bodyText);
|
||||
assert.equal(body.advanced, true);
|
||||
assert.deepEqual(body.expansions, []);
|
||||
assert.equal(body.faction, 'fremen');
|
||||
assert.equal(
|
||||
body.entry.sessionToken,
|
||||
JSON.parse(attempt.bodyText).entry.sessionToken,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// @dune-suite integration
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { GameView, Action } from '../game/engine';
|
||||
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
async function request(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
cookie?: string,
|
||||
origin?: string,
|
||||
) {
|
||||
const response = await fetch(base + path, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(origin ? { origin } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
data: (await response
|
||||
.json()
|
||||
.catch(() => ({ error: 'Non-JSON response' }))) as GameView & {
|
||||
error?: string;
|
||||
},
|
||||
cookie: response.headers.get('set-cookie')?.split(';')[0],
|
||||
};
|
||||
}
|
||||
|
||||
void test('normal HTTP lobby explicitly starts Advanced preview and restores both private seats', async () => {
|
||||
const host = await request('/api/rooms', {
|
||||
name: 'Advanced preview host',
|
||||
faction: 'atreides',
|
||||
advanced: true,
|
||||
expansions: [],
|
||||
});
|
||||
assert.equal(host.status, 201);
|
||||
assert.ok(host.cookie);
|
||||
assert.equal(host.data.advanced, true);
|
||||
assert.equal(host.data.advancedPreview, false);
|
||||
const path = `/api/rooms/${host.data.code}`;
|
||||
const peer = await request(path, {
|
||||
type: 'join',
|
||||
name: 'Advanced preview peer',
|
||||
faction: 'emperor',
|
||||
});
|
||||
assert.equal(peer.status, 200);
|
||||
assert.ok(peer.cookie);
|
||||
let version = peer.data.version;
|
||||
async function act(action: Action, cookie = host.cookie) {
|
||||
const result = await request(path, { version, action }, cookie);
|
||||
assert.equal(result.status, 200, result.data.error);
|
||||
version = result.data.version;
|
||||
return result.data;
|
||||
}
|
||||
for (const cookie of [host.cookie, peer.cookie])
|
||||
await act({ type: 'ready' }, cookie);
|
||||
for (const [action, cookie, origin] of [
|
||||
[{ type: 'rules', advanced: false }, peer.cookie, undefined],
|
||||
[{ type: 'start' }, host.cookie, undefined],
|
||||
[{ type: 'start', advancedPreview: 'true' }, host.cookie, undefined],
|
||||
[{ type: 'start', advancedPreview: true }, peer.cookie, undefined],
|
||||
[{ type: 'start', advancedPreview: true }, undefined, undefined],
|
||||
[
|
||||
{ type: 'start', advancedPreview: true },
|
||||
host.cookie,
|
||||
'https://other.invalid',
|
||||
],
|
||||
] as const) {
|
||||
const rejected = await request(path, { version, action }, cookie, origin);
|
||||
assert.ok(rejected.status >= 400);
|
||||
assert.equal(
|
||||
(await request(path, undefined, host.cookie)).data.version,
|
||||
version,
|
||||
);
|
||||
}
|
||||
const basic = await act({ type: 'rules', advanced: false });
|
||||
assert.ok(basic.players.every((p) => !p.ready));
|
||||
const preview = await act({ type: 'rules', advanced: true });
|
||||
assert.ok(preview.players.every((p) => !p.ready));
|
||||
for (const cookie of [host.cookie, peer.cookie])
|
||||
await act({ type: 'ready' }, cookie);
|
||||
const startVersion = version;
|
||||
const started = await act({ type: 'start', advancedPreview: true });
|
||||
assert.equal(started.status, 'setup');
|
||||
assert.equal(started.advancedPreview, true);
|
||||
assert.ok(
|
||||
(
|
||||
await request(
|
||||
path,
|
||||
{
|
||||
version: startVersion,
|
||||
action: { type: 'start', advancedPreview: true },
|
||||
},
|
||||
host.cookie,
|
||||
)
|
||||
).status >= 400,
|
||||
);
|
||||
for (const [cookie, me] of [
|
||||
[host.cookie, host.data.me],
|
||||
[peer.cookie, peer.data.me],
|
||||
]) {
|
||||
const restored = await request(path, undefined, cookie);
|
||||
assert.equal(restored.status, 200);
|
||||
assert.equal(restored.data.me, me);
|
||||
assert.equal(restored.data.version, version);
|
||||
assert.equal(restored.data.advancedPreview, true);
|
||||
assert.ok(
|
||||
restored.data.players.find((p) => p.id === me)!.traitorChoices?.length,
|
||||
);
|
||||
for (const rival of restored.data.players.filter((p) => p.id !== me))
|
||||
for (const field of ['hand', 'spice', 'traitors', 'traitorChoices'])
|
||||
assert.equal(Object.hasOwn(rival, field), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame,
|
||||
initializeBaseGameForAudit, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
||||
import { createLeaderSkills } from '../game/leader-skills';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
|
||||
const BASE = FACTIONS.filter(entry => entry.expansion === 'base').map(entry => entry.id);
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
const act = (game: Game, owner: string, action: Action) => reload(applyAction(reload(game), owner, action));
|
||||
const preview = { type: 'start', advancedPreview: true };
|
||||
function lobby(roster: FactionId[] = ['atreides', 'harkonnen'], advanced = false) {
|
||||
const game = createGame('ADVANCEDPREVIEW', newPlayer(roster[0], roster[0], roster[0]), advanced);
|
||||
for (const id of roster.slice(1)) joinGame(game, newPlayer(id, id, id));
|
||||
return game;
|
||||
}
|
||||
function ready(game: Game) {
|
||||
for (const p of game.players) if (!p.ready) game = act(game, p.id, { type: 'ready' });
|
||||
return game;
|
||||
}
|
||||
function reject(game: Game, owner: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, owner, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function completedSetup(initial: Game, difficulty: Difficulty = 'Easy') {
|
||||
let game = initial;
|
||||
for (let step = 0; game.status === 'setup' && step < 25; step++) {
|
||||
const pending = viewGame(game, game.host).setupPending;
|
||||
assert.ok(pending.length, `${difficulty}: ${game.setupStage} has an entitled setup actor.`);
|
||||
const id = pending[0], view = viewGame(game, id);
|
||||
view.players.find(candidate => candidate.id === id)!.bot = difficulty;
|
||||
for (const other of view.players.filter(candidate => candidate.id !== id)) {
|
||||
assert.equal(other.hand, undefined);
|
||||
assert.equal(other.traitors, undefined);
|
||||
assert.equal(other.traitorChoices, undefined);
|
||||
assert.equal(other.prediction, undefined);
|
||||
}
|
||||
assert.equal('deck' in view, false);
|
||||
assert.equal('traitorReserve' in view, false);
|
||||
const before = structuredClone(view), actions = botActions(view);
|
||||
assert.deepEqual(view, before);
|
||||
assert.ok(actions.length, `${difficulty}: ${game.setupStage}/${id} can continue legally.`);
|
||||
game = act(game, id, actions[0]);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.setupStage, undefined);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)].map(card => card.id).sort();
|
||||
assert.deepEqual(cards, baseDeck().map(card => card.id).sort());
|
||||
const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices])];
|
||||
assert.equal(new Set(traitors).size, traitors.length);
|
||||
assert.deepEqual(traitors.sort(), game.players.flatMap(p => p.leaders.map(leader => leader.id)).sort());
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
|
||||
assert.equal(p.traitors.length, p.faction === 'harkonnen' ? 4 : 1);
|
||||
if (game.advanced && ['fremen', 'emperor'].includes(p.faction)) {
|
||||
assert.ok(p.elites);
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, amount) => sum + amount, 0),
|
||||
p.faction === 'fremen' ? 3 : 5);
|
||||
} else assert.equal(p.elites, undefined);
|
||||
if (p.faction === 'beneGesserit') {
|
||||
assert.ok(p.prediction);
|
||||
assert.equal(p.reserves, 19);
|
||||
if (game.advanced) assert.equal(p.advisorSetup, true);
|
||||
}
|
||||
assert.equal(viewGame(game, p.id).advancedPreview, game.advancedPreview === true);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('Basic stays the default and its ordinary start does not opt into Advanced preview', () => {
|
||||
const game = ready(lobby());
|
||||
assert.equal(game.advanced, false);
|
||||
assert.equal(game.advancedPreview, undefined);
|
||||
assert.equal(viewGame(game, game.host).advancedPreview, false);
|
||||
reject(game, game.host, preview);
|
||||
const started = act(game, game.host, { type: 'start' });
|
||||
assert.equal(started.advanced, false);
|
||||
assert.equal(started.advancedPreview, undefined);
|
||||
const done = completedSetup(started);
|
||||
reject(done, done.host, { type: 'rules', advanced: true });
|
||||
reject(done, done.host, preview);
|
||||
});
|
||||
|
||||
void test('only the host can select a boolean rules mode, changes clear human readiness, and Basic clears unused Stronghold Cards but retains Tech Tokens', () => {
|
||||
let game = lobby(['atreides', 'emperor']);
|
||||
game = act(game, game.host, { type: 'addBot', faction: 'fremen', difficulty: 'Hard' });
|
||||
game = act(game, game.host, { type: 'techTokens', enabled: true });
|
||||
game = ready(game);
|
||||
const tokens = structuredClone(game.techTokens);
|
||||
const bot = game.players.find(p => p.bot)!;
|
||||
reject(game, 'emperor', { type: 'rules', advanced: true });
|
||||
reject(game, bot.id, { type: 'rules', advanced: true });
|
||||
for (const advanced of [undefined, null, 1, 0, 'true', 'false', {}, []])
|
||||
reject(game, game.host, { type: 'rules', advanced });
|
||||
reject(game, game.host, { type: 'rules', advanced: true, advancedPreview: true });
|
||||
game = act(game, game.host, { type: 'rules', advanced: true });
|
||||
assert.equal(game.advanced, true);
|
||||
assert.equal(game.advancedPreview, undefined);
|
||||
assert.deepEqual(game.players.map(p => [p.id, p.ready]), game.players.map(p => [p.id, !!p.bot]));
|
||||
assert.match(game.log.at(-1)!.text, /Advanced preview/);
|
||||
game = act(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
game = ready(game);
|
||||
assert.deepEqual(act(game, game.host, { type: 'rules', advanced: true }), game, 'A rules no-op preserves readiness and logs.');
|
||||
game = act(game, game.host, { type: 'rules', advanced: false });
|
||||
assert.equal(game.strongholdCards, null);
|
||||
assert.deepEqual(game.techTokens, tokens);
|
||||
assert.ok(game.players.every(p => p.ready === !!p.bot));
|
||||
assert.match(game.log.at(-1)!.text, /Stronghold Cards were disabled/);
|
||||
assert.deepEqual(act(game, game.host, { type: 'rules', advanced: false }), game);
|
||||
reject(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
completedSetup(act(ready(game), game.host, { type: 'start' }));
|
||||
});
|
||||
|
||||
void test('Advanced starts require the exact host opt-in and cannot dispatch audit initializers or hidden bypass options', () => {
|
||||
const game = ready(lobby(BASE, true));
|
||||
reject(game, game.host, { type: 'start' });
|
||||
reject(game, game.players[1].id, preview);
|
||||
for (const advancedPreview of [false, 1, 'true', null, {}])
|
||||
reject(game, game.host, { type: 'start', advancedPreview });
|
||||
for (const extra of [{ audit: true }, { advanced: false }, { allowUnsupported: true }, { profile: 'ix' }, { expansions: [] }])
|
||||
reject(game, game.host, { ...preview, ...extra });
|
||||
reject(game, game.host, { type: 'initializeBaseGameForAudit' });
|
||||
const unready = reload(game);
|
||||
unready.players[1].ready = false;
|
||||
reject(unready, unready.host, preview);
|
||||
const started = act(game, game.host, preview);
|
||||
assert.equal(started.status, 'setup');
|
||||
assert.equal(started.setupStage, 'prediction');
|
||||
assert.equal(started.advancedPreview, true);
|
||||
assert.ok(started.players.every(p => !p.hand.length && !p.traitors.length && !p.traitorChoices.length));
|
||||
reject(started, started.host, preview);
|
||||
reject(started, started.host, { type: 'rules', advanced: false });
|
||||
});
|
||||
|
||||
void test('the explicit preview keeps expansion, module and separate development-profile gates closed at rules selection and start', () => {
|
||||
const changes: ((game: Game) => void)[] = [
|
||||
...['ix', 'choam', 'ecaz'].map(expansion => (game: Game) => { game.expansions = [expansion]; }),
|
||||
...FACTIONS.filter(entry => entry.expansion !== 'base').map(entry => (game: Game) => {
|
||||
game.players[1] = { ...newPlayer(game.players[1].id, entry.name, entry.id), ready: true };
|
||||
}),
|
||||
game => { game.homeworlds = { custody: null }; },
|
||||
game => { game.nexusCards = { cards: null, phase: null }; },
|
||||
game => { game.discoveryEnabled = true; },
|
||||
game => { game.leaderSkills = createLeaderSkills(() => 0.25); },
|
||||
game => { game.ecazTreachery = true; },
|
||||
game => { game.mentatQuestionPreview = true; },
|
||||
game => { game.moritaniAssassinatePreview = true; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = ready(lobby());
|
||||
change(game);
|
||||
reject(game, game.host, { type: 'rules', advanced: true });
|
||||
game.advanced = true;
|
||||
reject(game, game.host, preview);
|
||||
}
|
||||
});
|
||||
|
||||
void test('preview start validates a fresh lobby and refuses existing pieces or module ownership instead of redealing', () => {
|
||||
const changes: ((game: Game) => void)[] = [
|
||||
game => { game.turn = 2; },
|
||||
game => { game.phase = 1; },
|
||||
game => { game.deck.push(baseDeck()[0]); },
|
||||
game => { game.players[0].hand.push(baseDeck()[0]); },
|
||||
game => { game.players[0].spice = 1; },
|
||||
game => { game.players[0].forces = { 'arrakeen:10': 1 }; game.players[0].reserves--; },
|
||||
game => { game.players[1].id = game.players[0].id; },
|
||||
game => { game.players[1].faction = game.players[0].faction; },
|
||||
game => { game.playerPositions![game.players[1].id] = game.playerPositions![game.players[0].id]; },
|
||||
game => { game.advancedPreview = true; },
|
||||
game => { game.techTokens = createTechTokens(); game.techTokens.production.owner = game.host; },
|
||||
game => { game.strongholdCards = createStrongholdCards(); game.strongholdCards.owners.arrakeen = game.host; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = ready(lobby(['atreides', 'harkonnen', 'emperor'], true));
|
||||
change(game);
|
||||
reject(game, game.host, preview);
|
||||
}
|
||||
const one = ready(lobby(['atreides'], true));
|
||||
reject(one, one.host, preview);
|
||||
});
|
||||
|
||||
void test('existing Tech Tokens and Advanced Stronghold Cards use their normal setup validation and initial ownership', () => {
|
||||
let game = lobby(['atreides', 'fremen', 'beneGesserit']);
|
||||
game = act(game, game.host, { type: 'rules', advanced: true });
|
||||
game = act(game, game.host, { type: 'techTokens', enabled: true });
|
||||
game = act(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
const done = completedSetup(act(ready(game), game.host, preview), 'Hard');
|
||||
assert.equal(done.advancedPreview, true);
|
||||
assert.deepEqual(done.strongholdCards, createStrongholdCards());
|
||||
assert.deepEqual(done.techTokens, createTechTokens(done.players));
|
||||
let two = lobby(['atreides', 'guild'], true);
|
||||
two = act(two, two.host, { type: 'techTokens', enabled: true });
|
||||
reject(ready(two), two.host, preview);
|
||||
});
|
||||
|
||||
void test('fresh setup rejects altered native leaders and prior player ledgers before either normal start or audit initialization', () => {
|
||||
const changes: ((game: Game) => void)[] = [
|
||||
game => { game.players[0].leaders.pop(); },
|
||||
game => { game.players[0].leaders[1] = structuredClone(game.players[0].leaders[0]); },
|
||||
game => { game.players[0].leaders[0].name = 'Changed leader'; },
|
||||
game => { game.players[0].leaders[0].strength++; },
|
||||
game => { game.players[0].leaders[0].dead = true; },
|
||||
game => { game.players[0].leaders[0].deaths = 1; },
|
||||
game => { game.players[0].leaders[0].usedAt = 'arrakeen'; },
|
||||
game => { game.players[0].leaders[0].capturedBy = game.players[1].id; },
|
||||
game => { game.players[0].leaders[0].gholaBy = game.players[1].id; },
|
||||
game => { game.players[0].leaders[0].controller = game.players[0].id; },
|
||||
game => { game.players[0].ally = game.players[1].id; },
|
||||
game => { game.players[0].allySinceTurn = 1; },
|
||||
game => { game.players[0].bribes = 1; },
|
||||
game => { game.players[0].revived = 1; },
|
||||
game => { game.players[0].freeForcesRevived = 1; },
|
||||
game => { game.players[0].leaderRevived = true; },
|
||||
game => { game.players[0].revivalCycle = 1; },
|
||||
game => { game.players[0].battleLosses = 7; },
|
||||
game => { game.players[0].kwisatz = { dead: false }; },
|
||||
game => { game.players[0].specialKaramaUsed = true; },
|
||||
game => { game.players[0].charityTurn = 1; },
|
||||
game => { game.players[0].faceDancers = []; },
|
||||
game => { game.players[0].revealedTraitors = [game.players[0].leaders[0].id]; },
|
||||
game => { game.players[0].gholaBlocked = { stale: 1 }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = ready(lobby(['atreides', 'harkonnen'], true));
|
||||
change(game);
|
||||
const before = structuredClone(game);
|
||||
reject(game, game.host, preview);
|
||||
assert.throws(() => initializeBaseGameForAudit(game));
|
||||
assert.deepEqual(game, before);
|
||||
game.advanced = false;
|
||||
reject(game, game.host, { type: 'start' });
|
||||
}
|
||||
});
|
||||
|
||||
void test('normal base start cannot retain undeclared card zones, physical resources or stale continuations', () => {
|
||||
const changes: ((game: Game) => void)[] = [
|
||||
game => { game.richeseCache = [baseDeck()[0]]; },
|
||||
game => { game.richeseRemoved = [baseDeck()[0]]; },
|
||||
game => { game.ixSetupCards = [baseDeck()[0]]; },
|
||||
game => { game.spice = { 'the_great_flat:15': 9 }; },
|
||||
game => { game.aid[game.host] = { recipient: game.players[1].id, amount: 4 }; },
|
||||
game => { game.freeRevival = [game.host]; },
|
||||
game => { game.emperorExtra[game.host] = 2; },
|
||||
game => { game.hajr = [game.host]; },
|
||||
game => { game.stormDials[game.host] = 1; },
|
||||
game => { game.mobileStronghold = { location: 'polar_sink:0' }; },
|
||||
game => { game.pendingIxAlly = { player: game.host, card: baseDeck()[0].id, free: false }; },
|
||||
game => { game.truthHistory = []; },
|
||||
game => { Reflect.deleteProperty(game, 'allianceOffers'); },
|
||||
game => { Reflect.deleteProperty(game, 'stormDials'); },
|
||||
game => { game.botsPending = true; },
|
||||
game => { game.botNextActionAt = Date.now(); },
|
||||
];
|
||||
for (const advanced of [false, true]) for (const change of changes) {
|
||||
const game = ready(lobby(['atreides', 'harkonnen'], advanced));
|
||||
change(game);
|
||||
reject(game, game.host, advanced ? preview : { type: 'start' });
|
||||
}
|
||||
});
|
||||
|
||||
void test('ordinary lobby configuration and persisted storage metadata remain compatible with strict fresh-state admission', () => {
|
||||
let game = lobby(['atreides', 'harkonnen']);
|
||||
game = act(game, game.host, { type: 'addBot', faction: 'emperor', difficulty: 'Medium' });
|
||||
const bot = game.players.find(p => p.bot)!;
|
||||
game = act(game, game.host, { type: 'configureBot', target: bot.id, difficulty: 'Hard', faction: 'fremen', position: 6 });
|
||||
game = act(game, 'harkonnen', { type: 'faction', faction: 'guild' });
|
||||
game = act(game, game.host, { type: 'homeworlds', enabled: true });
|
||||
game = act(game, game.host, { type: 'homeworlds', enabled: false });
|
||||
game = act(game, game.host, { type: 'rules', advanced: true });
|
||||
game = act(game, game.host, { type: 'techTokens', enabled: true });
|
||||
game = act(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
game = ready(game);
|
||||
game.version = 42;
|
||||
game.botsPending = false;
|
||||
const positions = structuredClone(game.playerPositions);
|
||||
const names = game.players.map(p => p.name);
|
||||
const done = completedSetup(act(game, game.host, preview));
|
||||
assert.equal(done.version, 42);
|
||||
assert.deepEqual(done.playerPositions, positions);
|
||||
assert.deepEqual(done.players.map(p => p.name), names);
|
||||
assert.equal(done.players.find(p => p.id === bot.id)!.bot, 'Hard');
|
||||
});
|
||||
|
||||
void test('all 57 base rosters from 2 through 6 complete normal Advanced preview setup with each of four legal AI profiles and JSON custody', () => {
|
||||
for (let mask = 1; mask < 2 ** BASE.length; mask++) {
|
||||
const roster = BASE.filter((_, index) => mask & (1 << index));
|
||||
if (roster.length < 2) continue;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = ready(lobby(roster, true));
|
||||
const done = completedSetup(act(game, game.host, preview), difficulty);
|
||||
assert.equal(done.advancedPreview, true);
|
||||
assert.equal(done.advanced, true);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('saved preview marker cannot silently change mode or introduce an unsupported profile', () => {
|
||||
const game = completedSetup(act(ready(lobby(['atreides', 'guild'], true)), 'atreides', preview));
|
||||
for (const change of [
|
||||
(state: Game) => { state.advanced = false; },
|
||||
(state: Game) => { state.status = 'lobby'; },
|
||||
(state: Game) => { state.expansions = ['ix']; },
|
||||
(state: Game) => { state.discoveryEnabled = true; },
|
||||
(state: Game) => { Object.assign(state, { advancedPreview: false }); },
|
||||
]) {
|
||||
const corrupt = reload(game);
|
||||
change(corrupt);
|
||||
const before = structuredClone(corrupt);
|
||||
assert.throws(() => viewGame(corrupt, corrupt.host));
|
||||
assert.throws(() => normalizeAutomaticGame(corrupt));
|
||||
reject(corrupt, corrupt.host, { type: 'advanceBots' });
|
||||
assert.deepEqual(corrupt, before);
|
||||
}
|
||||
});
|
||||
@@ -110,8 +110,13 @@ void test('real isolated initializer adds the Advanced Auditor before prediction
|
||||
const before = structuredClone(g);
|
||||
assert.throws(
|
||||
() => applyAction(g, 'c', { type: 'start' }),
|
||||
/still being implemented/,
|
||||
advanced ? /Advanced preview/ : /still being implemented/,
|
||||
);
|
||||
if (advanced)
|
||||
assert.throws(
|
||||
() => applyAction(g, 'c', { type: 'start', advancedPreview: true }),
|
||||
/Expansion factions are still being implemented/,
|
||||
);
|
||||
assert.throws(() => initializeBaseGameForAudit(g), /base factions/);
|
||||
assert.deepEqual(g, before);
|
||||
initialize(g);
|
||||
|
||||
Reference in new issue
Block a user