This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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)}`),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user