From 5985959882aef924ba42971f2989033e16ddc9de Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 19 Sep 2026 22:50:17 -0400 Subject: [PATCH] Show resetting state after game end --- src/lib/server/pipeline/worker.ts | 12 +++++++++--- src/lib/ui/components/ServerCard.svelte | 11 +++++++++-- src/lib/ui/stores/fleet.ts | 14 +++++++++++--- src/lib/ui/stores/servers.ts | 8 ++++++++ src/routes/servers/[name]/+page.svelte | 7 ++++++- tests/unit/server-status.test.ts | 24 ++++++++++++++++++++++++ tests/unit/ui-server-clock.test.ts | 21 +++++++++++++++++++++ 7 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts index c57e44b..2e54b56 100644 --- a/src/lib/server/pipeline/worker.ts +++ b/src/lib/server/pipeline/worker.ts @@ -5,7 +5,7 @@ import { emit } from './bus.js'; import type { ServerStatusEntry } from './bus.js'; import { rconStatusResult } from '../integrations/rcon.js'; import type { RconFailure } from '../integrations/rcon.js'; -import { listServers, setServerField } from '../fleet/servers.js'; +import { getServer, listServers, setServerField } from '../fleet/servers.js'; import { komodoInspectHealthy } from '../integrations/komodo.js'; import { restartSettling } from './restart-rollout.js'; import { classifyHealth } from '../fleet/health.js'; @@ -113,8 +113,14 @@ async function refreshStatus(): Promise { const nextPlayers = result.status.players; let activeSince = srv.active_since; let inactiveSince = srv.inactive_since; - const gameStartedAt = srv.game_started_at; - const gameEndedAt = srv.game_ended_at; + // Telemetry can arrive while the RCON request is in flight. + const current = getServer(srv.name) ?? srv; + const gameStartedAt = nextPlayers === 0 ? null : current.game_started_at; + const gameEndedAt = nextPlayers === 0 ? null : current.game_ended_at; + if (nextPlayers === 0) { + setServerField(srv.name, 'game_started_at', null); + setServerField(srv.name, 'game_ended_at', null); + } if (nextPlayers > 0 && (activeSince === null || prevPlayers === 0 || prevPlayers === null)) { activeSince = now; inactiveSince = null; diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index 154c61c..5bd48f6 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -76,11 +76,13 @@ let gtLabel = $derived(gametypeLabel(gametype)); let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null); + let resetting = $derived(!!gameEndedAt && players !== 0); + let resetElapsed = $derived(resetting ? elapsedSince(gameEndedAt, $uiNow) : null); let inactiveElapsed = $derived(inactiveSince ? elapsedSince(inactiveSince, $uiNow) : null); let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince); let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null); let showTiming = $derived( - health === 'online' && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null) + health === 'online' && (resetting || activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null) ); // Shared-element transition to /servers/{name} — Chromium only, others @@ -134,7 +136,12 @@ {#if showTiming && !(compactEmpty && players === 0)}
- {#if activeElapsed} + {#if resetting} +
+ Resetting + {resetElapsed} +
+ {:else if activeElapsed}
Active {activeElapsed} diff --git a/src/lib/ui/stores/fleet.ts b/src/lib/ui/stores/fleet.ts index 5d6f425..3d4ccee 100644 --- a/src/lib/ui/stores/fleet.ts +++ b/src/lib/ui/stores/fleet.ts @@ -1,6 +1,6 @@ import { derived, get, writable } from 'svelte/store'; import { browser } from '$app/environment'; -import { statusByServer, statusReceivedAt, serverTimeNow, syncServerTime } from './servers.js'; +import { statusByServer, statusReceivedAt, serverTimeNow, syncServerTime, matchEventsByServer } from './servers.js'; import { watchSatisfied, type FleetServer, type FleetOperation, type LobbyWatch } from '$lib/fleet-overview.js'; import { serverTitle } from '../server-labels.js'; import { toast } from '../toast.js'; @@ -13,14 +13,22 @@ export const fleetRefreshing = writable(false); export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null); export const lobbyWatches = writable([]); -export const fleetServers = derived([fleet, statusByServer, statusReceivedAt], ([snapshot, live, received]) => +export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer], ([snapshot, live, received, matches]) => snapshot.servers.map(server => { const status = received > snapshot.receivedAt ? live.get(server.name) : undefined; - return status ? { ...server, last_players: status.players, last_map: status.map, + const current = status ? { ...server, last_players: status.players, last_map: status.map, last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError, roster: status.roster, health: status.health, endpoint: status.endpoint, replySnippet: status.replySnippet, active_since: status.activeSince, inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt, game_ended_at: status.gameEndedAt } : server; + const match = matches.get(server.name); + // Apply lifecycle events immediately, until a newer status observes them + // or confirms that the lobby has emptied. + if (!match || Date.parse(match.receivedAt) <= Date.parse(current.last_seen_at ?? '') + || match.receivedAt < (current.game_started_at ?? '') + || match.receivedAt < (current.game_ended_at ?? '')) return current; + return { ...current, game_started_at: match.event === 'game_start' ? match.receivedAt : current.game_started_at, + game_ended_at: match.event === 'game_end' ? match.receivedAt : null }; })); let inFlight: Promise | null = null; diff --git a/src/lib/ui/stores/servers.ts b/src/lib/ui/stores/servers.ts index 6525a9c..d55d33a 100644 --- a/src/lib/ui/stores/servers.ts +++ b/src/lib/ui/stores/servers.ts @@ -3,6 +3,7 @@ import { browser } from '$app/environment'; import type { GameTelemetry } from '$lib/telemetry.js'; export const telemetryByServer = writable>(new Map()); +export const matchEventsByServer = writable>(new Map()); export interface RosterPlayer { num: number | null; @@ -76,6 +77,13 @@ export function startServerStream(): (() => void) | undefined { source.addEventListener('server-telemetry', (evt) => { const data = JSON.parse((evt as MessageEvent).data) as { entries: GameTelemetry[]; snapshot?: boolean; serverNow?: number }; syncServerTime(data.serverNow); + matchEventsByServer.update((previous) => { + const next = data.snapshot ? new Map() : new Map(previous); + for (const entry of data.entries) { + if (entry.event === 'game_start' || entry.event === 'game_end') next.set(entry.server, entry); + } + return next; + }); telemetryByServer.update((previous) => { const next = data.snapshot ? new Map() : new Map(previous); for (const entry of data.entries) next.set(entry.server, entry); diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index 8847303..fb41e4d 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -154,6 +154,7 @@ let effectiveInactiveSince = $derived(server?.inactive_since ?? null); let effectiveGameStartedAt = $derived(server?.game_started_at ?? null); let effectiveGameEndedAt = $derived(server?.game_ended_at ?? null); + let resetElapsed = $derived(fresh && effectiveGameEndedAt && server?.last_players !== 0 ? elapsedSince(effectiveGameEndedAt, $uiNow) : null); let activeElapsed = $derived(fresh && effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null); let inactiveElapsed = $derived(fresh && effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null); let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince); @@ -260,7 +261,11 @@
{gametypeLabel(effectiveGametype)}
{/if}
- {#if activeElapsed} + {#if resetElapsed} +
+ +
+ {:else if activeElapsed}
diff --git a/tests/unit/server-status.test.ts b/tests/unit/server-status.test.ts index 8fc28f9..5927bb4 100644 --- a/tests/unit/server-status.test.ts +++ b/tests/unit/server-status.test.ts @@ -30,6 +30,30 @@ async function setup() { } describe('database fleet and container readiness', () => { + it('keeps game_end through occupied polls until game_start or an empty lobby', async () => { + const { inspect, rcon, fanoutStatus, getServer } = await setup(); + inspect.mockResolvedValue({ ok: true, state: 'running' }); + const reply = (players: number) => ({ ok: true as const, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + status: { players, map: 'zombie_theater', gametype: 'zom', roster: [] } }); + rcon.mockResolvedValue(reply(1)); + await postTelemetry(gameEvent('game_start', 0, 1, 'managed')); + await postTelemetry(gameEvent('game_end', 10, 1, 'managed')); + const ended = getServer('managed')?.game_ended_at; + expect(ended).toEqual(expect.any(String)); + await postTelemetry(gameEvent('heartbeat', 10, 1, 'managed')); + await fanoutStatus(); + expect(getServer('managed')?.game_ended_at).toBe(ended); + await postTelemetry(gameEvent('game_start', 0, 1, 'managed')); + expect(getServer('managed')?.game_ended_at).toBeNull(); + await postTelemetry(gameEvent('game_end', 10, 1, 'managed')); + rcon.mockResolvedValue(reply(0)); + await fanoutStatus(); + expect(getServer('managed')).toMatchObject({ game_started_at: null, game_ended_at: null }); + rcon.mockResolvedValue(reply(1)); + await fanoutStatus(); + expect(getServer('managed')?.game_ended_at).toBeNull(); + }); + it('waits for game_start telemetry after log initialization and player joins', async () => { const { inspect, rcon, fanoutStatus, getServer } = await setup(); const { GET } = await import('../../src/routes/api/servers/+server.js'); diff --git a/tests/unit/ui-server-clock.test.ts b/tests/unit/ui-server-clock.test.ts index 513c7a8..2275922 100644 --- a/tests/unit/ui-server-clock.test.ts +++ b/tests/unit/ui-server-clock.test.ts @@ -65,6 +65,27 @@ async function setup() { } describe('Fleet report clock', () => { + it('applies game lifecycle events immediately and clears them after an empty report', async () => { + const { fleetServers } = await setup(); + const report = (event: string, time: number) => source.dispatchEvent(new MessageEvent('server-telemetry', { + data: JSON.stringify({ serverNow: time, entries: [{ server: 't5_zombie_theater_1', event, + receivedAt: new Date(time).toISOString(), players: [] }] }) + })); + emit(epoch, [status(epoch, { players: 1 })]); + report('game_end', epoch + 1000); + expect(get(fleetServers)[0].game_ended_at).toBe(new Date(epoch + 1000).toISOString()); + report('heartbeat', epoch + 2000); + expect(get(fleetServers)[0].game_ended_at).not.toBeNull(); + report('game_start', epoch + 3000); + expect(get(fleetServers)[0].game_ended_at).toBeNull(); + report('game_end', epoch + 4000); + await vi.advanceTimersByTimeAsync(5000); + emit(epoch + 5000, [status(epoch + 5000)]); + expect(get(fleetServers)[0].game_ended_at).toBeNull(); + emit(epoch + 6000, [status(epoch + 6000, { players: 1 })]); + expect(get(fleetServers)[0].game_ended_at).toBeNull(); + }); + it.each([-2500, 60_000])('keeps fresh HTTP and streamed reports healthy with a browser clock offset of %i ms', async (offset) => { vi.setSystemTime(epoch + offset); const { fleetServers, uiNow, refreshFleet } = await setup();