From 6146bef51d93bb14fb3600a523c9eae513be659b Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 7 Oct 2026 20:16:42 -0400 Subject: [PATCH] Show fresh player capacity on Fleet cards --- src/lib/fleet-overview.ts | 2 + src/lib/server-capacity.ts | 16 +++ src/lib/server/fleet/monitoring-snapshot.ts | 2 +- src/lib/server/fleet/player-capacity.ts | 18 +++ src/lib/server/fleet/snapshot.ts | 3 + src/lib/server/pipeline/bus.ts | 2 + src/lib/server/pipeline/worker.ts | 9 +- src/lib/ui/stores/fleet.ts | 8 +- src/lib/ui/stores/servers.ts | 2 + src/routes/+page.svelte | 2 + src/routes/api/events/servers/+server.ts | 4 +- tests/unit/server-capacity.test.ts | 122 ++++++++++++++++++++ tests/unit/server-status.test.ts | 24 ++++ tests/unit/ui-server-clock.test.ts | 27 +++++ 14 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 src/lib/server-capacity.ts create mode 100644 src/lib/server/fleet/player-capacity.ts create mode 100644 tests/unit/server-capacity.test.ts diff --git a/src/lib/fleet-overview.ts b/src/lib/fleet-overview.ts index c8a969d..af02b98 100644 --- a/src/lib/fleet-overview.ts +++ b/src/lib/fleet-overview.ts @@ -23,6 +23,8 @@ export interface FleetServer { map_slug?: string | null; game_mode?: string | null; last_players: number | null; + capacity?: number | null; + capacity_observed_at?: string | null; last_map: string | null; last_gametype: string | null; last_seen_at: string | null; diff --git a/src/lib/server-capacity.ts b/src/lib/server-capacity.ts new file mode 100644 index 0000000..fc979d6 --- /dev/null +++ b/src/lib/server-capacity.ts @@ -0,0 +1,16 @@ +import { freshStatusTimestamp } from './server-freshness.js'; + +export interface PlayerCapacityObservation { + capacity: number | null; + capacity_observed_at: string | null; +} + +/** A newer roster must never renew the capacity reported by an older process. */ +export function currentCapacity(server: Partial & { telemetry_after?: string | null }, now: number): number | null { + const value = server.capacity; + const observedAt = server.capacity_observed_at; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0 + || !freshStatusTimestamp(observedAt ?? null, now)) return null; + if (server.telemetry_after && !(Date.parse(observedAt!) > Date.parse(server.telemetry_after))) return null; + return value; +} diff --git a/src/lib/server/fleet/monitoring-snapshot.ts b/src/lib/server/fleet/monitoring-snapshot.ts index 8865259..6340839 100644 --- a/src/lib/server/fleet/monitoring-snapshot.ts +++ b/src/lib/server/fleet/monitoring-snapshot.ts @@ -42,7 +42,7 @@ interface Record { /** A pass shares its reads; each asynchronous evidence boundary refreshes in one joined query. */ export class MonitoringSnapshot { private records = new Map(); - constructor() { ensureHostSchema(db()); this.refresh(); } + constructor(serverName?: string) { ensureHostSchema(db()); this.refresh(serverName); } refresh(server?: string): void { const rows = server === undefined ? db().prepare(sql + ' WHERE s.destroyed_at IS NULL ORDER BY s.name').all() as Row[] : db().prepare(sql + ' WHERE s.name=? AND s.destroyed_at IS NULL').all(server) as Row[]; diff --git a/src/lib/server/fleet/player-capacity.ts b/src/lib/server/fleet/player-capacity.ts new file mode 100644 index 0000000..53828b8 --- /dev/null +++ b/src/lib/server/fleet/player-capacity.ts @@ -0,0 +1,18 @@ +import type { PlayerCapacityObservation } from '../../server-capacity.js'; +import { monitoringStatus } from './health-observations.js'; +import { MonitoringSnapshot } from './monitoring-snapshot.js'; + +/** Read only the accepted, current-instance sample; never dispatch a probe. */ +export function observedPlayerCapacity(server: string, snapshot: MonitoringSnapshot): PlayerCapacityObservation { + const game = monitoringStatus(server, snapshot)?.game; + const capacity = game?.result.ok ? game.result.status.capacity : null; + return typeof capacity === 'number' && Number.isSafeInteger(capacity) && capacity > 0 + ? { capacity, capacity_observed_at: game!.observedAt } + : { capacity: null, capacity_observed_at: null }; +} + +/** One joined identity read for a fleet, or a single server on a detail route. */ +export function playerCapacityReader(serverName?: string) { + const snapshot = new MonitoringSnapshot(serverName); + return (server: string) => observedPlayerCapacity(server, snapshot); +} diff --git a/src/lib/server/fleet/snapshot.ts b/src/lib/server/fleet/snapshot.ts index 533304b..4cc6362 100644 --- a/src/lib/server/fleet/snapshot.ts +++ b/src/lib/server/fleet/snapshot.ts @@ -7,6 +7,7 @@ import { listHosts } from '../hosts/index.js'; import type { NativeInstance } from '../backends/native.js'; import { serverOperations } from './overview.js'; import { hostListStatus } from '../../ui/host-overview.js'; +import { playerCapacityReader } from './player-capacity.js'; export function fleetSnapshot(serverName?: string) { const bindings = serverName === undefined ? [] : [serverName]; @@ -27,12 +28,14 @@ export function fleetSnapshot(serverName?: string) { const entries = holds.get(row.server) ?? []; entries.push({ target: row.target, betaId: row.betaId }); holds.set(row.server, entries); } const observe = serverName === undefined ? observationReader() : serverObservation; + const capacity = playerCapacityReader(serverName); const selected = serverName === undefined ? undefined : getServer(serverName); const rows = serverName === undefined ? listServers() : selected && !selected.destroyed_at ? [selected] : []; const servers = rows.map((s) => { const instance = s.native_instance_id ? instances.get(s.name) : null; return { ...observe(s), + ...capacity(s.name), host: s.host_id ? hostsById.get(s.host_id) ?? null : null, network: instance ? { bindAddress: instance.bind_address, localPort: instance.port, publicAddress: instance.public_address, publicPort: instance.public_port } : null, plutonium: versions.get(s.name), diff --git a/src/lib/server/pipeline/bus.ts b/src/lib/server/pipeline/bus.ts index 666e75a..68ca933 100644 --- a/src/lib/server/pipeline/bus.ts +++ b/src/lib/server/pipeline/bus.ts @@ -16,6 +16,8 @@ export interface ServerStatusEntry { rconStatus?: GameObservation; telemetry?: GameTelemetry; players: number | null; + capacity?: number | null; + capacityObservedAt?: string | null; map: string | null; gametype: string | null; roster: StatusPlayer[] | null; diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts index bf06ed7..4d79e3b 100644 --- a/src/lib/server/pipeline/worker.ts +++ b/src/lib/server/pipeline/worker.ts @@ -19,6 +19,8 @@ import { runMaintenance } from '../maintenance/index.js'; import { cleanupControllerLibraries } from '../game-files/index.js'; import { trackActivity } from './activity.js'; import { MonitoringSnapshot } from '../fleet/monitoring-snapshot.js'; +import { observedPlayerCapacity } from '../fleet/player-capacity.js'; +import type { PlayerCapacityObservation } from '../../server-capacity.js'; import { withHostRequestContext } from '../hosts/request-context.js'; import { withOperationSource } from '../hosts/operation-context.js'; @@ -125,7 +127,7 @@ function queueStatus(srv: ServerRow, snapshot: MonitoringSnapshot, probe?: () => capacity = await withOperationSource('Status monitor', () => refreshServerStatus(server, snapshot, probe)); // Publish this server's latest evidence without waiting for the fleet. const current = snapshot.get(server)?.server; - if (current) publishStatus(statusEntry(current, snapshot.observe)); + if (current) publishStatus(statusEntry(current, snapshot.observe, observedPlayerCapacity(server, snapshot))); } catch (err) { console.error(`[worker] status error for ${server}:`, err); } finally { pendingStatus.delete(server); finish(capacity); } }); @@ -252,9 +254,10 @@ async function refreshServerStatus(server: string, snapshot: MonitoringSnapshot, return capacity; } -export function statusEntry(srv: ServerRow, observe = serverObservation): ServerStatusEntry { +export function statusEntry(srv: ServerRow, observe = serverObservation, capacity?: PlayerCapacityObservation): ServerStatusEntry { const final = observe(srv); - return { server: srv.name, players: final.last_players, map: final.last_map ?? srv.map, + return { server: srv.name, players: final.last_players, capacity: capacity?.capacity ?? null, + capacityObservedAt: capacity?.capacity_observed_at ?? null, map: final.last_map ?? srv.map, gametype: final.last_gametype, roster: final.roster, health: final.health, lastSeenAt: final.last_seen_at, lastError: final.last_error, replySnippet: final.replySnippet, endpoint: srv.game_port ? { host: srv.rcon_host ?? config.rcon.host, port: srv.game_port } : null, reason: final.last_error ? final.last_error.split(':')[0] as RconFailure : null, diff --git a/src/lib/ui/stores/fleet.ts b/src/lib/ui/stores/fleet.ts index c60eb9f..f821134 100644 --- a/src/lib/ui/stores/fleet.ts +++ b/src/lib/ui/stores/fleet.ts @@ -1,6 +1,7 @@ import { freshStatusTimestamp } from '$lib/server-freshness.js'; import { pollVisible } from '../poll.js'; import { selectServerObservation } from '$lib/server-observation.js'; +import { currentCapacity } from '$lib/server-capacity.js'; import { derived, writable, get } from 'svelte/store'; import { browser } from '$app/environment'; import { statusByServer, statusReceivedAt, syncServerTime, serverTimeNow, matchEventsByServer, telemetryByServer, uiNow } from './servers.js'; @@ -19,7 +20,7 @@ export function createFleetStore(serverName?: string) { const freshnessPhase = derived([fleet, statusByServer, telemetryByServer, uiNow], ([snapshot, live, reports, now]) => { const times = snapshot.servers.flatMap(server => { const status = live.get(server.name); - return [status?.lastSeenAt, status?.containerCheckedAt, status?.rconStatus?.last_seen_at, status?.telemetry?.receivedAt, + return [status?.capacityObservedAt, server.capacity_observed_at, status?.lastSeenAt, status?.containerCheckedAt, status?.rconStatus?.last_seen_at, status?.telemetry?.receivedAt, server.last_seen_at, server.container_checked_at, server.last_checked_at, server.rcon_status?.last_seen_at, server.telemetry?.receivedAt, reports.get(server.name)?.receivedAt]; }); @@ -32,7 +33,7 @@ export function createFleetStore(serverName?: string) { const status = (candidate?.receivedAt ?? received) > snapshot.receivedAt ? candidate : undefined; const match = matches.get(server.name); const report = reports.get(server.name); - const times = [status?.lastSeenAt, status?.containerCheckedAt, status?.rconStatus?.last_seen_at, + const times = [status?.capacityObservedAt, server.capacity_observed_at, status?.lastSeenAt, status?.containerCheckedAt, status?.rconStatus?.last_seen_at, status?.telemetry?.receivedAt, server.last_seen_at, server.container_checked_at, server.last_checked_at, server.rcon_status?.last_seen_at, server.telemetry?.receivedAt, report?.receivedAt]; const phase = times.map(time => `${freshStatusTimestamp(time ?? null, now)}:${now - Date.parse(time ?? '') >= 0 && now - Date.parse(time ?? '') <= 120_000}`).join(','); @@ -40,6 +41,7 @@ export function createFleetStore(serverName?: string) { if (saved && saved.server === server && saved.status === status && saved.match === match && saved.report === report && saved.phase === phase) return saved.value; const remember = (value: FleetServer) => { observed.set(server.name, { server, status, match, report, phase, value }); return value; }; const base = status ? { ...server, last_players: status.players, last_map: status.map, + capacity: status.capacity ?? null, capacity_observed_at: status.capacityObservedAt ?? null, 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, @@ -48,6 +50,8 @@ export function createFleetStore(serverName?: string) { telemetry_after: status.telemetryAfter ?? server.telemetry_after, rcon_status: status.rconStatus, telemetry: status.telemetry ?? server.telemetry } : server; const current = selectServerObservation(base, reports.get(server.name), now); + current.capacity = currentCapacity(current, now); + if (current.capacity === null) current.capacity_observed_at = null; // Apply lifecycle events immediately, until a newer status observes them // or confirms that the lobby has emptied. if (!match || current.health !== 'online' diff --git a/src/lib/ui/stores/servers.ts b/src/lib/ui/stores/servers.ts index a82afb8..877b80c 100644 --- a/src/lib/ui/stores/servers.ts +++ b/src/lib/ui/stores/servers.ts @@ -25,6 +25,8 @@ export interface ServerStatus { rconStatus?: GameObservation; telemetry?: GameTelemetry; players: number | null; + capacity?: number | null; + capacityObservedAt?: string | null; map: string | null; gametype: string | null; roster: RosterPlayer[] | null; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7496dd8..0f0a704 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -21,6 +21,7 @@ import { fleet, fleetServers, fleetError, fleetIntegrations, refreshFleet } from '$ui/stores/fleet.js'; import { telemetryByServer, uiNow } from '$ui/stores/servers.js'; import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js'; + import { currentCapacity } from '$lib/server-capacity.js'; import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js'; import type { ZombieGame } from '$lib/map-aliases.js'; import { elapsedSince } from '$ui/duration.js'; @@ -248,6 +249,7 @@ {#each group.servers as server (server.name)}
[number]) => statusEntry(server, observation); + const capacity = playerCapacityReader(); + const observe = (server: ReturnType[number]) => statusEntry(server, observation, capacity(server.name)); controller.enqueue(encoder.encode(': hello\n\n')); controller.enqueue(encoder.encode(`event: server-status\ndata: ${JSON.stringify({ entries: listServers().map(server => observe(server)), snapshot: true, serverNow: Date.now() })}\n\n`)); controller.enqueue(encoder.encode(`event: server-telemetry\ndata: ${JSON.stringify({ entries: listTelemetry(), snapshot: true, serverNow: Date.now() })}\n\n`)); diff --git a/tests/unit/server-capacity.test.ts b/tests/unit/server-capacity.test.ts new file mode 100644 index 0000000..571cda5 --- /dev/null +++ b/tests/unit/server-capacity.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { currentCapacity } from '../../src/lib/server-capacity.js'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +const now = Date.parse('2026-10-07T12:00:00Z'); +const iso = (offset = 0) => new Date(now + offset).toISOString(); +const unavailable = { capacity: null, capacity_observed_at: null }; +let fixture: Fixture; + +beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(now); + fixture = await newFixture([]); +}); +afterEach(async () => { await fixture.cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); }); + +async function setup(count = 1) { + const { db } = await import('../../src/lib/server/db/index.js'); + const database = db(); + const names = Array.from({ length: count }, (_, i) => `server-${i}`); + for (const name of names) database.prepare(`INSERT INTO servers(name,created_at,game_port,container_state, + container_checked_at,last_checked_at,last_seen_at,last_players) VALUES(?, ?, 28960, 'running', ?, ?, ?, 3)`) + .run(name, iso(-60_000), iso(), iso(), iso()); + const { monitoringStatusWriter } = await import('../../src/lib/server/fleet/health-observations.js'); + function publish(name: string, capacity: number | null | undefined = 4, observedAt = iso(), ok = true) { + monitoringStatusWriter(name)({ observedAt: new Date().toISOString(), result: { ok: true, state: 'running' } }, + { observedAt, result: ok ? { ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + status: { players: 3, capacity, map: 'zombie_theater', gametype: 'zom', roster: [] } } + : { ok: false, endpoint: null, reason: 'timeout', detail: 'No reply' } }); + } + return { database, names, publish, ...await import('../../src/lib/server/fleet/player-capacity.js') }; +} + +describe('current player capacity', () => { + it('requires a fresh positive integer from after the latest lifecycle submission', () => { + const known = { capacity: 4, capacity_observed_at: iso() }; + expect(currentCapacity(known, now)).toBe(4); + expect(currentCapacity(known, now + 90_000)).toBe(4); + expect(currentCapacity(known, now + 90_001)).toBeNull(); + expect(currentCapacity(known, now - 2000)).toBeNull(); + expect(currentCapacity({ ...known, telemetry_after: iso(-1) }, now)).toBe(4); + for (const telemetry_after of [iso(), iso(1), 'invalid']) expect(currentCapacity({ ...known, telemetry_after }, now)).toBeNull(); + for (const capacity of [null, undefined, 0, -1, 4.5, NaN, Infinity]) expect(currentCapacity({ ...known, capacity }, now)).toBeNull(); + for (const capacity_observed_at of [null, undefined, 'invalid']) expect(currentCapacity({ ...known, capacity_observed_at }, now)).toBeNull(); + }); + + it('projects the accepted structured sample into REST and initial SSE without probing', async () => { + const api = await setup(); + api.publish('server-0'); + const rcon = vi.spyOn(await import('../../src/lib/server/integrations/rcon.js'), 'rconStatusResult'); + const { fleetSnapshot } = await import('../../src/lib/server/fleet/snapshot.js'); + expect(fleetSnapshot().servers[0]).toMatchObject({ capacity: 4, capacity_observed_at: iso() }); + const { GET } = await import('../../src/routes/api/events/servers/+server.js'); + const reader = (await GET()).body!.getReader(); + try { + await reader.read(); // stream greeting + const frame = new TextDecoder().decode((await reader.read()).value); + const body = JSON.parse(frame.split('data: ')[1]); + expect(body.entries[0]).toMatchObject({ capacity: 4, capacityObservedAt: iso() }); + } finally { await reader.cancel(); } + expect(rcon).not.toHaveBeenCalled(); + }); + + it('clears missing, invalid, failed and superseded evidence instead of reusing the previous limit', async () => { + const api = await setup(); + const read = () => api.playerCapacityReader()('server-0'); + expect(read()).toEqual(unavailable); + api.publish('server-0'); + expect(read()).toEqual({ capacity: 4, capacity_observed_at: iso() }); + for (const value of [null, 0, -1, 4.5, NaN]) { + api.publish('server-0', value); + expect(read()).toEqual(unavailable); + } + api.publish('server-0', 4, iso(), false); + expect(read()).toEqual(unavailable); + api.publish('server-0'); + api.database.prepare("UPDATE servers SET last_checked_at=?,last_error='timeout:newer failure'").run(iso(1)); + expect(read()).toEqual(unavailable); + }); + + it('retains the original sample age even when the container is checked again', async () => { + const api = await setup(); + api.publish('server-0'); + vi.setSystemTime(now + 90_001); + api.publish('server-0', 4, iso()); + expect(api.playerCapacityReader()('server-0')).toEqual(unavailable); + }); + + it.each(['generation', 'lifecycle', 'registration', 'offline'] as const)('rejects capacity after %s changes', async change => { + const api = await setup(); + const hosts = await import('../../src/lib/server/hosts/index.js'); + const host = hosts.createHost({ name: 'Capacity host' }); + hosts.enrollHost(hosts.createEnrollment(host.id).token, { protocolVersion: 1 }); + api.database.prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) + VALUES('instance','server-0',?,'library','image',28960,?)`).run(host.id, iso()); + api.publish('server-0'); + expect(api.playerCapacityReader()('server-0').capacity).toBe(4); + if (change === 'generation') api.database.prepare('UPDATE native_instances SET generation=2').run(); + if (change === 'lifecycle') api.database.prepare("INSERT INTO restart_rollout(server,requested_at) VALUES('server-0',?)").run(iso(1)); + if (change === 'registration') api.database.prepare('UPDATE servers SET created_at=?').run(iso(1)); + if (change === 'offline') api.database.prepare('UPDATE ec_hosts SET last_heartbeat=?').run(iso(-91_000)); + expect(api.playerCapacityReader()('server-0')).toEqual(unavailable); + }); + + it('reads a fleet in one joined query and keeps server-detail reads scoped', async () => { + const api = await setup(20); + api.names.forEach(name => api.publish(name)); + // Initialize schema before measuring the snapshot's actual reads. + api.playerCapacityReader(); + const prepare = vi.spyOn(api.database, 'prepare'); + const fleet = api.playerCapacityReader(); + expect(api.names.map(name => fleet(name).capacity)).toEqual(Array(20).fill(4)); + expect(prepare).toHaveBeenCalledTimes(1); + expect(prepare.mock.calls[0][0]).toContain('WHERE s.destroyed_at IS NULL ORDER BY s.name'); + prepare.mockClear(); + const scoped = api.playerCapacityReader('server-0'); + expect(scoped('server-0').capacity).toBe(4); + expect(scoped('server-1')).toEqual(unavailable); + expect(prepare).toHaveBeenCalledTimes(1); + expect(prepare.mock.calls[0][0]).toContain('WHERE s.name=? AND s.destroyed_at IS NULL'); + }); +}); diff --git a/tests/unit/server-status.test.ts b/tests/unit/server-status.test.ts index 1eb9ab4..e3905ae 100644 --- a/tests/unit/server-status.test.ts +++ b/tests/unit/server-status.test.ts @@ -28,6 +28,30 @@ async function setup() { } describe('database fleet and container readiness', () => { + it('publishes capacity from accepted monitoring samples and clears it on the next failed check', async () => { + const { inspect, rcon, fanoutStatus } = await setup(); + const { GET } = await import('../../src/routes/api/servers/+server.js'); + const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); + vi.useFakeTimers({ toFake: ['Date'] }); + const at = Date.parse('2026-10-07T12:00:00Z'); + vi.setSystemTime(at); + inspect.mockResolvedValue({ ok: true, state: 'running' }); + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + status: { players: 3, capacity: 4, map: 'zombie_theater', gametype: 'zom', roster: [] } }); + const events: unknown[] = []; + const stop = subscribe(event => { if (event.type === 'server-status') events.push(event); }); + try { + await fanoutStatus(); + expect(events.at(-1)).toMatchObject({ entries: [{ capacity: 4, capacityObservedAt: new Date(at).toISOString() }] }); + expect((await GET().json()).servers[0]).toMatchObject({ capacity: 4, capacity_observed_at: new Date(at).toISOString() }); + vi.setSystemTime(at + 30_000); + rcon.mockResolvedValue({ ok: false, endpoint: null, reason: 'timeout', detail: 'No reply' }); + await fanoutStatus(); + expect(events.at(-1)).toMatchObject({ entries: [{ capacity: null, capacityObservedAt: null }] }); + expect((await GET().json()).servers[0]).toMatchObject({ capacity: null, capacity_observed_at: null }); + } finally { stop(); } + }); + it('requires two distinct failed monitoring checks, preserves diagnostics, and resets on success', 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 bd0f698..ae3628e 100644 --- a/tests/unit/ui-server-clock.test.ts +++ b/tests/unit/ui-server-clock.test.ts @@ -86,6 +86,33 @@ function queueEvent(type: string, data: object) { } describe('animation-frame stream batching', () => { + it('replaces snapshot capacity with live values and honors explicit unavailable samples', async () => { + const at = new Date(epoch).toISOString(); + snapshot.servers = [server(epoch, { capacity: 4, capacity_observed_at: at })]; + const { fleetServers } = await setup(); + expect(get(fleetServers)[0]).toMatchObject({ capacity: 4, capacity_observed_at: at }); + await vi.advanceTimersByTimeAsync(1000); + emit(epoch + 1000, [status(epoch + 1000, { capacity: 8, capacityObservedAt: new Date(epoch + 1000).toISOString() })]); + expect(get(fleetServers)[0].capacity).toBe(8); + await vi.advanceTimersByTimeAsync(1000); + emit(epoch + 2000, [status(epoch + 2000, { capacity: null, capacityObservedAt: null })]); + expect(get(fleetServers)[0]).toMatchObject({ capacity: null, capacity_observed_at: null }); + }); + + it('expires capacity independently of fresh rosters and rejects the previous lifecycle limit', async () => { + const at = (offset = 0) => new Date(epoch + offset).toISOString(); + snapshot.servers = [server(epoch, { capacity: 4, capacity_observed_at: at(-90_000), + container_state: 'running', container_checked_at: at() })]; + const { fleetServers } = await setup(); + expect(get(fleetServers)[0].capacity).toBe(4); + await vi.advanceTimersByTimeAsync(1000); + queueEvent('server-telemetry', { serverNow: epoch + 1000, entries: [{ ...gameEvent('heartbeat', 15, 3), receivedAt: at(1000) }] }); + flushFrame(); + expect(get(fleetServers)[0]).toMatchObject({ health: 'online', last_players: 3, capacity: null, capacity_observed_at: null }); + emit(epoch + 1000, [status(epoch + 1000, { capacity: 4, capacityObservedAt: at(), telemetryAfter: at(1) })]); + expect(get(fleetServers)[0]).toMatchObject({ capacity: null, capacity_observed_at: null }); + }); + it('publishes each changed store once for a burst and preserves lifecycle evidence through heartbeats', async () => { const stores = await setup(); emit(epoch, [status(epoch), status(epoch, { server: 'two' })]);