Adjust Discord publication and lobby capacity defaults
CI / checks (push) Failing after 7m21s

This commit is contained in:
2026-10-18 17:23:50 -04:00
parent fd00ef5357
commit d715173719
15 changed files with 304 additions and 89 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ EC_SETUP_TOKEN=
# Telemetry ingestion is disabled until a token of at least 32 chars is set. # Telemetry ingestion is disabled until a token of at least 32 chars is set.
EC_TELEMETRY_TOKEN= EC_TELEMETRY_TOKEN=
# Optional native Discord companion. Keep these in the controller environment; # Optional native Discord companion. Keep these in the controller environment;
# enable the integration and choose published servers under Admin → Discord. # enable the integration and optionally exclude servers under Admin → Discord.
# The application ID and public key are in the Discord Developer Portal. # The application ID and public key are in the Discord Developer Portal.
# The bot token is a secret: never put it in channel messages or screenshots. # The bot token is a secret: never put it in channel messages or screenshots.
EC_DISCORD_APPLICATION_ID= EC_DISCORD_APPLICATION_ID=
+16 -9
View File
@@ -4,7 +4,8 @@ Electric Cherry can publish live Black Ops I and Black Ops II server boards in
Discord, answer commands privately, and send requested map availability alerts by Discord, answer commands privately, and send requested map availability alerts by
DM. Configure it under **Admin → Discord**. Administrators can make changes; DM. Configure it under **Admin → Discord**. Administrators can make changes;
Super Viewers can inspect configuration, the saved preview, and delivery health. Super Viewers can inspect configuration, the saved preview, and delivery health.
The integration is disabled by default and publishes no servers until selected. The integration is disabled by default. Once enabled, it publishes all current
and newly created game servers unless an administrator excludes them.
## Install the application ## Install the application
@@ -32,8 +33,8 @@ The integration is disabled by default and publishes no servers until selected.
the endpoint before accepting it. The controller checks Discord's request the endpoint before accepting it. The controller checks Discord's request
signatures; this endpoint does not use the Electric Cherry login session. signatures; this endpoint does not use the Electric Cherry login session.
5. In Discord, enable Developer Mode, then copy the Discord server ID and the 5. In Discord, enable Developer Mode, then copy the Discord server ID and the
board channel IDs. Open **Admin → Discord**, enter those IDs, and select the board channel IDs. Open **Admin → Discord**, enter those IDs, and uncheck any
game servers to publish. Either game may omit its board channel. game servers you want to exclude. Either game may omit its board channel.
6. Review the information-sharing switches, enable the companion, and save. 6. Review the information-sharing switches, enable the companion, and save.
Select **Register commands** to install the commands in the saved Discord Select **Register commands** to install the commands in the saved Discord
server. Saving configuration and registering commands are separate actions. server. Saving configuration and registering commands are separate actions.
@@ -51,9 +52,11 @@ action updates that application's command set in the configured Discord server.
## Choose what is published ## Choose what is published
The published-server selection is an explicit allowlist. Newly created game All current and newly created game servers are included by default. Uncheck a
servers stay private until an administrator selects them. The selection controls server to exclude it from boards, private server browsing, server details, and
boards, private server browsing, server details, and availability watches. availability watches. Exclusions are retained if a game server is removed, so a
server with the same name remains excluded if it returns. Check an excluded
server again to remove that exclusion.
Player names in server details and public connection addresses are separate, Player names in server details and public connection addresses are separate,
disabled-by-default options. Connection instructions use an explicitly configured disabled-by-default options. Connection instructions use an explicitly configured
@@ -63,7 +66,7 @@ without either switch enabled.
**Publish fleet leaderboards** is a separate decision. It exposes recorded player **Publish fleet leaderboards** is a separate decision. It exposes recorded player
names and statistics from the whole fleet, including historical matches on names and statistics from the whole fleet, including historical matches on
servers outside the published-server selection. Leave it disabled if those excluded servers. Leave it disabled if those
statistics should remain available only to Electric Cherry users. statistics should remain available only to Electric Cherry users.
The settings page's preview uses the **saved settings**. Pending form edits do The settings page's preview uses the **saved settings**. Pending form edits do
@@ -81,6 +84,9 @@ Boards show observed occupancy, capacity and current round where available,
with separate empty, in-progress, full, starting, offline, not-responding, and with separate empty, in-progress, full, starting, offline, not-responding, and
unknown states. Freshness is part of the observation. **Empty slots do not prove unknown states. Freshness is part of the observation. **Empty slots do not prove
that a match accepts new players**; this release does not label games joinable. that a match accepts new players**; this release does not label games joinable.
Capacity defaults to four players, matching the fleet UI, until a fresh reported
capacity overrides it. A default capacity does not make stale player counts
current or allow a stale observation to trigger an availability alert.
Members can use the board controls or slash commands. Browsing, filters, details, Members can use the board controls or slash commands. Browsing, filters, details,
leaderboards, and command confirmations are visible only to the member who leaderboards, and command confirmations are visible only to the member who
@@ -107,8 +113,9 @@ Each member may have up to ten active watches. Requesting the same map and
condition again reuses the existing watch. Members can cancel only their own condition again reuses the existing watch. Members can cancel only their own
watches. watches.
An **empty** watch needs an observed empty lobby. A **space** watch needs a known An **empty** watch needs an observed empty lobby. A **space** watch needs fewer
capacity and fewer players than that capacity. Neither condition promises that players than the current capacity, using the four-player default when no fresh
capacity override is available. Neither condition promises that
joining is allowed. Unknown, stale, and offline observations never satisfy a joining is allowed. Unknown, stale, and offline observations never satisfy a
watch. The worker requires two qualifying observations at least 15 seconds apart watch. The worker requires two qualifying observations at least 15 seconds apart
before queueing a notification. before queueing a notification.
+7 -7
View File
@@ -4,7 +4,7 @@ export interface DiscordSettings {
enabled: boolean; enabled: boolean;
guildId: string; guildId: string;
channels: Record<DiscordGame, string>; channels: Record<DiscordGame, string>;
publishedServers: string[]; excludedServers: string[];
showPlayers: boolean; showPlayers: boolean;
showAddresses: boolean; showAddresses: boolean;
leaderboardEnabled: boolean; leaderboardEnabled: boolean;
@@ -14,7 +14,7 @@ export const defaultDiscordSettings: DiscordSettings = {
enabled: false, enabled: false,
guildId: '', guildId: '',
channels: { t5: '', t6: '' }, channels: { t5: '', t6: '' },
publishedServers: [], excludedServers: [],
showPlayers: false, showPlayers: false,
showAddresses: false, showAddresses: false,
leaderboardEnabled: false leaderboardEnabled: false
@@ -43,20 +43,20 @@ export function validateDiscordSettings(value: unknown): DiscordSettings {
throw new Error('Channel IDs must be valid Discord IDs or empty.'); throw new Error('Channel IDs must be valid Discord IDs or empty.');
} }
if ( if (
!Array.isArray(input.publishedServers) || !Array.isArray(input.excludedServers) ||
input.publishedServers.length > 100 || input.excludedServers.length > 100 ||
input.publishedServers.some( input.excludedServers.some(
(name) => typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(name) (name) => typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(name)
) )
) )
throw new Error('Choose up to 100 published servers.'); throw new Error('Choose up to 100 servers to exclude from Discord.');
if (input.enabled && (!input.guildId || (!channels.t5 && !channels.t6))) if (input.enabled && (!input.guildId || (!channels.t5 && !channels.t6)))
throw new Error('Choose a Discord server and at least one board channel before enabling.'); throw new Error('Choose a Discord server and at least one board channel before enabling.');
return { return {
enabled: input.enabled as boolean, enabled: input.enabled as boolean,
guildId: input.guildId, guildId: input.guildId,
channels: { t5: channels.t5 as string, t6: channels.t6 as string }, channels: { t5: channels.t5 as string, t6: channels.t6 as string },
publishedServers: [...new Set(input.publishedServers as string[])].sort(), excludedServers: [...new Set(input.excludedServers as string[])].sort(),
showPlayers: input.showPlayers as boolean, showPlayers: input.showPlayers as boolean,
showAddresses: input.showAddresses as boolean, showAddresses: input.showAddresses as boolean,
leaderboardEnabled: input.leaderboardEnabled as boolean leaderboardEnabled: input.leaderboardEnabled as boolean
+7 -1
View File
@@ -27,6 +27,7 @@ import { migratePlayerScoreTotal } from './player-score-total.js';
import { migrateBank } from './bank.js'; import { migrateBank } from './bank.js';
import { migrateAfk } from './afk.js'; import { migrateAfk } from './afk.js';
import { migrateDiscord } from '../discord/schema.js'; import { migrateDiscord } from '../discord/schema.js';
import { migrateDiscordPublicationDefaults } from '../discord/publication-defaults.js';
import { migrateBankCredentials } from './bank-credentials.js'; import { migrateBankCredentials } from './bank-credentials.js';
import { migrateTemperatures } from '../hosts/temperatures.js'; import { migrateTemperatures } from '../hosts/temperatures.js';
import { import {
@@ -212,7 +213,12 @@ export const migrations: readonly Migration[] = [
mode: 'foreign-keys-off', mode: 'foreign-keys-off',
run: migrateModerationCaseStates run: migrateModerationCaseStates
}, },
{ version: 37, name: 'Discord boards and availability subscriptions', run: migrateDiscord } { version: 37, name: 'Discord boards and availability subscriptions', run: migrateDiscord },
{
version: 38,
name: 'Discord default server publication',
run: migrateDiscordPublicationDefaults
}
]; ];
export const databaseVersion = migrations.length; export const databaseVersion = migrations.length;
+11 -4
View File
@@ -14,6 +14,8 @@ type SourceServer = FleetServer & {
network?: { publicAddress: string | null; publicPort: number | null } | null; network?: { publicAddress: string | null; publicPort: number | null } | null;
}; };
export const DEFAULT_DISCORD_LOBBY_CAPACITY = 4;
/** IDs stay short enough for Discord components, even for long registry names. */ /** IDs stay short enough for Discord components, even for long registry names. */
export function discordServerKey(name: string): string { export function discordServerKey(name: string): string {
return createHash('sha256').update(name).digest('hex').slice(0, 20); return createHash('sha256').update(name).digest('hex').slice(0, 20);
@@ -69,19 +71,24 @@ export function projectFleet(
settings: DiscordSettings, settings: DiscordSettings,
now = Date.now() now = Date.now()
): DiscordPublicServer[] { ): DiscordPublicServer[] {
const published = new Set(settings.publishedServers); const excluded = new Set(settings.excludedServers);
return servers return servers
.flatMap((server): DiscordPublicServer[] => { .flatMap((server): DiscordPublicServer[] => {
if (!published.has(server.name)) return []; if (excluded.has(server.name)) return [];
const identity = fleetIdentity(server); const identity = fleetIdentity(server);
if (identity.game !== 't5' && identity.game !== 't6') return []; if (identity.game !== 't5' && identity.game !== 't6') return [];
const game = identity.game; const game = identity.game;
const fresh = freshServer(server, now); const fresh =
freshServer(server, now) &&
(!server.telemetry_after ||
Date.parse(server.last_seen_at ?? '') > Date.parse(server.telemetry_after));
const players = const players =
fresh && Number.isSafeInteger(server.last_players) && server.last_players! >= 0 fresh && Number.isSafeInteger(server.last_players) && server.last_players! >= 0
? server.last_players ? server.last_players
: null; : null;
const capacity = players !== null ? currentCapacity(server, now) : null; // These lobbies have four slots when the live status omits the limit.
// Occupancy still needs fresh, current-process evidence below.
const capacity = currentCapacity(server, now) ?? DEFAULT_DISCORD_LOBBY_CAPACITY;
const state: DiscordPublicServer['state'] = const state: DiscordPublicServer['state'] =
server.health === 'starting' || server.health === 'provisioning' server.health === 'starting' || server.health === 'provisioning'
? 'starting' ? 'starting'
@@ -0,0 +1,24 @@
import type Database from 'better-sqlite3';
/** Replace the old opt-in default while preserving an existing nonempty selection. */
export function migrateDiscordPublicationDefaults(handle: Database.Database): void {
const row = handle.prepare('SELECT value FROM discord_configuration WHERE id=1').get() as
{ value: string } | undefined;
if (!row) return;
const settings = JSON.parse(row.value) as Record<string, unknown>;
if ('excludedServers' in settings) return;
const published = new Set(settings.publishedServers as string[]);
settings.excludedServers = published.size
? (
handle
.prepare('SELECT name FROM servers WHERE destroyed_at IS NULL ORDER BY name')
.all() as { name: string }[]
)
.map((server) => server.name)
.filter((name) => !published.has(name))
: [];
delete settings.publishedServers;
handle
.prepare('UPDATE discord_configuration SET value=? WHERE id=1')
.run(JSON.stringify(settings));
}
+3 -3
View File
@@ -8,7 +8,7 @@ import {
} from '../../leaderboard-efficiency.js'; } from '../../leaderboard-efficiency.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { gameTitle } from '../../ui/server-labels.js'; import { gameTitle } from '../../ui/server-labels.js';
import { discordServerKey, discordText } from './projection.js'; import { DEFAULT_DISCORD_LOBBY_CAPACITY, discordServerKey, discordText } from './projection.js';
export const BOARD_PAGE_SIZE = 8; export const BOARD_PAGE_SIZE = 8;
export const BROWSER_PAGE_SIZE = 5; export const BROWSER_PAGE_SIZE = 5;
@@ -65,7 +65,7 @@ function summary(server: DiscordPublicServer): string {
const count = const count =
server.players === null server.players === null
? 'Players unknown' ? 'Players unknown'
: `${server.players}/${server.capacity ?? '?'} players`; : `${server.players}/${server.capacity ?? DEFAULT_DISCORD_LOBBY_CAPACITY} players`;
return `${count}${server.round === null ? '' : ` · Round ${server.round}`}\n${states[server.state]}`; return `${count}${server.round === null ? '' : ` · Round ${server.round}`}\n${states[server.state]}`;
} }
@@ -250,7 +250,7 @@ export function renderWatchMaps(
export function renderWatchChoice(server: DiscordPublicServer): DiscordMessage { export function renderWatchChoice(server: DiscordPublicServer): DiscordMessage {
return discordMessage([ return discordMessage([
text( text(
`## Watch ${discordText(server.map)}\nEmpty means zero observed players. Space means fewer players than the current observed capacity. Neither guarantees that a match accepts new players.\nOne DM · Expires in 24 hours` `## Watch ${discordText(server.map)}\nEmpty means zero observed players. Space means fewer players than the lobby capacity. Neither guarantees that a match accepts new players.\nOne DM · Expires in 24 hours`
), ),
row( row(
button('Empty lobby', `ec:w:empty:${discordServerKey(server.mapKey)}`), button('Empty lobby', `ec:w:empty:${discordServerKey(server.mapKey)}`),
+2 -2
View File
@@ -6,7 +6,7 @@ import type {
DiscordSettings DiscordSettings
} from '../../discord.js'; } from '../../discord.js';
import { requireDiscordCredentials } from './config.js'; import { requireDiscordCredentials } from './config.js';
import { discordText, publicFleet } from './projection.js'; import { DEFAULT_DISCORD_LOBBY_CAPACITY, discordText, publicFleet } from './projection.js';
import { renderBoard } from './render.js'; import { renderBoard } from './render.js';
import { DiscordStore, discordStore, type DiscordJob, type DiscordSubscription } from './store.js'; import { DiscordStore, discordStore, type DiscordJob, type DiscordSubscription } from './store.js';
import { DiscordDeliveryError, DiscordRest, type DiscordTransport } from './transport.js'; import { DiscordDeliveryError, DiscordRest, type DiscordTransport } from './transport.js';
@@ -373,7 +373,7 @@ function matchingServer(
function alertMessage(watch: DiscordSubscription, server: DiscordPublicServer): DiscordMessage { function alertMessage(watch: DiscordSubscription, server: DiscordPublicServer): DiscordMessage {
const safe = (value: string) => discordText(value, 100); const safe = (value: string) => discordText(value, 100);
return { return {
content: `🍒 **${safe(server.map)}** ${watch.mode === 'empty' ? 'has an empty lobby' : 'has an open player slot'}: **${safe(server.label)}** (${server.players}/${server.capacity ?? '?'}).\nThis describes occupancy; joining an in-progress game may still be restricted.\nYour one-time map watch is complete. Use /servers to browse or /watch to watch again.`, content: `🍒 **${safe(server.map)}** ${watch.mode === 'empty' ? 'has an empty lobby' : 'has an open player slot'}: **${safe(server.label)}** (${server.players}/${server.capacity ?? DEFAULT_DISCORD_LOBBY_CAPACITY}).\nThis describes occupancy; joining an in-progress game may still be restricted.\nYour one-time map watch is complete. Use /servers to browse or /watch to watch again.`,
allowed_mentions: { parse: [] } allowed_mentions: { parse: [] }
}; };
} }
@@ -4,7 +4,6 @@ import { discordAdminState } from '$lib/server/discord/admin.js';
import { requireDiscordCredentials } from '$lib/server/discord/config.js'; import { requireDiscordCredentials } from '$lib/server/discord/config.js';
import { saveDiscordSettings } from '$lib/server/discord/store.js'; import { saveDiscordSettings } from '$lib/server/discord/store.js';
import { jsonBody } from '$lib/server/request-body.js'; import { jsonBody } from '$lib/server/request-body.js';
import { listServers } from '$lib/server/fleet/servers.js';
import { resetDiscordClient } from '$lib/server/discord/worker.js'; import { resetDiscordClient } from '$lib/server/discord/worker.js';
export function GET() { export function GET() {
@@ -15,14 +14,6 @@ export async function PUT({ request }: { request: Request }) {
try { try {
const settings = validateDiscordSettings(await jsonBody(request, 32 * 1024)); const settings = validateDiscordSettings(await jsonBody(request, 32 * 1024));
if (settings.enabled) requireDiscordCredentials(); if (settings.enabled) requireDiscordCredentials();
const known = new Set(listServers().map((server) => server.name));
if (settings.publishedServers.some((name) => !known.has(name)))
return json(
{
error: 'One of the selected servers is no longer available. Refresh and try again.'
},
{ status: 400 }
);
saveDiscordSettings(settings); saveDiscordSettings(settings);
resetDiscordClient(); resetDiscordClient();
return json(discordAdminState()); return json(discordAdminState());
@@ -43,11 +43,15 @@
adminState.credentials.publicKeyConfigured && adminState.credentials.publicKeyConfigured &&
adminState.credentials.botTokenConfigured adminState.credentials.botTokenConfigured
); );
const unavailableSelections = $derived( const unavailableExclusions = $derived(
draft?.publishedServers.filter( draft?.excludedServers.filter(
(name) => !adminState?.servers.some((server) => server.name === name) (name) => !adminState?.servers.some((server) => server.name === name)
) ?? [] ) ?? []
); );
const includedServerCount = $derived(
adminState?.servers.filter((server) => !draft?.excludedServers.includes(server.name))
.length ?? 0
);
onMount(() => { onMount(() => {
origin = window.location.origin; origin = window.location.origin;
@@ -85,9 +89,9 @@
function chooseServer(name: string, checked: boolean) { function chooseServer(name: string, checked: boolean) {
if (!draft || !administrator) return; if (!draft || !administrator) return;
draft.publishedServers = checked draft.excludedServers = checked
? [...new Set([...draft.publishedServers, name])].sort() ? draft.excludedServers.filter((excluded) => excluded !== name)
: draft.publishedServers.filter((selected) => selected !== name); : [...new Set([...draft.excludedServers, name])].sort();
} }
function discard() { function discard() {
@@ -192,7 +196,8 @@
Enable Discord companion Enable Discord companion
</label> </label>
<p class="help"> <p class="help">
Enabling publishes the selected servers to your configured channels. Enabling publishes all servers except your exclusions to the configured
channels.
</p> </p>
<Field <Field
label="Discord server ID" label="Discord server ID"
@@ -229,8 +234,8 @@
<fieldset class="panel" disabled={!administrator || busy}> <fieldset class="panel" disabled={!administrator || busy}>
<legend>Published servers</legend> <legend>Published servers</legend>
<p class="help"> <p class="help">
Only selected servers appear in Discord browsing, boards, and All current and new servers are published by default. Uncheck a server
availability alerts. New servers remain private until selected. to exclude it from Discord browsing, boards, and availability alerts.
</p> </p>
{#if adminState.servers.length === 0}<p class="muted"> {#if adminState.servers.length === 0}<p class="muted">
No supported game servers are available. No supported game servers are available.
@@ -246,7 +251,7 @@
<label class="server-choice"> <label class="server-choice">
<input <input
type="checkbox" type="checkbox"
checked={draft.publishedServers.includes( checked={!draft.excludedServers.includes(
server.name server.name
)} )}
onchange={(event) => onchange={(event) =>
@@ -265,20 +270,21 @@
</div> </div>
{/if} {/if}
{/each} {/each}
{#if unavailableSelections.length} {#if unavailableExclusions.length}
<div class="server-group"> <div class="server-group">
<h3>No longer in the fleet</h3> <h3>Excluded servers no longer in the fleet</h3>
{#each unavailableSelections as name (name)} {#each unavailableExclusions as name (name)}
<label class="server-choice"> <label class="server-choice">
<input <input
type="checkbox" type="checkbox"
checked checked={false}
onchange={(event) => onchange={(event) =>
chooseServer(name, event.currentTarget.checked)} chooseServer(name, event.currentTarget.checked)}
/> />
<span <span
>{name}<small >{name}<small
>Remove this selection to stop publishing it.</small >Excluded if this server returns. Check to remove
the exclusion.</small
></span ></span
> >
</label> </label>
@@ -286,7 +292,7 @@
</div> </div>
{/if} {/if}
<p class="help"> <p class="help">
{countLabel(draft.publishedServers.length, 'server')} selected. {countLabel(includedServerCount, 'server')} included.
</p> </p>
</fieldset> </fieldset>
@@ -308,8 +314,8 @@
> >
<p class="help"> <p class="help">
Leaderboards publish player names and recorded statistics from the Leaderboards publish player names and recorded statistics from the
entire fleet, including servers outside the selection above. Keep this entire fleet, including excluded servers. Keep this off if those results
off if those results should stay private. should stay private.
</p> </p>
</fieldset> </fieldset>
@@ -477,14 +483,14 @@
<span class="muted">Saved settings</span> <span class="muted">Saved settings</span>
</div> </div>
<p class="help"> <p class="help">
This shows the information available to Discord from your saved selection. Empty This shows the information available to Discord with your saved exclusions.
slots do not establish whether a game accepts new players. Empty slots do not establish whether a game accepts new players.
</p> </p>
{#if !adminState.settings.enabled}<p class="muted"> {#if !adminState.settings.enabled}<p class="muted">
Discord is disabled. This preview is visible only in Electric Cherry. Discord is disabled. This preview is visible only in Electric Cherry.
</p>{/if} </p>{/if}
{#if !adminState.preview.t5.length && !adminState.preview.t6.length} {#if !adminState.preview.t5.length && !adminState.preview.t6.length}
<p class="muted">Select servers and save your changes to see a preview.</p> <p class="muted">No servers are included by the saved settings.</p>
{:else} {:else}
<div class="preview-games"> <div class="preview-games">
{#each games as game} {#each games as game}
@@ -503,7 +509,7 @@
</div> </div>
<p> <p>
{server.map} · {server.players ?? '?'} / {server.capacity ?? {server.map} · {server.players ?? '?'} / {server.capacity ??
'?'} players{server.round === null 4} players{server.round === null
? '' ? ''
: ` · Round ${server.round}`} : ` · Round ${server.round}`}
</p> </p>
+75 -11
View File
@@ -31,7 +31,7 @@ function snapshot(): DiscordAdminState {
enabled: false, enabled: false,
guildId, guildId,
channels: { t5: channelId, t6: '' }, channels: { t5: channelId, t6: '' },
publishedServers: [], excludedServers: [],
showPlayers: false, showPlayers: false,
showAddresses: false, showAddresses: false,
leaderboardEnabled: false leaderboardEnabled: false
@@ -57,7 +57,25 @@ function snapshot(): DiscordAdminState {
map: 'Kino der Toten' map: 'Kino der Toten'
} }
], ],
preview: { t5: [], t6: [] } preview: {
t5: [
{
name: serverName,
game: 't5',
label: 'Kino der Toten · Lobby 1',
map: 'Kino der Toten',
mapKey: 't5:zombie_theater',
state: 'empty',
players: 0,
capacity: 4,
round: null,
observedAt: '2026-10-18T20:00:00.000Z',
playerNames: [],
connection: null
}
],
t6: []
}
}; };
} }
@@ -103,8 +121,9 @@ async function show(role = 'admin') {
}); });
} }
it('saves explicit publication choices without credentials and restores saved values on discard', async () => { it('includes servers by default, saves an exclusion without credentials, and restores it on discard', async () => {
const initial = snapshot(); const initial = snapshot();
initial.preview.t5[0].capacity = null;
let submitted: DiscordSettings | undefined; let submitted: DiscordSettings | undefined;
const fetch = vi.fn(async (_url: string, init?: RequestInit) => { const fetch = vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') { if (init?.method === 'PUT') {
@@ -117,11 +136,15 @@ it('saves explicit publication choices without credentials and restores saved va
await show(); await show();
expect(checkbox('Enable Discord companion').checked).toBe(false); expect(checkbox('Enable Discord companion').checked).toBe(false);
expect(checkbox(serverName).checked).toBe(false); expect(checkbox(serverName).checked).toBe(true);
expect(checkbox('Show player names').checked).toBe(false); expect(checkbox('Show player names').checked).toBe(false);
expect(checkbox('Show public server connection addresses').checked).toBe(false); expect(checkbox('Show public server connection addresses').checked).toBe(false);
expect(checkbox('Publish fleet leaderboards').checked).toBe(false); expect(checkbox('Publish fleet leaderboards').checked).toBe(false);
expect(document.body.textContent).toContain('including servers outside the selection above'); expect(document.body.textContent).toContain('including excluded servers');
expect(document.body.textContent).toContain('1 server included.');
expect(document.querySelector('.preview-server')?.textContent?.replace(/\s+/g, ' ')).toContain(
'0 / 4 players'
);
toggle('Enable Discord companion'); toggle('Enable Discord companion');
toggle(serverName); toggle(serverName);
@@ -136,7 +159,7 @@ it('saves explicit publication choices without credentials and restores saved va
...initial.settings, ...initial.settings,
enabled: true, enabled: true,
guildId: '45678901234567890', guildId: '45678901234567890',
publishedServers: [serverName], excludedServers: [serverName],
showPlayers: true, showPlayers: true,
leaderboardEnabled: true leaderboardEnabled: true
}); });
@@ -149,7 +172,7 @@ it('saves explicit publication choices without credentials and restores saved va
button('Discard').click(); button('Discard').click();
flushSync(); flushSync();
expect(checkbox('Show player names').checked).toBe(true); expect(checkbox('Show player names').checked).toBe(true);
expect(checkbox(serverName).checked).toBe(true); expect(checkbox(serverName).checked).toBe(false);
expect(button('Save changes').disabled).toBe(true); expect(button('Save changes').disabled).toBe(true);
}); });
@@ -174,11 +197,18 @@ it('keeps Super Viewer controls read-only and rejects a directly submitted mutat
); );
}); });
it('preserves publication edits and the last snapshot across polling failures and recovery', async () => { it('automatically includes new servers on polling while retaining draft exclusions and recovering from errors', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const initial = snapshot(); const initial = snapshot();
const updated = snapshot(); const updated = snapshot();
updated.status.activeSubscriptions = 3; updated.status.activeSubscriptions = 3;
const newServer = 't6_zm_tomb_1';
updated.servers.push({
name: newServer,
game: 't6',
label: 'Origins · Lobby 1',
map: 'Origins'
});
const fetch = vi const fetch = vi
.fn() .fn()
.mockResolvedValueOnce(Response.json(initial)) .mockResolvedValueOnce(Response.json(initial))
@@ -195,17 +225,51 @@ it('preserves publication edits and the last snapshot across polling failures an
'Discord status unavailable.' 'Discord status unavailable.'
); );
expect(document.body.textContent).toContain('Kino der Toten · Lobby 1'); expect(document.body.textContent).toContain('Kino der Toten · Lobby 1');
expect(checkbox(serverName).checked).toBe(true); expect(checkbox(serverName).checked).toBe(false);
expect(field('Discord server ID').value).toBe('45678901234567890'); expect(field('Discord server ID').value).toBe('45678901234567890');
button('Retry loading').click(); await vi.advanceTimersByTimeAsync(60_000);
await vi.waitFor(() => expect(document.querySelector('[role="alert"]')).toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="alert"]')).toBeNull());
expect(checkbox(serverName).checked).toBe(true); expect(checkbox(serverName).checked).toBe(false);
expect(checkbox(newServer).checked).toBe(true);
expect(document.body.textContent).toContain('1 server included.');
expect(field('Discord server ID').value).toBe('45678901234567890'); expect(field('Discord server ID').value).toBe('45678901234567890');
expect(button('Save changes').disabled).toBe(false); expect(button('Save changes').disabled).toBe(false);
expect(document.body.textContent).toContain('Unsaved changes'); expect(document.body.textContent).toContain('Unsaved changes');
}); });
it('retains exclusions for removed servers until the administrator explicitly removes them', async () => {
const initial = snapshot();
const removedServer = 't5_zombie_cosmodrome_1';
initial.settings.excludedServers = [removedServer];
const writes: DiscordSettings[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
const settings = JSON.parse(String(init.body)) as DiscordSettings;
writes.push(settings);
return Response.json({ ...initial, settings });
}
return Response.json(initial);
})
);
await show();
expect(checkbox(removedServer).checked).toBe(false);
expect(document.body.textContent).toContain('Excluded if this server returns.');
expect(document.body.textContent).toContain('1 server included.');
toggle('Show player names');
button('Save changes').click();
await vi.waitFor(() => expect(button('Save changes').disabled).toBe(true));
expect(writes[0].excludedServers).toEqual([removedServer]);
toggle(removedServer);
button('Save changes').click();
await vi.waitFor(() => expect(writes).toHaveLength(2));
expect(writes[1].excludedServers).toEqual([]);
expect(document.body.textContent).not.toContain('Excluded servers no longer in the fleet');
});
it('registers commands only on an explicit action and retains its error through successful refreshes', async () => { it('registers commands only on an explicit action and retains its error through successful refreshes', async () => {
const fetch = vi.fn(async (url: string) => const fetch = vi.fn(async (url: string) =>
url.endsWith('/register') url.endsWith('/register')
+3 -3
View File
@@ -42,7 +42,7 @@ beforeEach(() => {
enabled: true, enabled: true,
guildId, guildId,
channels: { t5: channelId, t6: '' }, channels: { t5: channelId, t6: '' },
publishedServers: ['kino'] excludedServers: []
}); });
server = { server = {
name: 'kino', name: 'kino',
@@ -68,7 +68,7 @@ function worker(rest: DiscordTransport, render = () => [message('Public board')]
return new DiscordWorker({ return new DiscordWorker({
store, store,
transport: () => rest, transport: () => rest,
fleet: (settings) => (settings.publishedServers.includes(server.name) ? [server] : []), fleet: (settings) => (!settings.excludedServers.includes(server.name) ? [server] : []),
render, render,
now: () => now now: () => now
}); });
@@ -337,7 +337,7 @@ describe('Discord one-time availability notifications', () => {
const watch = store.subscribe(watchInput); const watch = store.subscribe(watchInput);
const { rest, request } = transport((method, path) => { const { rest, request } = transport((method, path) => {
if (path === '/users/@me/channels') if (path === '/users/@me/channels')
store.saveSettings({ ...store.settings(), publishedServers: [] }); store.saveSettings({ ...store.settings(), excludedServers: [server.name] });
}); });
const instance = worker(rest, () => []); const instance = worker(rest, () => []);
await instance.tick(); await instance.tick();
+64 -13
View File
@@ -122,7 +122,7 @@ beforeEach(() => {
enabled: true, enabled: true,
guildId, guildId,
channels: { t5: '444444444444444444', t6: '' }, channels: { t5: '444444444444444444', t6: '' },
publishedServers: [name] excludedServers: []
}; };
vi.mocked(getDiscordSettings).mockImplementation(() => settings); vi.mocked(getDiscordSettings).mockImplementation(() => settings);
vi.mocked(fleetSnapshot).mockReturnValue({ servers: [server()] } as ReturnType< vi.mocked(fleetSnapshot).mockReturnValue({ servers: [server()] } as ReturnType<
@@ -137,7 +137,22 @@ afterEach(() => {
}); });
describe('Discord publication boundary', () => { describe('Discord publication boundary', () => {
it('projects only opted-in servers and explicitly approved fields', () => { it('publishes current and newly added servers by default while respecting exclusions', () => {
const original = server();
const added = server({ name: 't5_zombie_theater_2' });
expect(projectFleet([original], settings, now).map((item) => item.name)).toEqual([name]);
expect(projectFleet([original, added], settings, now).map((item) => item.name)).toEqual([
name,
added.name
]);
expect(
projectFleet([original, added], { ...settings, excludedServers: [name] }, now).map(
(item) => item.name
)
).toEqual([added.name]);
});
it('excludes unpublished servers and projects only explicitly approved fields', () => {
const raw = { const raw = {
...server(), ...server(),
host: { id: 'internal-host' }, host: { id: 'internal-host' },
@@ -147,7 +162,7 @@ describe('Discord publication boundary', () => {
}; };
const projected = projectFleet( const projected = projectFleet(
[raw as unknown as FleetServer, server({ name: 'unpublished' })], [raw as unknown as FleetServer, server({ name: 'unpublished' })],
settings, { ...settings, excludedServers: ['unpublished'] },
now now
); );
expect(projected).toHaveLength(1); expect(projected).toHaveLength(1);
@@ -195,17 +210,54 @@ describe('Discord publication boundary', () => {
const projected = projectFleet([server(change)], settings, now)[0]; const projected = projectFleet([server(change)], settings, now)[0];
expect(projected).toMatchObject({ state, players }); expect(projected).toMatchObject({ state, players });
if (players === null) if (players === null)
expect(projected).toMatchObject({ capacity: null, round: null, playerNames: [] }); expect(projected).toMatchObject({ capacity: 4, round: null, playerNames: [] });
}); });
it('never treats stale or previous-process capacity as an available space', () => { it('uses the four-player lobby limit when current capacity is unavailable', () => {
for (const change of [ for (const change of [
{ capacity_observed_at: new Date(now - 100000).toISOString() }, { capacity: null, capacity_observed_at: null },
{ telemetry_after: new Date(now - 5000).toISOString() } { capacity: 0 },
{ capacity: 8, capacity_observed_at: new Date(now - 100000).toISOString() },
{
capacity: 8,
capacity_observed_at: new Date(now - 20000).toISOString(),
telemetry_after: new Date(now - 15000).toISOString()
}
]) { ]) {
const projected = projectFleet([server(change)], settings, now); const projected = projectFleet([server(change)], settings, now);
expect(projected[0].capacity).toBeNull(); expect(projected[0].capacity).toBe(4);
expect(filterServers(projected, { status: 'space' })).toEqual(projected);
for (const message of [
...renderBoard('t5', projected),
renderBrowser(projected),
renderServer(projected[0])
]) {
expect(messageText(message)).toContain('2/4 players');
expect(messageText(message)).not.toContain('2/?');
}
}
const full = projectFleet([server({ last_players: 4, capacity: null })], settings, now);
expect(full[0]).toMatchObject({ capacity: 4, state: 'full' });
expect(filterServers(full, { status: 'space' })).toEqual([]);
});
it('uses a current measured capacity when available', () => {
const projected = projectFleet([server({ capacity: 8, last_players: 4 })], settings, now);
expect(projected[0]).toMatchObject({ capacity: 8, state: 'in-progress' });
expect(messageText(renderServer(projected[0]))).toContain('4/8 players');
expect(filterServers(projected, { status: 'space' })).toEqual(projected);
});
it('never treats stale or previous-process occupancy as an available space', () => {
for (const change of [
{ last_seen_at: new Date(now - 100000).toISOString() },
{ health: 'stale' as const },
{ telemetry_after: new Date(now - 5000).toISOString() }
]) {
const projected = projectFleet([server({ ...change, capacity: null })], settings, now);
expect(projected[0]).toMatchObject({ capacity: 4, players: null });
expect(filterServers(projected, { status: 'space' })).toEqual([]); expect(filterServers(projected, { status: 'space' })).toEqual([]);
expect(messageText(renderServer(projected[0]))).toContain('Players unknown');
} }
}); });
@@ -218,12 +270,11 @@ describe('Discord publication boundary', () => {
map_slug: 'town', map_slug: 'town',
telemetry: undefined telemetry: undefined
}); });
const setup = { ...settings, publishedServers: [town.name] }; expect(projectFleet([town], settings, now)[0]).toMatchObject({
expect(projectFleet([town], setup, now)[0]).toMatchObject({
map: 'Town Survival', map: 'Town Survival',
mapKey: 't6:zm_transit:town' mapKey: 't6:zm_transit:town'
}); });
expect(projectFleet([{ ...town, last_map: 'zm_tomb' }], setup, now)[0]).toMatchObject({ expect(projectFleet([{ ...town, last_map: 'zm_tomb' }], settings, now)[0]).toMatchObject({
map: 'Origins', map: 'Origins',
mapKey: 't6:zm_tomb:tomb' mapKey: 't6:zm_tomb:tomb'
}); });
@@ -355,7 +406,7 @@ describe('Discord interaction isolation', () => {
expect(result.type).toBe(4); expect(result.type).toBe(4);
expect(result.data.flags).toBe(32832); expect(result.data.flags).toBe(32832);
expect(messageText(result.data)).toContain('Round 28'); expect(messageText(result.data)).toContain('Round 28');
settings.publishedServers = []; settings.excludedServers = [name];
expect( expect(
messageText( messageText(
( (
@@ -400,7 +451,7 @@ describe('Discord interaction isolation', () => {
}); });
expect(messageText(result.data)).toContain('one DM'); expect(messageText(result.data)).toContain('one DM');
vi.mocked(subscribeToMap).mockClear(); vi.mocked(subscribeToMap).mockClear();
settings.publishedServers = []; settings.excludedServers = [name];
await handleDiscordInteraction( await handleDiscordInteraction(
interaction({ custom_id: `ec:w:space:${discordServerKey('t5:zombie_theater')}` }, 3) interaction({ custom_id: `ec:w:space:${discordServerKey('t5:zombie_theater')}` }, 3)
); );
+58
View File
@@ -0,0 +1,58 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { defaultDiscordSettings } from '../../src/lib/discord.js';
import { migrateDiscord } from '../../src/lib/server/discord/schema.js';
import { migrateDiscordPublicationDefaults } from '../../src/lib/server/discord/publication-defaults.js';
import { DiscordStore } from '../../src/lib/server/discord/store.js';
let handle: Database.Database;
let store: DiscordStore;
beforeEach(() => {
handle = new Database(':memory:');
migrateDiscord(handle);
handle.exec(`
CREATE TABLE servers (name TEXT PRIMARY KEY, destroyed_at TEXT);
INSERT INTO servers VALUES ('kino', NULL), ('town', NULL), ('retired', '2026-10-18');
`);
store = new DiscordStore(handle);
});
afterEach(() => handle.close());
function legacySettings(publishedServers: string[]) {
const value: Record<string, unknown> = { ...defaultDiscordSettings, publishedServers };
delete value.excludedServers;
handle
.prepare('INSERT INTO discord_configuration(id,value) VALUES(1,?)')
.run(JSON.stringify(value));
}
describe('Discord publication defaults upgrade', () => {
it('includes all servers on new installations without creating configuration', () => {
migrateDiscordPublicationDefaults(handle);
expect(store.settings()).toEqual(defaultDiscordSettings);
expect(store.settings().excludedServers).toEqual([]);
expect(handle.prepare('SELECT * FROM discord_configuration').all()).toEqual([]);
});
it('replaces the previously empty default selection without enabling Discord or optional sharing', () => {
legacySettings([]);
migrateDiscordPublicationDefaults(handle);
expect(store.settings()).toEqual(defaultDiscordSettings);
const value = (
handle.prepare('SELECT value FROM discord_configuration').get() as { value: string }
).value;
expect(JSON.parse(value)).not.toHaveProperty('publishedServers');
});
it('preserves existing nonempty selections as exclusions, and retains later opt-outs on replay', () => {
legacySettings(['kino']);
migrateDiscordPublicationDefaults(handle);
expect(store.settings().excludedServers).toEqual(['town']);
handle.exec("INSERT INTO servers VALUES ('moon', NULL)");
expect(store.settings().excludedServers).not.toContain('moon');
store.saveSettings({ ...store.settings(), excludedServers: ['kino', 'town'] });
const before = handle.prepare('SELECT * FROM discord_configuration').all();
migrateDiscordPublicationDefaults(handle);
expect(handle.prepare('SELECT * FROM discord_configuration').all()).toEqual(before);
});
});
+6 -5
View File
@@ -199,9 +199,10 @@ describe('Discord signed webhook boundary', () => {
}); });
describe('Discord configuration and access', () => { describe('Discord configuration and access', () => {
it('defaults to no publication and validates IDs and an explicit bounded allowlist', () => { it('defaults to all servers with sharing disabled and validates bounded exclusions', () => {
expect(validateDiscordSettings(defaultDiscordSettings)).toEqual(defaultDiscordSettings); expect(validateDiscordSettings(defaultDiscordSettings)).toEqual(defaultDiscordSettings);
expect(defaultDiscordSettings.enabled).toBe(false); expect(defaultDiscordSettings.enabled).toBe(false);
expect(defaultDiscordSettings.excludedServers).toEqual([]);
expect(defaultDiscordSettings.showPlayers).toBe(false); expect(defaultDiscordSettings.showPlayers).toBe(false);
expect(defaultDiscordSettings.showAddresses).toBe(false); expect(defaultDiscordSettings.showAddresses).toBe(false);
expect(defaultDiscordSettings.leaderboardEnabled).toBe(false); expect(defaultDiscordSettings.leaderboardEnabled).toBe(false);
@@ -214,20 +215,20 @@ describe('Discord configuration and access', () => {
expect(() => expect(() =>
validateDiscordSettings({ validateDiscordSettings({
...defaultDiscordSettings, ...defaultDiscordSettings,
publishedServers: ['../../secrets'] excludedServers: ['../../secrets']
}) })
).toThrow(); ).toThrow();
expect(() => expect(() =>
validateDiscordSettings({ validateDiscordSettings({
...defaultDiscordSettings, ...defaultDiscordSettings,
publishedServers: Array(101).fill('t5_map_1') excludedServers: Array(101).fill('t5_map_1')
}) })
).toThrow(); ).toThrow();
expect( expect(
validateDiscordSettings({ validateDiscordSettings({
...defaultDiscordSettings, ...defaultDiscordSettings,
publishedServers: ['t5_map_2', 't5_map_1', 't5_map_1'] excludedServers: ['t5_map_2', 't5_map_1', 't5_map_1']
}).publishedServers }).excludedServers
).toEqual(['t5_map_1', 't5_map_2']); ).toEqual(['t5_map_1', 't5_map_2']);
}); });