diff --git a/.env.example b/.env.example index 0fdb43e..da0f75f 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ EC_SETUP_TOKEN= # Telemetry ingestion is disabled until a token of at least 32 chars is set. EC_TELEMETRY_TOKEN= # 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 bot token is a secret: never put it in channel messages or screenshots. EC_DISCORD_APPLICATION_ID= diff --git a/docs/guides/discord.md b/docs/guides/discord.md index d61918d..1393057 100644 --- a/docs/guides/discord.md +++ b/docs/guides/discord.md @@ -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 DM. Configure it under **Admin → Discord**. Administrators can make changes; 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 @@ -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 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 - board channel IDs. Open **Admin → Discord**, enter those IDs, and select the - game servers to publish. Either game may omit its board channel. + board channel IDs. Open **Admin → Discord**, enter those IDs, and uncheck any + game servers you want to exclude. Either game may omit its board channel. 6. Review the information-sharing switches, enable the companion, and save. Select **Register commands** to install the commands in the saved Discord 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 -The published-server selection is an explicit allowlist. Newly created game -servers stay private until an administrator selects them. The selection controls -boards, private server browsing, server details, and availability watches. +All current and newly created game servers are included by default. Uncheck a +server to exclude it from boards, private server browsing, server details, and +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, 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 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. 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 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. +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, 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 watches. -An **empty** watch needs an observed empty lobby. A **space** watch needs a known -capacity and fewer players than that capacity. Neither condition promises that +An **empty** watch needs an observed empty lobby. A **space** watch needs fewer +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 watch. The worker requires two qualifying observations at least 15 seconds apart before queueing a notification. diff --git a/src/lib/discord.ts b/src/lib/discord.ts index cebc025..8a250a3 100644 --- a/src/lib/discord.ts +++ b/src/lib/discord.ts @@ -4,7 +4,7 @@ export interface DiscordSettings { enabled: boolean; guildId: string; channels: Record; - publishedServers: string[]; + excludedServers: string[]; showPlayers: boolean; showAddresses: boolean; leaderboardEnabled: boolean; @@ -14,7 +14,7 @@ export const defaultDiscordSettings: DiscordSettings = { enabled: false, guildId: '', channels: { t5: '', t6: '' }, - publishedServers: [], + excludedServers: [], showPlayers: false, showAddresses: 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.'); } if ( - !Array.isArray(input.publishedServers) || - input.publishedServers.length > 100 || - input.publishedServers.some( + !Array.isArray(input.excludedServers) || + input.excludedServers.length > 100 || + input.excludedServers.some( (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))) throw new Error('Choose a Discord server and at least one board channel before enabling.'); return { enabled: input.enabled as boolean, guildId: input.guildId, 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, showAddresses: input.showAddresses as boolean, leaderboardEnabled: input.leaderboardEnabled as boolean diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index eb71cec..1517948 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -27,6 +27,7 @@ import { migratePlayerScoreTotal } from './player-score-total.js'; import { migrateBank } from './bank.js'; import { migrateAfk } from './afk.js'; import { migrateDiscord } from '../discord/schema.js'; +import { migrateDiscordPublicationDefaults } from '../discord/publication-defaults.js'; import { migrateBankCredentials } from './bank-credentials.js'; import { migrateTemperatures } from '../hosts/temperatures.js'; import { @@ -212,7 +213,12 @@ export const migrations: readonly Migration[] = [ mode: 'foreign-keys-off', 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; diff --git a/src/lib/server/discord/projection.ts b/src/lib/server/discord/projection.ts index bc91529..9fd6312 100644 --- a/src/lib/server/discord/projection.ts +++ b/src/lib/server/discord/projection.ts @@ -14,6 +14,8 @@ type SourceServer = FleetServer & { 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. */ export function discordServerKey(name: string): string { return createHash('sha256').update(name).digest('hex').slice(0, 20); @@ -69,19 +71,24 @@ export function projectFleet( settings: DiscordSettings, now = Date.now() ): DiscordPublicServer[] { - const published = new Set(settings.publishedServers); + const excluded = new Set(settings.excludedServers); return servers .flatMap((server): DiscordPublicServer[] => { - if (!published.has(server.name)) return []; + if (excluded.has(server.name)) return []; const identity = fleetIdentity(server); if (identity.game !== 't5' && identity.game !== 't6') return []; 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 = fresh && Number.isSafeInteger(server.last_players) && server.last_players! >= 0 ? server.last_players : 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'] = server.health === 'starting' || server.health === 'provisioning' ? 'starting' diff --git a/src/lib/server/discord/publication-defaults.ts b/src/lib/server/discord/publication-defaults.ts new file mode 100644 index 0000000..093e6ef --- /dev/null +++ b/src/lib/server/discord/publication-defaults.ts @@ -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; + 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)); +} diff --git a/src/lib/server/discord/render.ts b/src/lib/server/discord/render.ts index f5c5364..21103ff 100644 --- a/src/lib/server/discord/render.ts +++ b/src/lib/server/discord/render.ts @@ -8,7 +8,7 @@ import { } from '../../leaderboard-efficiency.js'; import { MIN_RECORDED_ROUND } from '../../match-history.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 BROWSER_PAGE_SIZE = 5; @@ -65,7 +65,7 @@ function summary(server: DiscordPublicServer): string { const count = server.players === null ? '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]}`; } @@ -250,7 +250,7 @@ export function renderWatchMaps( export function renderWatchChoice(server: DiscordPublicServer): DiscordMessage { return discordMessage([ 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( button('Empty lobby', `ec:w:empty:${discordServerKey(server.mapKey)}`), diff --git a/src/lib/server/discord/worker.ts b/src/lib/server/discord/worker.ts index 092d96a..679ef6c 100644 --- a/src/lib/server/discord/worker.ts +++ b/src/lib/server/discord/worker.ts @@ -6,7 +6,7 @@ import type { DiscordSettings } from '../../discord.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 { DiscordStore, discordStore, type DiscordJob, type DiscordSubscription } from './store.js'; import { DiscordDeliveryError, DiscordRest, type DiscordTransport } from './transport.js'; @@ -373,7 +373,7 @@ function matchingServer( function alertMessage(watch: DiscordSubscription, server: DiscordPublicServer): DiscordMessage { const safe = (value: string) => discordText(value, 100); 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: [] } }; } diff --git a/src/routes/api/integrations/discord/+server.ts b/src/routes/api/integrations/discord/+server.ts index 1d143c0..c92344b 100644 --- a/src/routes/api/integrations/discord/+server.ts +++ b/src/routes/api/integrations/discord/+server.ts @@ -4,7 +4,6 @@ import { discordAdminState } from '$lib/server/discord/admin.js'; import { requireDiscordCredentials } from '$lib/server/discord/config.js'; import { saveDiscordSettings } from '$lib/server/discord/store.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'; export function GET() { @@ -15,14 +14,6 @@ export async function PUT({ request }: { request: Request }) { try { const settings = validateDiscordSettings(await jsonBody(request, 32 * 1024)); 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); resetDiscordClient(); return json(discordAdminState()); diff --git a/src/routes/settings/integrations/discord/+page.svelte b/src/routes/settings/integrations/discord/+page.svelte index 5807fa2..2b039fd 100644 --- a/src/routes/settings/integrations/discord/+page.svelte +++ b/src/routes/settings/integrations/discord/+page.svelte @@ -43,11 +43,15 @@ adminState.credentials.publicKeyConfigured && adminState.credentials.botTokenConfigured ); - const unavailableSelections = $derived( - draft?.publishedServers.filter( + const unavailableExclusions = $derived( + draft?.excludedServers.filter( (name) => !adminState?.servers.some((server) => server.name === name) ) ?? [] ); + const includedServerCount = $derived( + adminState?.servers.filter((server) => !draft?.excludedServers.includes(server.name)) + .length ?? 0 + ); onMount(() => { origin = window.location.origin; @@ -85,9 +89,9 @@ function chooseServer(name: string, checked: boolean) { if (!draft || !administrator) return; - draft.publishedServers = checked - ? [...new Set([...draft.publishedServers, name])].sort() - : draft.publishedServers.filter((selected) => selected !== name); + draft.excludedServers = checked + ? draft.excludedServers.filter((excluded) => excluded !== name) + : [...new Set([...draft.excludedServers, name])].sort(); } function discard() { @@ -192,7 +196,8 @@ Enable Discord companion

- Enabling publishes the selected servers to your configured channels. + Enabling publishes all servers except your exclusions to the configured + channels.

Published servers

- Only selected servers appear in Discord browsing, boards, and - availability alerts. New servers remain private until selected. + All current and new servers are published by default. Uncheck a server + to exclude it from Discord browsing, boards, and availability alerts.

{#if adminState.servers.length === 0}

No supported game servers are available. @@ -246,7 +251,7 @@

-

No longer in the fleet

- {#each unavailableSelections as name (name)} +

Excluded servers no longer in the fleet

+ {#each unavailableExclusions as name (name)} @@ -286,7 +292,7 @@
{/if}

- {countLabel(draft.publishedServers.length, 'server')} selected. + {countLabel(includedServerCount, 'server')} included.

@@ -308,8 +314,8 @@ >

Leaderboards publish player names and recorded statistics from the - entire fleet, including servers outside the selection above. Keep this - off if those results should stay private. + entire fleet, including excluded servers. Keep this off if those results + should stay private.

@@ -477,14 +483,14 @@ Saved settings

- This shows the information available to Discord from your saved selection. Empty - slots do not establish whether a game accepts new players. + This shows the information available to Discord with your saved exclusions. + Empty slots do not establish whether a game accepts new players.

{#if !adminState.settings.enabled}

Discord is disabled. This preview is visible only in Electric Cherry.

{/if} {#if !adminState.preview.t5.length && !adminState.preview.t6.length} -

Select servers and save your changes to see a preview.

+

No servers are included by the saved settings.

{:else}
{#each games as game} @@ -503,7 +509,7 @@

{server.map} · {server.players ?? '?'} / {server.capacity ?? - '?'} players{server.round === null + 4} players{server.round === null ? '' : ` · Round ${server.round}`}

diff --git a/tests/client/discord-settings.test.ts b/tests/client/discord-settings.test.ts index 12dce81..1447bdc 100644 --- a/tests/client/discord-settings.test.ts +++ b/tests/client/discord-settings.test.ts @@ -31,7 +31,7 @@ function snapshot(): DiscordAdminState { enabled: false, guildId, channels: { t5: channelId, t6: '' }, - publishedServers: [], + excludedServers: [], showPlayers: false, showAddresses: false, leaderboardEnabled: false @@ -57,7 +57,25 @@ function snapshot(): DiscordAdminState { 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(); + initial.preview.t5[0].capacity = null; let submitted: DiscordSettings | undefined; const fetch = vi.fn(async (_url: string, init?: RequestInit) => { if (init?.method === 'PUT') { @@ -117,11 +136,15 @@ it('saves explicit publication choices without credentials and restores saved va await show(); 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 public server connection addresses').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(serverName); @@ -136,7 +159,7 @@ it('saves explicit publication choices without credentials and restores saved va ...initial.settings, enabled: true, guildId: '45678901234567890', - publishedServers: [serverName], + excludedServers: [serverName], showPlayers: true, leaderboardEnabled: true }); @@ -149,7 +172,7 @@ it('saves explicit publication choices without credentials and restores saved va button('Discard').click(); flushSync(); 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); }); @@ -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(); const initial = snapshot(); const updated = snapshot(); 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 .fn() .mockResolvedValueOnce(Response.json(initial)) @@ -195,17 +225,51 @@ it('preserves publication edits and the last snapshot across polling failures an 'Discord status unavailable.' ); 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'); - button('Retry loading').click(); + await vi.advanceTimersByTimeAsync(60_000); 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(button('Save changes').disabled).toBe(false); 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 () => { const fetch = vi.fn(async (url: string) => url.endsWith('/register') diff --git a/tests/unit/discord-delivery.test.ts b/tests/unit/discord-delivery.test.ts index d6e97fc..9054ebf 100644 --- a/tests/unit/discord-delivery.test.ts +++ b/tests/unit/discord-delivery.test.ts @@ -42,7 +42,7 @@ beforeEach(() => { enabled: true, guildId, channels: { t5: channelId, t6: '' }, - publishedServers: ['kino'] + excludedServers: [] }); server = { name: 'kino', @@ -68,7 +68,7 @@ function worker(rest: DiscordTransport, render = () => [message('Public board')] return new DiscordWorker({ store, transport: () => rest, - fleet: (settings) => (settings.publishedServers.includes(server.name) ? [server] : []), + fleet: (settings) => (!settings.excludedServers.includes(server.name) ? [server] : []), render, now: () => now }); @@ -337,7 +337,7 @@ describe('Discord one-time availability notifications', () => { const watch = store.subscribe(watchInput); const { rest, request } = transport((method, path) => { if (path === '/users/@me/channels') - store.saveSettings({ ...store.settings(), publishedServers: [] }); + store.saveSettings({ ...store.settings(), excludedServers: [server.name] }); }); const instance = worker(rest, () => []); await instance.tick(); diff --git a/tests/unit/discord-interactions.test.ts b/tests/unit/discord-interactions.test.ts index a4529b2..602a6f0 100644 --- a/tests/unit/discord-interactions.test.ts +++ b/tests/unit/discord-interactions.test.ts @@ -122,7 +122,7 @@ beforeEach(() => { enabled: true, guildId, channels: { t5: '444444444444444444', t6: '' }, - publishedServers: [name] + excludedServers: [] }; vi.mocked(getDiscordSettings).mockImplementation(() => settings); vi.mocked(fleetSnapshot).mockReturnValue({ servers: [server()] } as ReturnType< @@ -137,7 +137,22 @@ afterEach(() => { }); 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 = { ...server(), host: { id: 'internal-host' }, @@ -147,7 +162,7 @@ describe('Discord publication boundary', () => { }; const projected = projectFleet( [raw as unknown as FleetServer, server({ name: 'unpublished' })], - settings, + { ...settings, excludedServers: ['unpublished'] }, now ); expect(projected).toHaveLength(1); @@ -195,17 +210,54 @@ describe('Discord publication boundary', () => { const projected = projectFleet([server(change)], settings, now)[0]; expect(projected).toMatchObject({ state, players }); 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 [ - { capacity_observed_at: new Date(now - 100000).toISOString() }, - { telemetry_after: new Date(now - 5000).toISOString() } + { capacity: null, capacity_observed_at: null }, + { 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); - 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(messageText(renderServer(projected[0]))).toContain('Players unknown'); } }); @@ -218,12 +270,11 @@ describe('Discord publication boundary', () => { map_slug: 'town', telemetry: undefined }); - const setup = { ...settings, publishedServers: [town.name] }; - expect(projectFleet([town], setup, now)[0]).toMatchObject({ + expect(projectFleet([town], settings, now)[0]).toMatchObject({ map: 'Town Survival', 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', mapKey: 't6:zm_tomb:tomb' }); @@ -355,7 +406,7 @@ describe('Discord interaction isolation', () => { expect(result.type).toBe(4); expect(result.data.flags).toBe(32832); expect(messageText(result.data)).toContain('Round 28'); - settings.publishedServers = []; + settings.excludedServers = [name]; expect( messageText( ( @@ -400,7 +451,7 @@ describe('Discord interaction isolation', () => { }); expect(messageText(result.data)).toContain('one DM'); vi.mocked(subscribeToMap).mockClear(); - settings.publishedServers = []; + settings.excludedServers = [name]; await handleDiscordInteraction( interaction({ custom_id: `ec:w:space:${discordServerKey('t5:zombie_theater')}` }, 3) ); diff --git a/tests/unit/discord-publication.test.ts b/tests/unit/discord-publication.test.ts new file mode 100644 index 0000000..4a2fa56 --- /dev/null +++ b/tests/unit/discord-publication.test.ts @@ -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 = { ...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); + }); +}); diff --git a/tests/unit/discord-webhook.test.ts b/tests/unit/discord-webhook.test.ts index 72aa14f..bec487b 100644 --- a/tests/unit/discord-webhook.test.ts +++ b/tests/unit/discord-webhook.test.ts @@ -199,9 +199,10 @@ describe('Discord signed webhook boundary', () => { }); 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(defaultDiscordSettings.enabled).toBe(false); + expect(defaultDiscordSettings.excludedServers).toEqual([]); expect(defaultDiscordSettings.showPlayers).toBe(false); expect(defaultDiscordSettings.showAddresses).toBe(false); expect(defaultDiscordSettings.leaderboardEnabled).toBe(false); @@ -214,20 +215,20 @@ describe('Discord configuration and access', () => { expect(() => validateDiscordSettings({ ...defaultDiscordSettings, - publishedServers: ['../../secrets'] + excludedServers: ['../../secrets'] }) ).toThrow(); expect(() => validateDiscordSettings({ ...defaultDiscordSettings, - publishedServers: Array(101).fill('t5_map_1') + excludedServers: Array(101).fill('t5_map_1') }) ).toThrow(); expect( validateDiscordSettings({ ...defaultDiscordSettings, - publishedServers: ['t5_map_2', 't5_map_1', 't5_map_1'] - }).publishedServers + excludedServers: ['t5_map_2', 't5_map_1', 't5_map_1'] + }).excludedServers ).toEqual(['t5_map_1', 't5_map_2']); });