This commit is contained in:
+7
-7
@@ -4,7 +4,7 @@ export interface DiscordSettings {
|
||||
enabled: boolean;
|
||||
guildId: string;
|
||||
channels: Record<DiscordGame, string>;
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
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)}`),
|
||||
|
||||
@@ -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: [] }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
</label>
|
||||
<p class="help">
|
||||
Enabling publishes the selected servers to your configured channels.
|
||||
Enabling publishes all servers except your exclusions to the configured
|
||||
channels.
|
||||
</p>
|
||||
<Field
|
||||
label="Discord server ID"
|
||||
@@ -229,8 +234,8 @@
|
||||
<fieldset class="panel" disabled={!administrator || busy}>
|
||||
<legend>Published servers</legend>
|
||||
<p class="help">
|
||||
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.
|
||||
</p>
|
||||
{#if adminState.servers.length === 0}<p class="muted">
|
||||
No supported game servers are available.
|
||||
@@ -246,7 +251,7 @@
|
||||
<label class="server-choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.publishedServers.includes(
|
||||
checked={!draft.excludedServers.includes(
|
||||
server.name
|
||||
)}
|
||||
onchange={(event) =>
|
||||
@@ -265,20 +270,21 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if unavailableSelections.length}
|
||||
{#if unavailableExclusions.length}
|
||||
<div class="server-group">
|
||||
<h3>No longer in the fleet</h3>
|
||||
{#each unavailableSelections as name (name)}
|
||||
<h3>Excluded servers no longer in the fleet</h3>
|
||||
{#each unavailableExclusions as name (name)}
|
||||
<label class="server-choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
checked={false}
|
||||
onchange={(event) =>
|
||||
chooseServer(name, event.currentTarget.checked)}
|
||||
/>
|
||||
<span
|
||||
>{name}<small
|
||||
>Remove this selection to stop publishing it.</small
|
||||
>Excluded if this server returns. Check to remove
|
||||
the exclusion.</small
|
||||
></span
|
||||
>
|
||||
</label>
|
||||
@@ -286,7 +292,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<p class="help">
|
||||
{countLabel(draft.publishedServers.length, 'server')} selected.
|
||||
{countLabel(includedServerCount, 'server')} included.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
@@ -308,8 +314,8 @@
|
||||
>
|
||||
<p class="help">
|
||||
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.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
@@ -477,14 +483,14 @@
|
||||
<span class="muted">Saved settings</span>
|
||||
</div>
|
||||
<p class="help">
|
||||
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.
|
||||
</p>
|
||||
{#if !adminState.settings.enabled}<p class="muted">
|
||||
Discord is disabled. This preview is visible only in Electric Cherry.
|
||||
</p>{/if}
|
||||
{#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}
|
||||
<div class="preview-games">
|
||||
{#each games as game}
|
||||
@@ -503,7 +509,7 @@
|
||||
</div>
|
||||
<p>
|
||||
{server.map} · {server.players ?? '?'} / {server.capacity ??
|
||||
'?'} players{server.round === null
|
||||
4} players{server.round === null
|
||||
? ''
|
||||
: ` · Round ${server.round}`}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user