Fix game timer: cycle-key tracking, wall-clock only, active gate
games_sp.log is not wiped on restart, so old J; lines from prior sessions were poisoning the anchor. Now the scanner walks the tail backward to the LATEST InitGame and only considers lines after it, returning an opaque cycle key (the first J; line) plus a state. The worker stamps game_started_at/ended_at with Date.now() at observation time instead of inferring from the log's own timestamps. Same-players game restarts produce a new key and cleanly re-anchor. When the server transitions to 0 players, game state is cleared server-side and the UI also gates the timer display on activeSince. Adds servers.game_cycle_key column with additive migration.
This commit is contained in:
@@ -55,29 +55,32 @@ async function scanMatches(): Promise<void> {
|
||||
return { kind: 'no-signal' as const };
|
||||
});
|
||||
const persistedInProgress = !!srv.game_started_at && !srv.game_ended_at;
|
||||
const persistedKey = srv.game_cycle_key;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (result.kind === 'in-progress' && result.startedAt) {
|
||||
// Transition to in-progress on fresh state OR when a new match
|
||||
// has begun after a crash left us with a stale in-progress
|
||||
// anchor. The back-computed startedAt for the SAME match stays
|
||||
// stable across polls (log time and wall time advance in
|
||||
// lockstep), so a new anchor >30s later means a new match.
|
||||
const newStart = new Date(result.startedAt).getTime();
|
||||
const persistedStart = srv.game_started_at
|
||||
? new Date(srv.game_started_at).getTime()
|
||||
: 0;
|
||||
if (!persistedInProgress || newStart > persistedStart + 30_000) {
|
||||
setServerField(srv.name, 'game_started_at', result.startedAt);
|
||||
if (result.kind === 'in-progress' && result.cycleKey) {
|
||||
// New cycle when persisted key differs (fresh start, or a game
|
||||
// restart with the same players — new J; line = new key).
|
||||
if (result.cycleKey !== persistedKey) {
|
||||
setServerField(srv.name, 'game_started_at', now);
|
||||
setServerField(srv.name, 'game_ended_at', null);
|
||||
setServerField(srv.name, 'game_cycle_key', result.cycleKey);
|
||||
} else if (!persistedInProgress) {
|
||||
// Same key but previous cycle was ended — treat as new run
|
||||
// (shouldn't happen in practice since key would differ,
|
||||
// but be defensive).
|
||||
setServerField(srv.name, 'game_started_at', now);
|
||||
setServerField(srv.name, 'game_ended_at', null);
|
||||
}
|
||||
} else if (result.kind === 'ended') {
|
||||
if (persistedInProgress && result.endedAt) {
|
||||
setServerField(srv.name, 'game_ended_at', result.endedAt);
|
||||
} else if (result.kind === 'ended' && result.cycleKey) {
|
||||
// Only stamp the end if it's the cycle we've been tracking and
|
||||
// we haven't already stamped it.
|
||||
if (result.cycleKey === persistedKey && persistedInProgress) {
|
||||
setServerField(srv.name, 'game_ended_at', now);
|
||||
}
|
||||
}
|
||||
// 'idle' / 'no-signal': leave state alone. If we were previously
|
||||
// in-progress and the log has rolled past the match, we'll only
|
||||
// learn that later when a new InitGame appears — that's fine.
|
||||
// 'idle' / 'no-signal': leave state alone. UI hides the timer when
|
||||
// the server has no players (see ServerCard/detail page).
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: STATUS_CONCURRENCY }, pump));
|
||||
@@ -121,6 +124,8 @@ async function fanoutStatus(): Promise<void> {
|
||||
const nextPlayers = result.status.players;
|
||||
let activeSince = srv.active_since;
|
||||
let inactiveSince = srv.inactive_since;
|
||||
let gameStartedAt = srv.game_started_at;
|
||||
let gameEndedAt = srv.game_ended_at;
|
||||
if (nextPlayers > 0 && (activeSince === null || prevPlayers === 0 || prevPlayers === null)) {
|
||||
activeSince = now;
|
||||
inactiveSince = null;
|
||||
@@ -131,6 +136,14 @@ async function fanoutStatus(): Promise<void> {
|
||||
activeSince = null;
|
||||
setServerField(srv.name, 'inactive_since', inactiveSince);
|
||||
setServerField(srv.name, 'active_since', null);
|
||||
// Game timer cannot run while inactive. Drop any tracked
|
||||
// match state so the next active session anchors fresh
|
||||
// when a new J; line fires.
|
||||
gameStartedAt = null;
|
||||
gameEndedAt = null;
|
||||
setServerField(srv.name, 'game_started_at', null);
|
||||
setServerField(srv.name, 'game_ended_at', null);
|
||||
setServerField(srv.name, 'game_cycle_key', null);
|
||||
}
|
||||
// Persist last-observed state. last_seen_at is the health anchor.
|
||||
setServerField(srv.name, 'last_players', nextPlayers);
|
||||
@@ -155,8 +168,8 @@ async function fanoutStatus(): Promise<void> {
|
||||
replySnippet: result.rawReply,
|
||||
activeSince,
|
||||
inactiveSince,
|
||||
gameStartedAt: srv.game_started_at,
|
||||
gameEndedAt: srv.game_ended_at
|
||||
gameStartedAt,
|
||||
gameEndedAt
|
||||
};
|
||||
} else {
|
||||
// RCON unreachable — leave last_seen_at, active_since, and
|
||||
|
||||
Reference in New Issue
Block a user