Files
Dune/game/admin-lobby-configuration.ts
T
Quintine e896570f07
Verify and publish checkpoint container / container (push) Successful in 11m2s
Add optional short alliance victory
2026-10-28 21:40:24 +11:00

306 lines
9.7 KiB
TypeScript

import { applyAction, createGame, newPlayer, type Game } from './engine';
import { FACTIONS } from './catalog';
import { DIFFICULTIES } from './bot-profiles';
import { createTechTokens } from './tech-tokens';
import { createStrongholdCards } from './stronghold-cards';
import type {
AdminLobbyAction,
AdminLobbyView,
} from '../lib/admin-lobby-configuration';
import type { RoomControl } from '../lib/room-control';
const unavailable = 'This saved lobby needs inspection before configuration.';
export class AdminLobbyRuleError extends Error {}
const reject = () => {
throw new AdminLobbyRuleError(unavailable);
};
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function same(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (Array.isArray(a) || Array.isArray(b))
return (
Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((value, index) => same(value, b[index]))
);
if (!record(a) || !record(b)) return false;
const aKeys = Object.keys(a).filter((key) => a[key] !== undefined),
bKeys = Object.keys(b).filter((key) => b[key] !== undefined);
return (
aKeys.length === bKeys.length &&
aKeys.every((key) => Object.hasOwn(b, key) && same(a[key], b[key]))
);
}
/** The same legacy/default player-circle allocation used by ordinary lobby actions. */
function positions(game: Game) {
const result: Record<string, number> = Object.create(null);
const occupied = new Set<number>();
if (
game.playerPositions !== undefined &&
(!record(game.playerPositions) ||
Object.keys(game.playerPositions).some(
(id) => !game.players.some((player) => player.id === id),
))
)
reject();
for (const player of game.players) {
const position = game.playerPositions?.[player.id];
if (position === undefined) continue;
if (
!Number.isInteger(position) ||
position < 1 ||
position > 6 ||
occupied.has(position)
)
reject();
result[player.id] = position;
occupied.add(position);
}
for (const player of game.players) {
if (result[player.id] !== undefined) continue;
const position = [1, 2, 3, 4, 5, 6].find(
(candidate) => !occupied.has(candidate),
);
if (position === undefined) reject();
result[player.id] = position!;
occupied.add(position!);
}
return result;
}
/** Compare native unused pieces and the complete runtime against a fresh game.
* Unknown runtime/private fields fail closed; diagnostics never name those fields. */
export function freshAdminLobby(game: Game): boolean {
try {
if (
game.status !== 'lobby' ||
typeof game.advanced !== 'boolean' ||
!Array.isArray(game.players) ||
game.players.length < 1 ||
game.players.length > 6 ||
!Array.isArray(game.expansions) ||
game.expansions.length ||
!Array.isArray(game.log) ||
!game.log.every(
(entry) =>
entry &&
Number.isSafeInteger(entry.seq) &&
entry.seq > 0 &&
typeof entry.text === 'string',
) ||
new Set(game.players.map((player) => player.id)).size !==
game.players.length ||
new Set(game.players.map((player) => player.faction)).size !==
game.players.length ||
(game.botsPending !== undefined && game.botsPending !== false) ||
game.botNextActionAt !== undefined
)
return false;
const host = game.players.find((player) => player.id === game.host);
if (!host || host.bot) return false;
for (const player of game.players) {
if (
!player ||
typeof player.id !== 'string' ||
!player.id ||
player.id.length > 80 ||
typeof player.name !== 'string' ||
typeof player.ready !== 'boolean' ||
!FACTIONS.some(
(faction) =>
faction.id === player.faction && faction.expansion === 'base',
) ||
(player.bot !== undefined && !DIFFICULTIES.includes(player.bot))
)
return false;
const expected = newPlayer(player.id, player.name, player.faction);
expected.ready = player.ready;
if (player.bot) expected.bot = player.bot;
// Old saved lobbies may omit this zero-valued counter, as ordinary start permits.
if (player.freeForcesRevived === undefined)
delete (expected as Partial<typeof expected>).freeForcesRevived;
if (!same(player, expected)) return false;
}
positions(game);
if (game.techTokens != null && !same(game.techTokens, createTechTokens()))
return false;
if (
game.strongholdCards != null &&
(!game.advanced || !same(game.strongholdCards, createStrongholdCards()))
)
return false;
if (
game.homeworlds != null ||
game.nexusCards != null ||
game.leaderSkills != null ||
game.ecazTreachery != null ||
(game.shortAllianceVictory !== undefined && typeof game.shortAllianceVictory !== 'boolean') ||
(game.discoveryEnabled !== undefined && game.discoveryEnabled !== false)
)
return false;
const expected = createGame(game.code, host, game.advanced);
const configuration = new Set([
'code',
'version',
'host',
'players',
'playerPositions',
'advanced',
'expansions',
'log',
'botsPending',
'botNextActionAt',
'techTokens',
'strongholdCards',
'homeworlds',
'nexusCards',
'leaderSkills',
'ecazTreachery',
'discoveryEnabled',
'shortAllianceVictory',
]);
for (const key of new Set([...Object.keys(game), ...Object.keys(expected)]))
if (
!configuration.has(key) &&
!same(
Object.hasOwn(game, key)
? (game as unknown as Record<string, unknown>)[key]
: undefined,
Object.hasOwn(expected, key)
? (expected as unknown as Record<string, unknown>)[key]
: undefined,
)
)
return false;
return true;
} catch {
return false;
}
}
export function projectAdminLobby(
code: string,
version: number,
state: unknown,
activeHumans: ReadonlySet<string>,
control: RoomControl,
): AdminLobbyView {
const base: AdminLobbyView = {
code,
version,
status: 'unreadable',
editable: false,
blockedReason: unavailable,
host: null,
advanced: false,
techTokens: false,
strongholdCards: false,
shortAllianceVictory: false,
players: [],
control,
};
try {
const game = state as Game;
if (
!record(state) ||
!['lobby', 'setup', 'playing', 'finished'].includes(game.status) ||
!Array.isArray(game.players) ||
game.players.length < 1 ||
game.players.length > 6 ||
game.players.some(
(player) =>
!player ||
typeof player.id !== 'string' ||
!player.id ||
player.id.length > 80 ||
typeof player.name !== 'string' ||
player.name.length > 32 ||
!FACTIONS.some((faction) => faction.id === player.faction) ||
(player.bot !== undefined && !DIFFICULTIES.includes(player.bot)),
)
)
return base;
const circles = positions(game);
const supported = game.code === code && freshAdminLobby(game);
return {
...base,
status: game.status,
advanced: game.advanced === true,
techTokens: !!game.techTokens,
strongholdCards: !!game.strongholdCards,
shortAllianceVictory: game.shortAllianceVictory === true,
host: game.players.some((player) => player.id === game.host)
? game.host
: null,
editable: supported && !control.paused,
blockedReason:
game.status !== 'lobby'
? 'Lobby configuration is available only before the game starts.'
: !supported
? unavailable
: control.paused
? 'Resume the room before configuring its lobby.'
: null,
players: game.players.map((player) => ({
id: player.id,
name: player.name,
faction: player.faction,
bot: player.bot ?? null,
position: circles[player.id],
ready: player.ready === true,
hostEligible: supported && !player.bot && activeHumans.has(player.id),
})),
};
} catch {
return base;
}
}
export function applyAdminLobbyAction(
game: Game,
action: AdminLobbyAction,
activeHumans: ReadonlySet<string>,
): Game {
if (!freshAdminLobby(game)) reject();
let next: Game;
if (action.type === 'assignHost') {
const target = game.players.find((player) => player.id === action.target);
if (!target || target.bot || !activeHumans.has(target.id))
throw new AdminLobbyRuleError(
'Choose an existing human player with active saved-seat access.',
);
if (target.id === game.host)
throw new AdminLobbyRuleError('That player already hosts this room.');
next = structuredClone(game);
next.host = target.id;
next.players.forEach((player) => {
player.ready = !!player.bot;
});
} else {
try {
next = applyAction(game, game.host, action);
} catch {
throw new AdminLobbyRuleError(
'That lobby change is unavailable. Check the selected rules, factions, AI seat and player circle.',
);
}
}
if (!freshAdminLobby(next)) reject();
// Ordinary engine actions retain their calculation, but must not impersonate
// the human host in the public chronicle. Administrator identity/reason stay in audit.
next.log = [
...structuredClone(game.log),
{
seq: (game.log.at(-1)?.seq ?? 0) + 1,
text:
action.type === 'assignHost'
? 'An administrator reassigned the room host. Human readiness was cleared.'
: 'An administrator updated the lobby configuration.',
},
];
return next;
}