Fix stale match liveness and stop handling (Refs #136)
CI / checks (push) Successful in 5m15s

This commit is contained in:
2026-10-10 19:30:51 -04:00
parent e3e740cea3
commit 03e2d08a38
10 changed files with 211 additions and 24 deletions
+1
View File
@@ -11,6 +11,7 @@ export interface ServerMatch {
server_deleted_at?: string | null;
map: string;
startedAt: string;
lastReportedAt: string | null;
endedAt: string | null;
status: 'active' | 'completed' | 'interrupted';
startPlayers: number | null;
+9 -1
View File
@@ -67,7 +67,15 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
'SELECT * FROM leaderboard_matches WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1'
)
.get(report.server) as Match | undefined;
if (match?.last_fingerprint === signature && match.map_key === identity.key) return;
if (match?.last_fingerprint === signature && match.map_key === identity.key) {
// Unchanged gameplay still proves an open match is reporting. Keep the
// duplicate from changing participation, counters, or completed results.
if (match.status === 'active')
handle
.prepare('UPDATE leaderboard_matches SET last_received_at = ? WHERE id = ?')
.run(report.receivedAt, match.id);
if (match.status !== 'interrupted') return;
}
const isStart = report.event === 'game_start';
const round = isStart ? 0 : report.round;
+1
View File
@@ -11,6 +11,7 @@ import {
import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js';
const matchSummary = `m.id, m.game, m.highest_round AS observedRound, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
m.last_received_at AS lastReportedAt,
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
m.start_players AS startPlayers,
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
+14 -6
View File
@@ -1551,12 +1551,20 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
hold(job, 'Waiting for the agent to verify a stopped container.');
return;
}
db()
.prepare(
'UPDATE servers SET container_state=?,last_players=NULL,last_roster=NULL WHERE name=?'
)
.run(health.state, job.server);
complete(job);
db().transaction(() => {
db()
.prepare(
'UPDATE servers SET container_state=?,last_players=NULL,last_roster=NULL WHERE name=?'
)
.run(health.state, job.server);
db()
.prepare(
`UPDATE leaderboard_matches SET status='interrupted', ended_at=?
WHERE server=? AND status='active' AND server_deleted_at IS NULL`
)
.run(containerObservedAt, job.server);
complete(job);
})();
return;
}
check(
+36 -17
View File
@@ -8,6 +8,7 @@
import { browser } from '$app/environment';
import { contextHref } from '$lib/navigation-context.js';
import { timestampISO } from '$lib/time-zone.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { goto } from '$app/navigation';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import { uiNow } from '$ui/stores/servers.js';
@@ -331,9 +332,23 @@
>
<tbody
>{#each data.matches as match (match.id)}
{@const reporting =
match.status === 'active' &&
!match.server_deleted_at &&
freshStatusTimestamp(timestampISO(match.lastReportedAt) ?? null, now)}
{@const notReporting = match.status === 'active' && !reporting}
{@const status = notReporting
? 'Not reporting'
: match.status === 'active'
? 'In progress'
: match.status === 'completed'
? 'Completed'
: 'Interrupted'}
{@const duration =
match.status === 'active'
? elapsedSince(timestampISO(match.startedAt) ?? null, now)
? reporting
? elapsedSince(timestampISO(match.startedAt) ?? null, now)
: null
: match.durationMs === null
? null
: formatDuration(match.durationMs)}
@@ -346,7 +361,7 @@
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
>{match.map} · Match #{match.id}</a
>
{:else if match.status === 'active' && !match.server_deleted_at}<a
{:else if reporting}<a
class="match-link"
href={contextHref(
`/servers/${encodeURIComponent(match.server)}`,
@@ -357,15 +372,17 @@
>
{:else}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context"
><RelativeTime value={match.startedAt} /> · {match.status ===
'active'
? 'In progress'
: match.status === 'completed'
? 'Completed'
: 'Interrupted'}{duration !== null
><RelativeTime value={match.startedAt} /> · {status}{duration !==
null
? ` · ${duration}`
: ''}</small
>
{#if notReporting}<small class="mobile-match-context"
>Last report <RelativeTime
value={match.lastReportedAt}
missing="Not recorded"
/></small
>{/if}
{#if server === undefined}<div class="instance">
<span title={match.server}>{matchInstanceLabel(match)}</span
>{#if match.server_deleted_at}<span>
@@ -376,14 +393,15 @@
<td
><span
class="status"
class:active={match.status === 'active'}
class:interrupted={match.status === 'interrupted'}
>{match.status === 'active'
? 'In progress'
: match.status === 'completed'
? 'Completed'
: 'Interrupted'}</span
></td
class:active={reporting}
class:interrupted={match.status === 'interrupted' ||
notReporting}>{status}</span
>{#if notReporting}<small
>Last report <RelativeTime
value={match.lastReportedAt}
missing="Not recorded"
/></small
>{/if}</td
>
<td class="numeric">{match.uniquePlayers}</td>
<td class="numeric"
@@ -443,7 +461,8 @@
<p>
Players counts unique players across all reports. A dash means the value is unavailable,
not zero. In-progress matches show elapsed time since their recorded start and link to
the live server. Completed durations require a recorded start and completed ending.
the live server while reports are current. Otherwise, they show Not reporting with the
last report time. Completed durations require a recorded start and completed ending.
Round shows the final round for completed matches and the highest observed round for
unfinished matches. Open a completed match for exact timestamps and starting and ending
player counts.