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

This commit is contained in:
2026-10-18 17:23:50 -04:00
parent fd00ef5357
commit d715173719
15 changed files with 304 additions and 89 deletions
+75 -11
View File
@@ -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')
+3 -3
View File
@@ -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();
+64 -13
View File
@@ -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)
);
+58
View File
@@ -0,0 +1,58 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { defaultDiscordSettings } from '../../src/lib/discord.js';
import { migrateDiscord } from '../../src/lib/server/discord/schema.js';
import { migrateDiscordPublicationDefaults } from '../../src/lib/server/discord/publication-defaults.js';
import { DiscordStore } from '../../src/lib/server/discord/store.js';
let handle: Database.Database;
let store: DiscordStore;
beforeEach(() => {
handle = new Database(':memory:');
migrateDiscord(handle);
handle.exec(`
CREATE TABLE servers (name TEXT PRIMARY KEY, destroyed_at TEXT);
INSERT INTO servers VALUES ('kino', NULL), ('town', NULL), ('retired', '2026-10-18');
`);
store = new DiscordStore(handle);
});
afterEach(() => handle.close());
function legacySettings(publishedServers: string[]) {
const value: Record<string, unknown> = { ...defaultDiscordSettings, publishedServers };
delete value.excludedServers;
handle
.prepare('INSERT INTO discord_configuration(id,value) VALUES(1,?)')
.run(JSON.stringify(value));
}
describe('Discord publication defaults upgrade', () => {
it('includes all servers on new installations without creating configuration', () => {
migrateDiscordPublicationDefaults(handle);
expect(store.settings()).toEqual(defaultDiscordSettings);
expect(store.settings().excludedServers).toEqual([]);
expect(handle.prepare('SELECT * FROM discord_configuration').all()).toEqual([]);
});
it('replaces the previously empty default selection without enabling Discord or optional sharing', () => {
legacySettings([]);
migrateDiscordPublicationDefaults(handle);
expect(store.settings()).toEqual(defaultDiscordSettings);
const value = (
handle.prepare('SELECT value FROM discord_configuration').get() as { value: string }
).value;
expect(JSON.parse(value)).not.toHaveProperty('publishedServers');
});
it('preserves existing nonempty selections as exclusions, and retains later opt-outs on replay', () => {
legacySettings(['kino']);
migrateDiscordPublicationDefaults(handle);
expect(store.settings().excludedServers).toEqual(['town']);
handle.exec("INSERT INTO servers VALUES ('moon', NULL)");
expect(store.settings().excludedServers).not.toContain('moon');
store.saveSettings({ ...store.settings(), excludedServers: ['kino', 'town'] });
const before = handle.prepare('SELECT * FROM discord_configuration').all();
migrateDiscordPublicationDefaults(handle);
expect(handle.prepare('SELECT * FROM discord_configuration').all()).toEqual(before);
});
});
+6 -5
View File
@@ -199,9 +199,10 @@ describe('Discord signed webhook boundary', () => {
});
describe('Discord configuration and access', () => {
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']);
});