diff --git a/frontend/src/html/pages/rbh/spectator-screen.html b/frontend/src/html/pages/rbh/spectator-screen.html index 454f75bb1bcc..d8e8ab82b79e 100644 --- a/frontend/src/html/pages/rbh/spectator-screen.html +++ b/frontend/src/html/pages/rbh/spectator-screen.html @@ -32,6 +32,14 @@ + + + -
diff --git a/frontend/src/styles/rbh/spectator-screen.scss b/frontend/src/styles/rbh/spectator-screen.scss index 99bf1fbaf224..780f564c75ec 100644 --- a/frontend/src/styles/rbh/spectator-screen.scss +++ b/frontend/src/styles/rbh/spectator-screen.scss @@ -40,19 +40,92 @@ } } + // ============================================================ + // COUNTDOWN OVERLAY + // ============================================================ + .countdownOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(6px); + opacity: 1; + transition: opacity 0.4s ease-out; + + &.hidden { + display: none !important; + } + + &.fade-out { + opacity: 0; + } + + &.fade-in { + opacity: 0; + } + + .countdownContent { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + } + + .countdownLabel { + font-size: 2rem; + font-weight: 500; + color: var(--sub-color); + text-transform: uppercase; + letter-spacing: 0.2em; + } + + .countdownNumber { + font-size: 15rem; + font-weight: 900; + color: var(--main-color); + line-height: 1; + font-variant-numeric: tabular-nums; + text-shadow: 0 0 60px + color-mix(in srgb, var(--main-color) 40%, transparent); + animation: countdownPulse 1s ease-in-out infinite; + } + + @keyframes countdownPulse { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.05); + opacity: 0.9; + } + } + } + // ============================================================ // LEADERBOARD VIEW // ============================================================ .leaderboardView { + height: 100%; + .content { - display: grid; - grid-template-columns: 1fr; + display: flex; + flex-direction: column; gap: 2rem; height: 100%; .bigtitle { font-size: 2em; margin-bottom: 1em; + flex-shrink: 0; .text { color: var(--main-color); } @@ -61,6 +134,11 @@ .tableAndUser { font-size: 1rem; width: 100%; + // Allow this container to shrink so overflow works inside + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; & > .divider { height: 0.25rem; @@ -68,6 +146,7 @@ background: var(--sub-alt-color); border-radius: calc(var(--roundness) / 2); margin-bottom: 2em; + flex-shrink: 0; } .leaderboardTable { @@ -75,6 +154,9 @@ display: flex; flex-direction: column; gap: 0.5rem; + // Fill remaining space and allow children to overflow + flex: 1; + min-height: 0; .leaderboardHeader, .leaderboardRow { @@ -122,6 +204,7 @@ font-size: 0.75em; padding-bottom: 0.5rem; border-bottom: 2px solid var(--sub-alt-color); + flex-shrink: 0; } .leaderboardBody { @@ -129,6 +212,40 @@ flex-direction: column; gap: 0.5rem; width: 100%; + // Enable vertical scrolling for large leaderboards + overflow-y: auto; + // Fill remaining space but don't exceed it + flex: 1; + min-height: 0; + // Small bottom padding so last row isn't flush against edge + padding-bottom: 0.5rem; + + // Subtle custom scrollbar for dark theme + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + margin: 0.25rem 0; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; + transition: background 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.25); + } + + &:active { + background: var(--main-color); + } + } .leaderboardRow { background: linear-gradient( @@ -146,6 +263,8 @@ transition: background 0.3s ease, border-color 0.3s ease; + // Prevent rows from shrinking when container is constrained + flex-shrink: 0; &:hover { background: linear-gradient( @@ -156,6 +275,41 @@ border-color: rgba(255, 255, 255, 0.1); } + &.tier-podium { + border-color: color-mix( + in srgb, + var(--main-color), + transparent 55% + ); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--main-color), transparent 88%) 0%, + rgba(255, 255, 255, 0.04) 100% + ); + } + + &.tier-contender { + border-left: 3px solid + color-mix(in srgb, var(--main-color), transparent 35%); + } + + &.placeholder-summary { + opacity: 0.9; + border-style: dashed; + border-color: rgba(255, 255, 255, 0.2); + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.02) 0%, + rgba(255, 255, 255, 0.01) 100% + ); + + .placeholder-count { + font-weight: 700; + color: var(--main-color); + margin-right: 0.4rem; + } + } + .avatarNameBadge { display: flex; gap: 0.5em; diff --git a/frontend/src/styles/tribe.scss b/frontend/src/styles/tribe.scss index 13e9490f5e1c..ad5efc2f01cb 100644 --- a/frontend/src/styles/tribe.scss +++ b/frontend/src/styles/tribe.scss @@ -1,8 +1,3 @@ -#tribeStateDisplay { - position: fixed; - z-index: 999999999999999; -} - .pageTribe { height: 100%; display: grid; @@ -723,6 +718,13 @@ &.faded { opacity: 0.25; } + &.dnf { + opacity: 0.35; + color: var(--sub-color); + .pos { + color: var(--sub-color); + } + } .progressAndGraph { width: 25%; } @@ -1026,17 +1028,6 @@ margin-bottom: 0.5rem; } } - - a { - color: var(--main-color); - text-decoration: underline; - } - } - - .eulaFooter { - color: var(--sub-color); - font-size: 0.85rem; - text-align: center; } .eulaCountdown { @@ -1204,6 +1195,18 @@ body.duelFlowActive { #bannerCenter { display: none !important; } + + // Hide all ads + .ad, + .advertisement, + #ad-vertical-left-wrapper, + #ad-vertical-right-wrapper, + #ad-footer-wrapper, + #ad-footer-small-wrapper, + #ad-result-wrapper, + #ad-result-small-wrapper { + display: none !important; + } } // Suppress qsr dev warning globally (fires before duelFlowActive is set) diff --git a/frontend/src/ts/event-handlers/global.ts b/frontend/src/ts/event-handlers/global.ts index e093b3936a25..f61f8adae27e 100644 --- a/frontend/src/ts/event-handlers/global.ts +++ b/frontend/src/ts/event-handlers/global.ts @@ -15,12 +15,41 @@ import * as TestState from "../test/test-state"; import * as TribeState from "../tribe/tribe-state"; import { isAnyChatSuggestionVisible } from "../tribe/tribe-chat"; import * as Tribe from "../tribe/tribe"; +import * as DuelState from "../tribe/duel/duel-state"; document.addEventListener("keydown", async (e) => { if (PageTransition.get()) return; if (e.key === undefined) return; - const pageTestActive: boolean = ActivePage.get() === "test"; + const activePage = ActivePage.get(); + const pageTestActive: boolean = activePage === "test"; + const duelState = DuelState.getFlowState(); + const duelHotkeysLocked = + (activePage === "test" || + activePage === "tribe" || + activePage === "waiting") && + duelState !== "SYSTEM_SELECT" && + duelState !== "OTP"; + + if ( + duelHotkeysLocked && + !isInputElementFocused() && + (e.key === "Tab" || e.key === "Enter" || e.key === "Escape") + ) { + e.preventDefault(); + return; + } + + if ( + duelHotkeysLocked && + e.key.toLowerCase() === "p" && + (e.metaKey || e.ctrlKey) && + e.shiftKey + ) { + e.preventDefault(); + return; + } + if (pageTestActive && !TestState.resultVisible && !isInputElementFocused()) { const popupVisible: boolean = Misc.isAnyPopupVisible(); // this is nested because isAnyPopupVisible is a bit expensive diff --git a/frontend/src/ts/pages/rbh/spectator-screen.ts b/frontend/src/ts/pages/rbh/spectator-screen.ts index 7b5886aaa61c..5983f73caa6e 100644 --- a/frontend/src/ts/pages/rbh/spectator-screen.ts +++ b/frontend/src/ts/pages/rbh/spectator-screen.ts @@ -1,10 +1,13 @@ import Page from "../../pages/page"; import { qs, ElementWithUtils, createElementWithUtils } from "../../utils/dom"; import { addToGlobal } from "../../utils/misc"; +import { getTribesServerUrl } from "../../utils/tribe"; +import { io, type Socket } from "socket.io-client"; // ============ TYPES ============ type LeaderboardEntry = { + id: string; name: string; wpm: number; acc: number; @@ -26,21 +29,66 @@ type DuelState = { maxTime: number; }; +type DuelSpectatorSide = { + id: string; + name: string; + wpm: number; + connected: boolean; +}; + +type DuelSpectatorPayload = { + serverTime: number; + roomId: string | null; + roomState: string | null; + active: boolean; + race: { + startAt: number | null; + duration: number; + }; + sides: { + L: DuelSpectatorSide | null; + R: DuelSpectatorSide | null; + }; +}; + +type DuelSpectatorSubscribeResponse = { + ok: boolean; + state: DuelSpectatorPayload; + leaderboard: unknown; +}; + type ViewType = "leaderboard" | "duel"; // ============ CONFIGURATION ============ -const USE_DUMMY_DATA = true; -const DUMMY_DATA_PATH = "/data/dummy-participants.json"; -const PROD_DATA_PATH = "/data/participants.json"; +const DUEL_LEADERBOARD_ENDPOINT = "/duel/leaderboard"; +const DUEL_SPECTATOR_ENDPOINT = "/duel/spectator"; +const LEADERBOARD_POLL_INTERVAL_MS = 1000; +const DUEL_STATE_POLL_INTERVAL_MS = 250; +const SPECTATOR_SUBSCRIBE_TIMEOUT_MS = 4000; +const DUEL_CLOCK_TICK_MS = 100; +const FALLBACK_DATA_PATH = "/data/dummy-participants.json"; +const DEFAULT_LEFT_NAME = "System Left"; +const DEFAULT_RIGHT_NAME = "System Right"; // ============ STATE ============ // Current view -// let currentView: ViewType = "leaderboard"; -let currentView: ViewType = "duel"; +let currentView: ViewType = "leaderboard"; // Leaderboard state let entries: LeaderboardEntry[] = []; +let leaderboardPollInterval: ReturnType | undefined; +let leaderboardFetchInFlight = false; +let hasWarnedLeaderboardFetch = false; +let duelStatePollInterval: ReturnType | undefined; +let duelStateFetchInFlight = false; +let hasWarnedDuelStateFetch = false; +let duelClockInterval: ReturnType | undefined; +let duelClockStartAt: number | null = null; +let duelClockDuration = 30; +let duelServerOffset = 0; +let spectatorSocket: Socket | null = null; +let spectatorSubscribeTimeout: ReturnType | undefined; // Duel state const duelState: DuelState = { @@ -52,6 +100,7 @@ const duelState: DuelState = { // DOM cache let pageElement: ElementWithUtils | null = null; +let placeholderSummaryEl: ElementWithUtils | null = null; // ============ HELPERS ============ @@ -60,6 +109,561 @@ function getPageElement(): ElementWithUtils | null { return pageElement; } +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function getDuelLeaderboardUrl(): string { + return `${getTribesServerUrl()}${DUEL_LEADERBOARD_ENDPOINT}`; +} + +function getDuelSpectatorUrl(): string { + return `${getTribesServerUrl()}${DUEL_SPECTATOR_ENDPOINT}`; +} + +function normalizeLeaderboardEntry( + value: unknown, + fallbackId?: string, +): LeaderboardEntry | null { + if (value === null || value === undefined || typeof value !== "object") { + return null; + } + + const entry = value as Record; + const id = entry["id"]; + const name = entry["name"]; + const wpm = entry["wpm"]; + const acc = entry["acc"]; + const raw = entry["raw"]; + const consistency = entry["consistency"]; + const date = entry["date"]; + + if ( + typeof name !== "string" || + name.trim().length === 0 || + !isFiniteNumber(wpm) || + !isFiniteNumber(acc) || + !isFiniteNumber(raw) || + !isFiniteNumber(consistency) + ) { + return null; + } + + const resolvedId = + typeof id === "string" && id.trim().length > 0 + ? id.trim() + : (fallbackId ?? name.trim()); + + return { + id: resolvedId, + name: name.trim(), + wpm, + acc, + raw, + consistency, + date: isFiniteNumber(date) ? date : Date.now(), + }; +} + +function parseLeaderboardPayload(payload: unknown): LeaderboardEntry[] { + const parsed: LeaderboardEntry[] = []; + + if (Array.isArray(payload)) { + for (const item of payload) { + const normalized = normalizeLeaderboardEntry(item); + if (normalized) { + parsed.push(normalized); + } + } + return parsed; + } + + if ( + payload !== null && + payload !== undefined && + typeof payload === "object" + ) { + for (const [id, value] of Object.entries(payload)) { + const normalized = normalizeLeaderboardEntry(value, id); + if (normalized) { + parsed.push(normalized); + } + } + return parsed; + } + + return parsed; +} + +function isSameEntry(a: LeaderboardEntry, b: LeaderboardEntry): boolean { + return ( + a.id === b.id && + a.name === b.name && + a.wpm === b.wpm && + a.acc === b.acc && + a.raw === b.raw && + a.consistency === b.consistency && + a.date === b.date + ); +} + +function splitLeaderboardEntries(sortedEntries: LeaderboardEntry[]): { + activeEntries: LeaderboardEntry[]; + placeholderCount: number; +} { + const activeEntries: LeaderboardEntry[] = []; + let placeholderCount = 0; + + for (const entry of sortedEntries) { + if (entry.wpm === -1) { + placeholderCount++; + continue; + } + activeEntries.push(entry); + } + + return { activeEntries, placeholderCount }; +} + +function reconcileLeaderboard(nextEntries: LeaderboardEntry[]): void { + const container = getContainer(); + if (container === null) return; + + const sortedNext = [...nextEntries].sort(leaderboardSort); + const { activeEntries, placeholderCount } = + splitLeaderboardEntries(sortedNext); + + // Fast path: if we have no cached state at all, do a full init + if (rowNodeMap.size === 0 && entryCache.size === 0) { + init(sortedNext); + return; + } + + const nextIdSet = new Set(); + for (const entry of activeEntries) { + nextIdSet.add(entry.id); + } + + // --- Remove stale entries (present in old set but not in new set) --- + const staleIds: string[] = []; + for (const [id, row] of rowNodeMap) { + if (!nextIdSet.has(id)) { + staleIds.push(id); + row.remove(); + } + } + for (const id of staleIds) { + rowNodeMap.delete(id); + entryCache.delete(id); + } + + // --- Add new entries & patch changed entries --- + for (let i = 0; i < activeEntries.length; i++) { + const entry = activeEntries[i]; + if (!entry) continue; + let row = rowNodeMap.get(entry.id); + + if (row === undefined) { + // Brand new entry: create DOM node + row = createRow(entry, i + 1); + container.append(row); + rowNodeMap.set(entry.id, row); + entryCache.set(entry.id, { ...entry }); + } else { + // Existing entry: patch only changed cells + const cached = entryCache.get(entry.id); + if (!cached || !isSameEntry(cached, entry)) { + updateRowContent(row, entry, i + 1); + entryCache.set(entry.id, { ...entry }); + } else { + // Data identical -- but rank might have shifted + const rankEl = row.native.children[0]; + const rankStr = String(i + 1); + if (rankEl && rankEl.textContent !== rankStr) { + rankEl.textContent = rankStr; + } + applyTierClass(row, i + 1, false); + } + } + } + + // Update the authoritative entries array + entries = sortedNext; + updatePlaceholderSummary(container, placeholderCount); + + // Reorder DOM to match new sort order (with bounded FLIP animations) + reorderAndAnimate(container, activeEntries); +} + +async function fetchLeaderboardFromServer(): Promise< + LeaderboardEntry[] | null +> { + const response = await fetch(getDuelLeaderboardUrl(), { + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const payload: unknown = await response.json(); + return parseLeaderboardPayload(payload); +} + +async function fetchFallbackLeaderboard(): Promise { + const response = await fetch(FALLBACK_DATA_PATH, { + cache: "no-store", + }); + if (!response.ok) return []; + const payload: unknown = await response.json(); + return parseLeaderboardPayload(payload); +} + +async function syncLeaderboardFromServer(): Promise { + if (leaderboardFetchInFlight) return false; + leaderboardFetchInFlight = true; + + try { + const nextEntries = await fetchLeaderboardFromServer(); + if (nextEntries !== null) { + reconcileLeaderboard(nextEntries); + } + hasWarnedLeaderboardFetch = false; + return true; + } catch (error) { + if (!hasWarnedLeaderboardFetch) { + console.warn( + "[SpectatorScreen] Failed to fetch duel leaderboard:", + error, + ); + hasWarnedLeaderboardFetch = true; + } + return false; + } finally { + leaderboardFetchInFlight = false; + } +} + +function startLeaderboardPolling(): void { + stopLeaderboardPolling(); + leaderboardPollInterval = setInterval(() => { + void syncLeaderboardFromServer(); + }, LEADERBOARD_POLL_INTERVAL_MS); +} + +function stopLeaderboardPolling(): void { + if (leaderboardPollInterval) { + clearInterval(leaderboardPollInterval); + leaderboardPollInterval = undefined; + } +} + +function normalizeSpectatorSide( + value: unknown, + fallbackName: string, +): DuelSpectatorSide | null { + if (value === null || value === undefined || typeof value !== "object") { + return null; + } + + const side = value as Record; + const id = side["id"]; + const name = side["name"]; + const wpm = side["wpm"]; + const connected = side["connected"]; + + if (typeof id !== "string" || id.trim().length === 0) return null; + + return { + id: id.trim(), + name: + typeof name === "string" && name.trim().length > 0 + ? name.trim() + : fallbackName, + wpm: isFiniteNumber(wpm) ? wpm : 0, + connected: typeof connected === "boolean" ? connected : true, + }; +} + +function parseDuelSpectatorPayload( + payload: unknown, +): DuelSpectatorPayload | null { + if ( + payload === null || + payload === undefined || + typeof payload !== "object" + ) { + return null; + } + + const record = payload as Record; + const serverTime = record["serverTime"]; + const active = record["active"]; + const race = record["race"]; + const sides = record["sides"]; + const roomId = record["roomId"]; + const roomState = record["roomState"]; + + if (!isFiniteNumber(serverTime) || typeof active !== "boolean") { + return null; + } + + if (race === null || race === undefined || typeof race !== "object") { + return null; + } + if (sides === null || sides === undefined || typeof sides !== "object") { + return null; + } + + const raceRecord = race as Record; + const startAtRaw = raceRecord["startAt"]; + const durationRaw = raceRecord["duration"]; + + const sidesRecord = sides as Record; + const left = normalizeSpectatorSide(sidesRecord["L"], DEFAULT_LEFT_NAME); + const right = normalizeSpectatorSide(sidesRecord["R"], DEFAULT_RIGHT_NAME); + + return { + serverTime, + roomId: typeof roomId === "string" ? roomId : null, + roomState: typeof roomState === "string" ? roomState : null, + active, + race: { + startAt: isFiniteNumber(startAtRaw) ? startAtRaw : null, + duration: + isFiniteNumber(durationRaw) && durationRaw > 0 ? durationRaw : 30, + }, + sides: { + L: left, + R: right, + }, + }; +} + +function stopDuelClock(): void { + if (duelClockInterval) { + clearInterval(duelClockInterval); + duelClockInterval = undefined; + } +} + +function resetDuelClock(): void { + stopDuelClock(); + duelClockStartAt = null; + duelClockDuration = 30; + duelServerOffset = 0; +} + +function getSyncedServerNow(): number { + return Date.now() + duelServerOffset; +} + +function tickDuelClock(): void { + if (duelClockStartAt === null) { + updateTimer(duelClockDuration, duelClockDuration); + return; + } + + const elapsed = Math.max(0, (getSyncedServerNow() - duelClockStartAt) / 1000); + const timeLeft = Math.max(0, duelClockDuration - elapsed); + updateTimer(timeLeft, duelClockDuration); +} + +function syncDuelClock( + startAt: number | null, + duration: number, + serverTime: number, +): void { + duelClockDuration = duration > 0 ? duration : 30; + duelServerOffset = serverTime - Date.now(); + duelClockStartAt = startAt; + + tickDuelClock(); + + if (duelClockStartAt === null) { + stopDuelClock(); + return; + } + + duelClockInterval ??= setInterval(() => { + tickDuelClock(); + }, DUEL_CLOCK_TICK_MS); +} + +function applyDuelSpectatorState(state: DuelSpectatorPayload): void { + const left = state.sides.L; + const right = state.sides.R; + + updatePlayer1({ + name: left?.name ?? DEFAULT_LEFT_NAME, + wpm: left?.wpm ?? 0, + isConnected: left?.connected ?? false, + }); + updatePlayer2({ + name: right?.name ?? DEFAULT_RIGHT_NAME, + wpm: right?.wpm ?? 0, + isConnected: right?.connected ?? false, + }); + + syncDuelClock(state.race.startAt, state.race.duration, state.serverTime); + + if (state.active) { + void showDuel(); + } else { + void showLeaderboard(); + } +} + +async function fetchDuelStateFromServer(): Promise { + const response = await fetch(getDuelSpectatorUrl(), { + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const payload: unknown = await response.json(); + return parseDuelSpectatorPayload(payload); +} + +async function syncDuelStateFromServer(): Promise { + if (duelStateFetchInFlight) return false; + duelStateFetchInFlight = true; + + try { + const payload = await fetchDuelStateFromServer(); + hasWarnedDuelStateFetch = false; + if (!payload) return false; + applyDuelSpectatorState(payload); + return true; + } catch (error) { + if (!hasWarnedDuelStateFetch) { + console.warn("[SpectatorScreen] Failed to fetch duel live state:", error); + hasWarnedDuelStateFetch = true; + } + return false; + } finally { + duelStateFetchInFlight = false; + } +} + +function startDuelStatePolling(): void { + stopDuelStatePolling(); + duelStatePollInterval = setInterval(() => { + void syncDuelStateFromServer(); + }, DUEL_STATE_POLL_INTERVAL_MS); +} + +function stopDuelStatePolling(): void { + if (duelStatePollInterval) { + clearInterval(duelStatePollInterval); + duelStatePollInterval = undefined; + } +} + +function startFallbackPolling(): void { + startDuelStatePolling(); + startLeaderboardPolling(); +} + +function stopFallbackPolling(): void { + stopDuelStatePolling(); + stopLeaderboardPolling(); +} + +function clearSpectatorSubscribeTimeout(): void { + if (!spectatorSubscribeTimeout) return; + clearTimeout(spectatorSubscribeTimeout); + spectatorSubscribeTimeout = undefined; +} + +function onSpectatorStatePushed(payload: unknown): void { + const parsed = parseDuelSpectatorPayload(payload); + if (!parsed) return; + applyDuelSpectatorState(parsed); +} + +function onLeaderboardPushed(payload: unknown): void { + const parsed = parseLeaderboardPayload(payload); + reconcileLeaderboard(parsed); +} + +function subscribeSpectatorFeed(): void { + if (!spectatorSocket || !spectatorSocket.connected) return; + + clearSpectatorSubscribeTimeout(); + spectatorSubscribeTimeout = setTimeout(() => { + startFallbackPolling(); + console.warn( + `[SpectatorScreen] duel_spectator_subscribe timeout (${SPECTATOR_SUBSCRIBE_TIMEOUT_MS}ms), using HTTP fallback`, + ); + }, SPECTATOR_SUBSCRIBE_TIMEOUT_MS); + + spectatorSocket.emit( + "duel_spectator_subscribe", + (response: DuelSpectatorSubscribeResponse) => { + clearSpectatorSubscribeTimeout(); + + if (!response.ok) { + startFallbackPolling(); + return; + } + + stopFallbackPolling(); + const parsedState = parseDuelSpectatorPayload(response.state); + if (parsedState) { + applyDuelSpectatorState(parsedState); + } + onLeaderboardPushed(response.leaderboard); + }, + ); +} + +function setupSpectatorPush(): void { + teardownSpectatorPush(); + + spectatorSocket = io(getTribesServerUrl(), { + autoConnect: true, + reconnection: true, + reconnectionAttempts: Infinity, + query: { + name: "Spectator", + }, + }); + + spectatorSocket.on("connect", () => { + subscribeSpectatorFeed(); + }); + + spectatorSocket.on("duel_spectator_state", (payload: unknown) => { + onSpectatorStatePushed(payload); + }); + + spectatorSocket.on("duel_leaderboard_snapshot", (payload: unknown) => { + onLeaderboardPushed(payload); + }); + + spectatorSocket.on("disconnect", () => { + startFallbackPolling(); + }); + + spectatorSocket.on("connect_error", (error: Error) => { + startFallbackPolling(); + console.warn("[SpectatorScreen] Spectator socket connect error:", error); + }); +} + +function teardownSpectatorPush(): void { + clearSpectatorSubscribeTimeout(); + + if (!spectatorSocket) return; + + if (spectatorSocket.connected) { + spectatorSocket.emit("duel_spectator_unsubscribe"); + } + spectatorSocket.removeAllListeners(); + spectatorSocket.disconnect(); + spectatorSocket = null; +} + // ============ VIEW SWITCHING ============ const TRANSITION_DURATION = 600; // ms @@ -108,26 +712,49 @@ export async function showDuel(): Promise { const leaderboardView = page.qs("#leaderboardView"); const duelView = page.qs("#duelView"); + const countdownOverlay = page.qs("#countdownOverlay"); + const countdownNumber = page.qs("#countdownNumber"); - if (!leaderboardView || !duelView) return; + if (!leaderboardView || !duelView || !countdownOverlay || !countdownNumber) { + return; + } isTransitioning = true; - // Fade out + blur current view + // Fade out leaderboard leaderboardView.addClass("transitioning-out"); - await new Promise((resolve) => setTimeout(resolve, TRANSITION_DURATION)); leaderboardView.addClass("hidden"); leaderboardView.removeClass("transitioning-out"); - // Show and fade in new view + // Show duel view blurred behind countdown duelView.removeClass("hidden"); + duelView.setStyle({ filter: "blur(24px)", opacity: "0.5" }); + + // Show countdown overlay with fade-in + countdownOverlay.addClass("fade-in"); + countdownOverlay.removeClass("hidden"); + void countdownOverlay.native.offsetHeight; // Force reflow + countdownOverlay.removeClass("fade-in"); + + // Run 10-second countdown + for (let i = 10; i >= 1; i--) { + countdownNumber.setText(i.toString()); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + // Fade out countdown overlay + countdownOverlay.addClass("fade-out"); + await new Promise((resolve) => setTimeout(resolve, 400)); + countdownOverlay.addClass("hidden"); + countdownOverlay.removeClass("fade-out"); + + // Reveal duel view (remove blur) + duelView.setStyle({ filter: "blur(0)", opacity: "1" }); duelView.addClass("transitioning-in"); - // Force reflow before removing transition class void duelView.native.offsetHeight; - await new Promise((resolve) => setTimeout(resolve, 50)); duelView.removeClass("transitioning-in"); @@ -141,32 +768,88 @@ export function getCurrentView(): ViewType { // ============ LEADERBOARD FUNCTIONS ============ -function createRow(entry: LeaderboardEntry, rank: number): ElementWithUtils { - const row = createElementWithUtils("div", { - classList: ["leaderboardRow"], - dataset: { name: entry.name }, +// --- Optimization caches --- +// Maps entry.id -> the live DOM node for that row (avoids re-querying) +const rowNodeMap = new Map(); +// Maps entry.id -> the last-rendered data snapshot (avoids redundant DOM writes) +const entryCache = new Map(); +// Cached container reference (avoids re-querying #leaderboardBody every tick) +let cachedContainer: ElementWithUtils | null = null; + +// FLIP animation threshold: only animate position changes for the top N rows. +// Rows beyond this index skip getBoundingClientRect entirely. +const FLIP_ANIMATION_LIMIT = 20; + +function getContainer(): ElementWithUtils | null { + cachedContainer ??= qs("#leaderboardBody"); + return cachedContainer; +} + +function clearCaches(): void { + rowNodeMap.clear(); + entryCache.clear(); + placeholderSummaryEl = null; +} + +// --- Formatting helpers (pure, no DOM) --- + +function formatStat( + val: number, + isPlaceholder: boolean, + isPct: boolean = false, +): string { + if (isPlaceholder) return "-"; + return isPct ? Math.floor(val) + "%" : String(Math.round(val)); +} + +function formatFloat( + val: number, + isPlaceholder: boolean, + isPct: boolean = false, +): string { + if (isPlaceholder) return "-"; + return isPct ? val.toFixed(2) + "%" : val.toFixed(2); +} + +function formatDate(date: number, isPlaceholder: boolean): string { + if (isPlaceholder || date <= 0) return "-"; + return new Date(date).toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", }); +} - const isPlaceholder = entry.wpm === -1; +const TIER_CLASSES = [ + "tier-podium", + "tier-contender", + "tier-field", + "tier-placeholder", +]; - const formatStat = (val: number, isPct: boolean = false): string | number => { - if (isPlaceholder) return "-"; - return isPct ? Math.floor(val) + "%" : Math.round(val); - }; +function setTextIfChanged(target: Element | null, value: string): void { + if (target && target.textContent !== value) { + target.textContent = value; + } +} - const formatFloat = (val: number, isPct: boolean = false): string => { - if (isPlaceholder) return "-"; - return isPct ? val.toFixed(2) + "%" : val.toFixed(2); - }; +function applyTierClass( + row: ElementWithUtils, + rank: number, + isPlaceholder: boolean, +): void { + row.removeClass(TIER_CLASSES); + row.addClass(getTierClass(rank, isPlaceholder)); +} - const dateStr = - !isPlaceholder && entry.date > 0 - ? new Date(entry.date).toLocaleDateString("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }) - : "-"; +// --- Row creation (only used for genuinely new entries) --- + +function createRow(entry: LeaderboardEntry, rank: number): ElementWithUtils { + const isPlaceholder = entry.wpm === -1; + const row = createElementWithUtils("div", { + classList: ["leaderboardRow", getTierClass(rank, isPlaceholder)], + dataset: { key: entry.id, name: entry.name }, + }); row.setHtml(`
${rank}
@@ -176,90 +859,336 @@ function createRow(entry: LeaderboardEntry, rank: number): ElementWithUtils {
${entry.name}
-
${formatStat(entry.wpm)}
-
${formatStat(entry.raw)}
-
${formatFloat(entry.wpm)}
-
${formatFloat(entry.acc, true)}
-
${formatFloat(entry.raw)}
-
${formatFloat(entry.consistency, true)}
-
${dateStr}
+
${formatStat(entry.wpm, isPlaceholder)}
+
${formatStat(entry.raw, isPlaceholder)}
+
${formatFloat(entry.wpm, isPlaceholder)}
+
${formatFloat(entry.acc, isPlaceholder, true)}
+
${formatFloat(entry.raw, isPlaceholder)}
+
${formatFloat(entry.consistency, isPlaceholder, true)}
+
${formatDate(entry.date, isPlaceholder)}
`); return row; } -export function init(initialData: LeaderboardEntry[]): void { - entries = [...initialData].sort((a, b) => b.wpm - a.wpm); - const container = qs("#leaderboardBody"); - if (container === null) return; +// --- In-place cell patching (avoids destroying/recreating the row) --- + +function updateRowContent( + row: ElementWithUtils, + entry: LeaderboardEntry, + rank: number, +): void { + const native = row.native; + const children = native.children; + // children order must match createRow's HTML structure: + // [0] rank, [1] name wrapper, [2] wpm-stat-narrow, [3] raw-stat-narrow, + // [4] wpm-stat-wide, [5] acc-stat-wide, [6] raw-stat-wide, + // [7] consistency-stat-wide, [8] date + if (children.length < 9) return; - container.empty(); - entries.forEach((e, i) => { - container.append(createRow(e, i + 1)); + const isPlaceholder = entry.wpm === -1; + const cached = entryCache.get(entry.id); + applyTierClass(row, rank, isPlaceholder); + + // Rank always needs checking because it depends on sort position + const rankStr = String(rank); + setTextIfChanged(children.item(0), rankStr); + + // Only patch fields that actually changed vs. the cached version + if (!cached || cached.name !== entry.name) { + // Update the nested .name element inside avatarNameBadge + const nameWrapper = children.item(1); + if (nameWrapper instanceof HTMLElement) { + const nameEl = nameWrapper.querySelector(".name"); + setTextIfChanged(nameEl, entry.name); + } + // Also update data-name attribute + native.dataset["name"] = entry.name; + } + + if (!cached || cached.wpm !== entry.wpm) { + const wpmNarrow = formatStat(entry.wpm, isPlaceholder); + setTextIfChanged(children.item(2), wpmNarrow); + const wpmWide = formatFloat(entry.wpm, isPlaceholder); + setTextIfChanged(children.item(4), wpmWide); + } + + if (!cached || cached.raw !== entry.raw) { + const rawNarrow = formatStat(entry.raw, isPlaceholder); + setTextIfChanged(children.item(3), rawNarrow); + const rawWide = formatFloat(entry.raw, isPlaceholder); + setTextIfChanged(children.item(6), rawWide); + } + + if (!cached || cached.acc !== entry.acc) { + const accWide = formatFloat(entry.acc, isPlaceholder, true); + setTextIfChanged(children.item(5), accWide); + } + + if (!cached || cached.consistency !== entry.consistency) { + const conWide = formatFloat(entry.consistency, isPlaceholder, true); + setTextIfChanged(children.item(7), conWide); + } + + if (!cached || cached.date !== entry.date) { + const dateStr = formatDate(entry.date, isPlaceholder); + setTextIfChanged(children.item(8), dateStr); + } +} + +// --- Sort comparator: wpm descending, placeholders (wpm === -1) to bottom --- + +function leaderboardSort(a: LeaderboardEntry, b: LeaderboardEntry): number { + // Placeholders always go to the bottom + if (a.wpm === -1 && b.wpm !== -1) return 1; + if (a.wpm !== -1 && b.wpm === -1) return -1; + return b.wpm - a.wpm; +} + +// --- Tier classification --- + +function getTierClass(rank: number, isPlaceholder: boolean): string { + if (isPlaceholder) return "tier-placeholder"; + if (rank <= 3) return "tier-podium"; + if (rank <= 10) return "tier-contender"; + return "tier-field"; +} + +// --- Placeholder summary (collapses N placeholders into one row) --- + +function createPlaceholderSummary(count: number): ElementWithUtils { + const s = count !== 1 ? "s" : ""; + const row = createElementWithUtils("div", { + classList: ["leaderboardRow", "placeholder-summary"], }); + row.setHtml(` +
+
+ ${count} + participant${s} awaiting first race +
+
+
+
+
+
+
+
+ `); + return row; } -export function update(updatedEntry: LeaderboardEntry): void { - const container = qs("#leaderboardBody"); - if (container === null) return; +function updatePlaceholderSummary( + container: ElementWithUtils, + count: number, +): void { + if (count === 0) { + if (placeholderSummaryEl) { + placeholderSummaryEl.remove(); + placeholderSummaryEl = null; + } + return; + } - const idx = entries.findIndex((e) => e.name === updatedEntry.name); - if (idx !== -1) entries[idx] = updatedEntry; - else entries.push(updatedEntry); + if (!placeholderSummaryEl) { + placeholderSummaryEl = createPlaceholderSummary(count); + } else { + const nameCol = placeholderSummaryEl.native.querySelector(".col.name"); + if (nameCol) { + const s = count !== 1 ? "s" : ""; + nameCol.innerHTML = `${count} participant${s} awaiting first race`; + } + } + + // Always ensure summary is at the bottom + container.append(placeholderSummaryEl); +} +// --- Reorder DOM nodes to match the entries array, with bounded FLIP --- + +function reorderAndAnimate( + container: ElementWithUtils, + sortedEntries: LeaderboardEntry[], + changedId?: string, +): void { + const containerNative = container.native; + + // --- FLIP: First --- batch all layout reads BEFORE any writes --- + // Only measure positions for the top N rows to avoid thrashing const oldPositions = new Map(); - container.qsa(".leaderboardRow").forEach((row) => { - const name = row.native.dataset["name"]; - if (name !== undefined && name !== "") { - oldPositions.set(name, row.native.getBoundingClientRect().top); + const measureLimit = Math.min(sortedEntries.length, FLIP_ANIMATION_LIMIT); + + for (let i = 0; i < measureLimit; i++) { + const measuredEntry = sortedEntries[i]; + if (!measuredEntry) continue; + const id = measuredEntry.id; + const node = rowNodeMap.get(id); + if (node !== undefined) { + oldPositions.set(id, node.native.getBoundingClientRect().top); + } + } + + // --- Reorder DOM nodes using insertBefore --- + // This moves existing nodes without destroying them. + // If the node is already in the correct position, insertBefore is a no-op + // in terms of the browser's internal representation. + let refNode: Node | null = containerNative.firstChild; + for (const entry of sortedEntries) { + const row = rowNodeMap.get(entry.id); + if (row === undefined) continue; + const rowNative = row.native; + + if (refNode !== rowNative) { + // Move node to the correct position + containerNative.insertBefore(rowNative, refNode); + } else { + // Already in position, advance reference + refNode = refNode.nextSibling; + } + } + + // --- FLIP: Last + Invert + Play --- only for top N rows --- + requestAnimationFrame(() => { + for (let i = 0; i < measureLimit; i++) { + const entry = sortedEntries[i]; + if (!entry) continue; + const id = entry.id; + const node = rowNodeMap.get(id); + if (node === undefined) continue; + + const oldTop = oldPositions.get(id); + if (oldTop === undefined) { + // New entry appearing in top N -- fade in + node.animate({ opacity: [0, 1], duration: 300 }); + continue; + } + + const newTop = node.native.getBoundingClientRect().top; + const delta = oldTop - newTop; + + if (delta === 0 && id !== changedId) continue; + + const isTarget = id === changedId; + if (isTarget) { + node.native.style.zIndex = "100"; + node.native.style.position = "relative"; + node.animate({ + translateY: [delta, delta * 0.3, 0], + scale: [1, 1.03, 1.03, 1], + boxShadow: [ + "0 0 0 0 transparent", + "0 8px 32px rgba(255,255,255,0.15), 0 4px 16px rgba(100,200,255,0.2)", + "0 8px 32px rgba(255,255,255,0.15), 0 4px 16px rgba(100,200,255,0.2)", + "0 0 0 0 transparent", + ], + duration: 1000, + easing: "easeOutExpo", + }); + } else if (delta !== 0) { + node.animate({ + translateY: [delta, 0], + duration: 600, + easing: "easeOutQuint", + }); + } } }); +} + +// --- Public API: init (full rebuild, used only on first load or hard reset) --- + +export function init(initialData: LeaderboardEntry[]): void { + const container = getContainer(); + if (container === null) return; + + clearCaches(); + entries = [...initialData].sort(leaderboardSort); + const { activeEntries, placeholderCount } = splitLeaderboardEntries(entries); - entries.sort((a, b) => b.wpm - a.wpm); container.empty(); - entries.forEach((e, i) => { - container.append(createRow(e, i + 1)); - }); + for (let i = 0; i < activeEntries.length; i++) { + const entry = activeEntries[i]; + if (!entry) continue; + const row = createRow(entry, i + 1); + container.append(row); + rowNodeMap.set(entry.id, row); + entryCache.set(entry.id, { ...entry }); + } + updatePlaceholderSummary(container, placeholderCount); +} - container.qsa(".leaderboardRow").forEach((row) => { - const name = row.native.dataset["name"]; - if (name === undefined || name === "") return; +// --- Public API: update (single-entry change, differential) --- - const oldTop = oldPositions.get(name); - const newTop = row.native.getBoundingClientRect().top; +export function update(updatedEntry: LeaderboardEntry): void { + const container = getContainer(); + if (container === null) return; - if (oldTop !== undefined) { - const delta = oldTop - newTop; - const isTarget = name === updatedEntry.name; - - if (delta !== 0 || isTarget) { - if (isTarget) { - row.native.style.zIndex = "100"; - row.native.style.position = "relative"; - row.animate({ - translateY: [delta, delta * 0.3, 0], - scale: [1, 1.03, 1.03, 1], - boxShadow: [ - "0 0 0 0 transparent", - "0 8px 32px rgba(255,255,255,0.15), 0 4px 16px rgba(100,200,255,0.2)", - "0 8px 32px rgba(255,255,255,0.15), 0 4px 16px rgba(100,200,255,0.2)", - "0 0 0 0 transparent", - ], - duration: 1000, - easing: "easeOutExpo", - }); - } else { - row.animate({ - translateY: [delta, 0], - duration: 600, - easing: "easeOutQuint", - }); - } - } + // Update or insert into the entries array + const idx = entries.findIndex((e) => e.id === updatedEntry.id); + if (idx !== -1) { + entries[idx] = updatedEntry; + } else { + entries.push(updatedEntry); + } + + // Re-sort the data + entries.sort(leaderboardSort); + const { activeEntries, placeholderCount } = splitLeaderboardEntries(entries); + const activeIdSet = new Set(activeEntries.map((entry) => entry.id)); + + // Remove stale rows (deleted entries or entries that turned into placeholders) + const staleIds: string[] = []; + for (const [id, row] of rowNodeMap) { + if (!activeIdSet.has(id)) { + staleIds.push(id); + row.remove(); + } + } + for (const staleId of staleIds) { + rowNodeMap.delete(staleId); + entryCache.delete(staleId); + } + + // Ensure rows exist for all active entries + for (let i = 0; i < activeEntries.length; i++) { + const entry = activeEntries[i]; + if (!entry) continue; + if (!rowNodeMap.has(entry.id)) { + const row = createRow(entry, i + 1); + container.append(row); + rowNodeMap.set(entry.id, row); + entryCache.set(entry.id, { ...entry }); + } + } + + // Patch all rows whose rank or data changed, then reorder DOM + for (let i = 0; i < activeEntries.length; i++) { + const entry = activeEntries[i]; + if (!entry) continue; + const existingRow = rowNodeMap.get(entry.id); + if (existingRow === undefined) continue; + const cached = entryCache.get(entry.id); + // Only touch DOM if data changed or rank shifted + if (!cached || !isSameEntry(cached, entry)) { + updateRowContent(existingRow, entry, i + 1); + entryCache.set(entry.id, { ...entry }); } else { - row.animate({ opacity: [0, 1], duration: 300 }); + // Rank might have changed even if data is the same (another entry moved) + const rankEl = existingRow.native.children[0]; + const rankStr = String(i + 1); + if (rankEl && rankEl.textContent !== rankStr) { + rankEl.textContent = rankStr; + } + applyTierClass(existingRow, i + 1, false); } - }); + } + + updatePlaceholderSummary(container, placeholderCount); + reorderAndAnimate( + container, + activeEntries, + updatedEntry.wpm === -1 ? undefined : updatedEntry.id, + ); } // ============ DUEL FUNCTIONS ============ @@ -328,19 +1257,23 @@ function updatePlayerCard(playerNum: 1 | 2, playerState: PlayerState): void { const waitingState = card.qs(".waitingState"); const nameElement = card.qs(".name"); const wpmValue = card.qs(".wpmValue"); + const waitingText = card.qs(".waitingText"); if (!playerInfo || !waitingState || !nameElement || !wpmValue) return; + const fallbackName = playerNum === 1 ? DEFAULT_LEFT_NAME : DEFAULT_RIGHT_NAME; + if (playerState.isConnected) { playerInfo.removeClass("hidden"); waitingState.addClass("hidden"); nameElement.setText( - playerState.name !== "" ? playerState.name : `Player ${playerNum}`, + playerState.name !== "" ? playerState.name : fallbackName, ); wpmValue.setText(Math.round(playerState.wpm).toString()); } else { playerInfo.addClass("hidden"); waitingState.removeClass("hidden"); + waitingText?.setText(`Waiting for ${playerState.name || fallbackName}`); } } @@ -364,8 +1297,8 @@ function updateTimerDisplay(): void { } export function reset(): void { - duelState.player1 = { name: "", wpm: 0, isConnected: false }; - duelState.player2 = { name: "", wpm: 0, isConnected: false }; + duelState.player1 = { name: DEFAULT_LEFT_NAME, wpm: 0, isConnected: false }; + duelState.player2 = { name: DEFAULT_RIGHT_NAME, wpm: 0, isConnected: false }; duelState.timeLeft = 30; duelState.maxTime = 30; @@ -411,12 +1344,20 @@ export const page = new Page({ path: "/rbh/spectator-screen", beforeShow: async () => { pageElement = null; + cachedContainer = null; currentView = "leaderboard"; + entries = []; + clearCaches(); + hasWarnedLeaderboardFetch = false; + leaderboardFetchInFlight = false; + hasWarnedDuelStateFetch = false; + duelStateFetchInFlight = false; + teardownSpectatorPush(); + stopFallbackPolling(); + resetDuelClock(); reset(); }, afterShow: async () => { - void showLeaderboard(); - addToGlobal({ spectatorScreen: { showLeaderboard, @@ -432,54 +1373,24 @@ export const page = new Page({ }, }); - // ============ WEBSOCKET STUB ============ - // TODO: Add WebSocket listener here - // socket.on("raceStart", () => showDuel()); - // socket.on("raceEnd", () => showLeaderboard()); - - const dataPath = USE_DUMMY_DATA ? DUMMY_DATA_PATH : PROD_DATA_PATH; - const response = await fetch(dataPath); - const participants = (await response.json()) as LeaderboardEntry[]; - - const placeholders = participants - .map((d) => ({ - ...d, - wpm: -1, - acc: -1, - raw: -1, - consistency: -1, - date: -1, - })) - .sort((a, b) => a.name.localeCompare(b.name)); - - init(placeholders); - - if (USE_DUMMY_DATA) { - participants.forEach((realEntry, idx) => { - setTimeout( - () => { - update(realEntry); - }, - 1000 + idx * 800, - ); - }); - - const hamiltonEntry = participants.find((p) => p.name === "Hamilton"); - if (hamiltonEntry) { - setTimeout(() => { - update({ - name: hamiltonEntry.name, - wpm: 200, - acc: hamiltonEntry.acc, - raw: hamiltonEntry.raw, - consistency: hamiltonEntry.consistency, - date: hamiltonEntry.date, - }); - }, 7000); - } + const duelLoaded = await syncDuelStateFromServer(); + if (!duelLoaded) { + void showLeaderboard(); + } + + const loadedFromServer = await syncLeaderboardFromServer(); + if (!loadedFromServer) { + const fallbackEntries = await fetchFallbackLeaderboard().catch( + (): LeaderboardEntry[] => [], + ); + reconcileLeaderboard(fallbackEntries); } + + setupSpectatorPush(); }, beforeHide: async () => { - // Cleanup if needed + teardownSpectatorPush(); + stopFallbackPolling(); + stopDuelClock(); }, }); diff --git a/frontend/src/ts/tribe/duel/duel-flow.ts b/frontend/src/ts/tribe/duel/duel-flow.ts index 200b04fd7148..7a78805334f1 100644 --- a/frontend/src/ts/tribe/duel/duel-flow.ts +++ b/frontend/src/ts/tribe/duel/duel-flow.ts @@ -15,6 +15,8 @@ import * as Random from "../../utils/random"; import TribeSocket from "../tribe-socket"; import * as TribeSound from "../tribe-sound"; import * as TribeCarets from "../tribe-carets"; +import * as TribeResults from "../tribe-results"; +import * as TribeChartController from "../tribe-chart-controller"; import type { DuelSide } from "./duel-state"; import * as TribeState from "../tribe-state"; @@ -35,6 +37,8 @@ let eulaInterval: ReturnType | undefined; let eulaTimeout: ReturnType | undefined; let waitingNavigationTimeout: ReturnType | undefined; let showLobbyTimeout: ReturnType | undefined; +let raceCountdownInterval: ReturnType | undefined; +let showAutoAdvanceTimeout: ReturnType | undefined; /** * Initialize the duel flow. @@ -177,51 +181,70 @@ async function handleSideSelect(side: DuelSide): Promise { async function handleAuthenticate(otp: string): Promise { console.log(`[DuelFlow] Authenticating with OTP...`); - if (DuelState.getFlowState() !== "OTP") { - console.warn( - `[DuelFlow] Ignoring OTP submit outside OTP state (${DuelState.getFlowState()})`, - ); + TribePageOtp.hideError(); + + const side = DuelState.getSide(); + if (!side) { + TribePageOtp.showError("No side selected. Please refresh."); return false; } - TribePageOtp.hideError(); - - // If not connected, connect first + // If not connected, connect first then authenticate if (!TribeSocket.getId()) { TribePageOtp.setLoading(true); - onConnectedCallback = async (): Promise => { - // After connect, register side and then authenticate - const side = DuelState.getSide(); - if (!side) { - TribePageOtp.showError("No side selected"); + const authResult = await new Promise((resolve) => { + let settled = false; + const settle = (value: boolean): void => { + if (settled) return; + settled = true; + resolve(value); + }; + + const connectTimeout = setTimeout(() => { + onConnectedCallback = undefined; + TribePageOtp.showError("Connection timed out. Please try again."); TribePageOtp.setLoading(false); - return; - } + settle(false); + }, 10_000); + + onConnectedCallback = async (): Promise => { + clearTimeout(connectTimeout); + + // Register side (may already be registered — server handles idempotently) + const registerResult = await registerCurrentSide(side, true); + if (!registerResult.ok) { + TribePageOtp.showError( + registerResult.error ?? "Failed to register side", + ); + TribePageOtp.setLoading(false); + settle(false); + return; + } - // Register side - const registerResult = await registerCurrentSide(side, true); - if (!registerResult.ok) { - TribePageOtp.showError( - registerResult.error ?? "Failed to register side", - ); + // Now authenticate + const result = await doAuthenticate(otp); TribePageOtp.setLoading(false); - return; - } + if (result) { + await showEulaPage(); + } + settle(result); + }; - // Now authenticate - const result = await doAuthenticate(otp); - TribePageOtp.setLoading(false); - if (result) { - await showEulaPage(); - } - }; + TribeSocket.connect(); + }); - TribeSocket.connect(); - return false; // Will continue in callback + return authResult; + } + + // Already connected — ensure side is registered before authenticating + // (page-load registration may still be in flight) + const registerResult = await registerCurrentSide(side, false); + if (!registerResult.ok) { + TribePageOtp.showError(registerResult.error ?? "Failed to register side"); + return false; } - // Already connected, just authenticate const result = await doAuthenticate(otp); if (result) { await showEulaPage(); @@ -418,14 +441,32 @@ async function startPracticeFlow(): Promise { ? PRACTICE_1_DURATION_SECONDS : PRACTICE_2_DURATION_SECONDS; + // Ensure stale race UI/charts from a previous duel round do not leak forward + hideAutoAdvanceButton(); + hideCountdownBelowResults(); + TribeChartController.destroyAllCharts(); + TribeResults.reset("result"); + DuelState.setFlowState(isPractice1 ? "PRACTICE_1" : "PRACTICE_2"); // Configure for time mode with appropriate duration - UpdateConfig.setConfig("mode", "time", { nosave: true }); - UpdateConfig.setConfig("time", duration, { nosave: true }); - UpdateConfig.setConfig("language", "english", { nosave: true }); - UpdateConfig.setConfig("numbers", false, { nosave: true }); - UpdateConfig.setConfig("punctuation", false, { nosave: true }); + UpdateConfig.setConfig("mode", "time", { nosave: true, tribeOverride: true }); + UpdateConfig.setConfig("time", duration, { + nosave: true, + tribeOverride: true, + }); + UpdateConfig.setConfig("language", "english", { + nosave: true, + tribeOverride: true, + }); + UpdateConfig.setConfig("numbers", false, { + nosave: true, + tribeOverride: true, + }); + UpdateConfig.setConfig("punctuation", false, { + nosave: true, + tribeOverride: true, + }); // Show banner on test page after navigation const bannerName = DuelState.getUsername() ?? DuelState.getSide() ?? "?"; @@ -731,15 +772,31 @@ export function onOpponentLeft(_side: DuelSide): void { duration: 3, }); - // If we're in lobby or racing, reset to lobby waiting state + // If we're in lobby, racing, or results, cancel everything and return to lobby const state = DuelState.getFlowState(); - if (state === "LOBBY" || state === "RACING") { + if (state === "LOBBY" || state === "RACING" || state === "RESULTS") { + // Cancel all pending race timers (scheduled navigation, countdown, etc.) + cleanupTransientTimers(); + hideAutoAdvanceButton(); + hideCountdownBelowResults(); + hideDuelBanner(); + + // Hide countdown overlay if it was showing + void import("../tribe-countdown").then((TribeCountdown) => { + TribeCountdown.hide2(); + }); + DuelState.setFlowState("LOBBY"); - // Refresh the lobby UI - if (state === "LOBBY") { + // Navigate back to tribe/lobby + NavigationEvent.dispatch("/tribe", { + tribeOverride: true, + force: true, + }); + + setTimeout(() => { setupDuelLobby(); - } + }, 300); } } @@ -775,6 +832,10 @@ export function onRaceScheduled( // Set state to RACING DuelState.setFlowState("RACING"); + // Starting a fresh duel race: ensure previous charts/result rows do not bleed over + TribeChartController.destroyAllCharts(); + TribeResults.reset("result"); + // Mark self as typing and set room state to RACE_ONGOING // This is required for input to work (keydown handler checks isRaceActive) // Set room to RACE_COUNTDOWN during the waiting/countdown phase. @@ -805,11 +866,23 @@ export function onRaceScheduled( // Configure race settings BEFORE navigation // This ensures words are generated with correct config - UpdateConfig.setConfig("mode", "time", { nosave: true }); - UpdateConfig.setConfig("time", raceDurationSeconds, { nosave: true }); - UpdateConfig.setConfig("language", "english", { nosave: true }); - UpdateConfig.setConfig("numbers", false, { nosave: true }); - UpdateConfig.setConfig("punctuation", false, { nosave: true }); + UpdateConfig.setConfig("mode", "time", { nosave: true, tribeOverride: true }); + UpdateConfig.setConfig("time", raceDurationSeconds, { + nosave: true, + tribeOverride: true, + }); + UpdateConfig.setConfig("language", "english", { + nosave: true, + tribeOverride: true, + }); + UpdateConfig.setConfig("numbers", false, { + nosave: true, + tribeOverride: true, + }); + UpdateConfig.setConfig("punctuation", false, { + nosave: true, + tribeOverride: true, + }); // Set waiting page message via DuelState — the waiting page's afterShow reads it DuelState.setWaitingPageMessage("Get ready..."); @@ -833,6 +906,7 @@ export function onRaceScheduled( * Navigate to test page and start countdown timer. */ function navigateToTestAndStartCountdown(startAt: number): void { + if (DuelState.getFlowState() !== "RACING") return; console.log("[DuelFlow] Navigating to test page..."); NavigationEvent.dispatch("/", { @@ -881,6 +955,7 @@ function waitForWordsAndStartCountdown(startAt: number, attempt: number): void { const maxAttempts = 10; setTimeout(() => { + if (DuelState.getFlowState() !== "RACING") return; const wordsEl = document.getElementById("words"); if (wordsEl || attempt >= maxAttempts) { if (!wordsEl) { @@ -900,6 +975,7 @@ function waitForWordsAndStartCountdown(startAt: number, attempt: number): void { * Start the race countdown timer. */ function startRaceCountdown(startAt: number): void { + if (DuelState.getFlowState() !== "RACING") return; import("../tribe-countdown") .then((TribeCountdown) => { TribeCountdown.show2(); @@ -929,12 +1005,13 @@ function startRaceCountdown(startAt: number): void { let lastPlayedSecond = remaining; // Update countdown every 100ms for smooth display - const countdownInterval = setInterval(() => { + raceCountdownInterval = setInterval(() => { const currentTime = DuelTimeSync.getServerNow(); remaining = Math.ceil((startAt - currentTime) / 1000); if (remaining <= 0) { - clearInterval(countdownInterval); + clearInterval(raceCountdownInterval); + raceCountdownInterval = undefined; TribeCountdown.hide2(); TribeSound.play("cd_go"); console.log("[DuelFlow] Starting race now!"); @@ -961,6 +1038,7 @@ function startRaceCountdown(startAt: number): void { * This is called AFTER the countdown reaches 0. */ function startDuelRace(): void { + if (DuelState.getFlowState() !== "RACING") return; // NOW enable typing — set room state to RACE_ONGOING and isTyping = true void import("../tribe-state").then((TribeState) => { const room = TribeState.getRoom(); @@ -1006,8 +1084,16 @@ export function onDuelRaceComplete(): void { hideDuelBanner(); TribeCarets.destroyAll(); + // Now that state is RESULTS (shouldBlockUI = false), populate opponent results + // and draw charts that were skipped during RACING state + TribeResults.update("result"); + void TribeChartController.drawAllCharts().then(() => { + void TribeChartController.updateChartMaxValues(); + }); + // Show auto-advance button below results after a short delay - setTimeout(() => { + showAutoAdvanceTimeout = setTimeout(() => { + showAutoAdvanceTimeout = undefined; showAutoAdvanceButton(); }, 500); } @@ -1087,6 +1173,10 @@ function showAutoAdvanceButton(): void { * Hide and clean up the auto-advance button. */ function hideAutoAdvanceButton(): void { + if (showAutoAdvanceTimeout) { + clearTimeout(showAutoAdvanceTimeout); + showAutoAdvanceTimeout = undefined; + } if (autoAdvanceInterval) { clearInterval(autoAdvanceInterval); autoAdvanceInterval = undefined; @@ -1294,6 +1384,14 @@ function cleanupTransientTimers(): void { clearTimeout(showLobbyTimeout); showLobbyTimeout = undefined; } + if (raceCountdownInterval) { + clearInterval(raceCountdownInterval); + raceCountdownInterval = undefined; + } + if (showAutoAdvanceTimeout) { + clearTimeout(showAutoAdvanceTimeout); + showAutoAdvanceTimeout = undefined; + } } async function registerCurrentSide( @@ -1307,7 +1405,11 @@ async function registerCurrentSide( } if (performTimeSync) { - await DuelTimeSync.sync(); + try { + await DuelTimeSync.sync(); + } catch (error) { + console.warn("[DuelFlow] Time sync failed, using local clock:", error); + } } return result; diff --git a/frontend/src/ts/tribe/duel/duel-state.ts b/frontend/src/ts/tribe/duel/duel-state.ts index d5b528264850..cb10e04a91e5 100644 --- a/frontend/src/ts/tribe/duel/duel-state.ts +++ b/frontend/src/ts/tribe/duel/duel-state.ts @@ -9,11 +9,9 @@ export type DuelFlowState = | "OTP" // Entering OTP | "EULA" // Showing competition rules/terms | "PRACTICE_1" // First practice (60 sec) - | "RESULT_1" // Showing result - | "COUNTDOWN_1" // Countdown before practice 2 + | "RESULT_1" // Showing result + countdown to practice 2 | "PRACTICE_2" // Second practice (30 sec) - | "RESULT_2" // Showing result - | "COUNTDOWN_2" // Countdown before lobby + | "RESULT_2" // Showing result + countdown to lobby | "LOBBY" // Waiting in lobby | "RACING" // Active duel | "RESULTS"; // Viewing final results @@ -25,14 +23,12 @@ const ALLOWED_TRANSITIONS: Record = { OTP: ["EULA"], EULA: ["PRACTICE_1"], PRACTICE_1: ["RESULT_1"], - RESULT_1: ["COUNTDOWN_1", "PRACTICE_2"], - COUNTDOWN_1: ["PRACTICE_2"], + RESULT_1: ["PRACTICE_2"], PRACTICE_2: ["RESULT_2"], - RESULT_2: ["COUNTDOWN_2", "LOBBY"], - COUNTDOWN_2: ["LOBBY"], + RESULT_2: ["LOBBY"], LOBBY: ["RACING"], RACING: ["RESULTS", "LOBBY"], - RESULTS: [], + RESULTS: ["LOBBY"], }; // --- State --- @@ -261,10 +257,6 @@ export function isPracticing(): boolean { return flowState === "PRACTICE_1" || flowState === "PRACTICE_2"; } -export function isInCountdown(): boolean { - return flowState === "COUNTDOWN_1" || flowState === "COUNTDOWN_2"; -} - export function isShowingResult(): boolean { return flowState === "RESULT_1" || flowState === "RESULT_2"; } @@ -274,15 +266,11 @@ export function isRacing(): boolean { } export function shouldBlockUI(): boolean { - // Block UI (retry buttons, etc.) during practice, result viewing, countdowns, and racing - // This blocks manual restarts/retries but doesn't prevent the test from being initialized - return isPracticing() || isShowingResult() || isInCountdown() || isRacing(); + return isPracticing() || isShowingResult() || isRacing(); } export function shouldBlockRestart(): boolean { - // Block restarts during practice, result viewing, countdowns, and racing - // But allow the initial test initialization with tribeOverride - return isPracticing() || isShowingResult() || isInCountdown() || isRacing(); + return isPracticing() || isShowingResult() || isRacing(); } export function shouldBlockNavigation(): boolean { diff --git a/frontend/src/ts/tribe/duel/duel-time-sync.ts b/frontend/src/ts/tribe/duel/duel-time-sync.ts index 17e2ce157837..b3606e7094cc 100644 --- a/frontend/src/ts/tribe/duel/duel-time-sync.ts +++ b/frontend/src/ts/tribe/duel/duel-time-sync.ts @@ -5,6 +5,8 @@ import Socket from "../tribe-socket/socket"; let serverOffset = 0; // serverTime - clientTime let synced = false; +const TIME_SYNC_ACK_TIMEOUT_MS = 3000; +const TIME_SYNC_SAMPLE_COUNT = 3; /** * Perform time sync with server. @@ -13,20 +15,32 @@ let synced = false; export async function sync(): Promise { const samples: number[] = []; - // Take 3 samples for median - for (let i = 0; i < 3; i++) { + // Take N samples for median + for (let i = 0; i < TIME_SYNC_SAMPLE_COUNT; i++) { const offset = await singleSync(); - samples.push(offset); + if (offset !== null) { + samples.push(offset); + } // Small delay between samples - if (i < 2) { + if (i < TIME_SYNC_SAMPLE_COUNT - 1) { await new Promise((r) => setTimeout(r, 50)); } } + if (samples.length === 0) { + synced = false; + serverOffset = 0; + console.warn( + "[DuelTimeSync] Sync failed (no valid samples), using local clock", + ); + return; + } + // Use median samples.sort((a, b) => a - b); - serverOffset = samples[1] ?? 0; + const medianIndex = Math.floor(samples.length / 2); + serverOffset = samples[medianIndex] ?? 0; synced = true; console.log( @@ -38,23 +52,56 @@ export async function sync(): Promise { * Perform a single time sync sample. * Returns the calculated offset. */ -async function singleSync(): Promise { - return new Promise((resolve) => { - const clientSend = Date.now(); - - Socket.emit( - "duel_time_sync", - { clientTime: clientSend }, - (response: { clientTime: number; serverTime: number }) => { - const clientReceive = Date.now(); - const roundTrip = clientReceive - clientSend; - const estimatedServerTime = response.serverTime + roundTrip / 2; - const offset = estimatedServerTime - clientReceive; - - resolve(offset); - }, - ); +async function singleSync(): Promise { + const clientSend = Date.now(); + let timeoutId: ReturnType | undefined; + + const ackPromise = new Promise<{ clientTime: number; serverTime: number }>( + (resolve) => { + Socket.emit( + "duel_time_sync", + { clientTime: clientSend }, + (response: { clientTime: number; serverTime: number }) => { + resolve(response); + }, + ); + }, + ); + + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + resolve(null); + }, TIME_SYNC_ACK_TIMEOUT_MS); }); + + const response = await Promise.race< + [typeof ackPromise, typeof timeoutPromise][number] + >([ackPromise, timeoutPromise]); + + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (response === null) { + console.warn( + `[DuelTimeSync] duel_time_sync ACK timeout after ${TIME_SYNC_ACK_TIMEOUT_MS}ms`, + ); + return null; + } + + if ( + typeof response.serverTime !== "number" || + !Number.isFinite(response.serverTime) + ) { + return null; + } + + const clientReceive = Date.now(); + const roundTrip = clientReceive - clientSend; + const estimatedServerTime = response.serverTime + roundTrip / 2; + const offset = estimatedServerTime - clientReceive; + + return offset; } /** diff --git a/frontend/src/ts/tribe/tribe-config-check.ts b/frontend/src/ts/tribe/tribe-config-check.ts index 67a9471027db..1a1fa085064e 100644 --- a/frontend/src/ts/tribe/tribe-config-check.ts +++ b/frontend/src/ts/tribe/tribe-config-check.ts @@ -1,6 +1,13 @@ import { getRoom, getSelf } from "./tribe-state"; +import * as DuelState from "./duel/duel-state"; export function canChangeConfig(override: boolean): boolean { + // During duel flow, config must remain locked unless the duel controller + // explicitly overrides it for deterministic practice/race setup. + if (DuelState.getFlowState() !== "SYSTEM_SELECT") { + return override; + } + const room = getRoom(); if (room === undefined) return true; diff --git a/frontend/src/ts/tribe/tribe-results.ts b/frontend/src/ts/tribe/tribe-results.ts index c19597b613aa..4a06dc927473 100644 --- a/frontend/src/ts/tribe/tribe-results.ts +++ b/frontend/src/ts/tribe/tribe-results.ts @@ -182,6 +182,9 @@ export function updatePositions( } } for (const id of Object.keys(elements)) { + (elements[id] as JQuery).addClass("dnf"); + (elements[id] as JQuery).find(".pos").text("DNF"); + (elements[id] as JQuery).find(".points").text(""); el.append(elements[id] as JQuery); } } diff --git a/frontend/src/ts/tribe/tribe-socket/routes/duel.ts b/frontend/src/ts/tribe/tribe-socket/routes/duel.ts index 67eb0ec3a6a5..99c9f5aee751 100644 --- a/frontend/src/ts/tribe/tribe-socket/routes/duel.ts +++ b/frontend/src/ts/tribe/tribe-socket/routes/duel.ts @@ -18,62 +18,89 @@ export type TimeSyncResponse = { serverTime: number; }; +const DUEL_ACK_TIMEOUT_MS = 5000; +const ACK_TIMEOUT_ERROR = "Request timed out. Check connection and retry."; + +async function emitWithAck( + event: string, + fallbackResponse: T, + data?: unknown, +): Promise { + let timeoutId: ReturnType | undefined; + + const ackPromise = new Promise((resolve) => { + const ack = (response: T): void => { + resolve(response); + }; + + if (data === undefined) { + Socket.emit(event as never, ack as never); + } else { + Socket.emit(event as never, data as never, ack as never); + } + }); + + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + console.warn( + `[DuelSocket] ${event} ACK timeout after ${DUEL_ACK_TIMEOUT_MS}ms`, + ); + resolve(fallbackResponse); + }, DUEL_ACK_TIMEOUT_MS); + }); + + const response = await Promise.race([ackPromise, timeoutPromise]); + if (timeoutId) { + clearTimeout(timeoutId); + } + return response; +} + // --- OUT (Client -> Server) --- async function registerSystem(side: DuelSide): Promise { - return new Promise((resolve) => { - Socket.emit( - "duel_register_system", - { side }, - (response: DuelAckResponse) => { - resolve(response); - }, - ); - }); + return emitWithAck( + "duel_register_system", + { ok: false, error: ACK_TIMEOUT_ERROR }, + { side }, + ); } async function authenticate(otp: string): Promise { - return new Promise((resolve) => { - Socket.emit("duel_authenticate", { otp }, (response: DuelAckResponse) => { - resolve(response); - }); - }); + return emitWithAck( + "duel_authenticate", + { ok: false, error: ACK_TIMEOUT_ERROR }, + { otp }, + ); } async function practiceComplete(): Promise { - return new Promise((resolve) => { - Socket.emit("duel_practice_complete", (response: DuelAckResponse) => { - resolve(response); - }); + return emitWithAck("duel_practice_complete", { + ok: false, + error: ACK_TIMEOUT_ERROR, }); } async function joinLobby(): Promise { - return new Promise((resolve) => { - Socket.emit("duel_join_lobby", (response: DuelAckResponse) => { - resolve(response); - }); + return emitWithAck("duel_join_lobby", { + ok: false, + error: ACK_TIMEOUT_ERROR, }); } async function resetSession(): Promise { - return new Promise((resolve) => { - Socket.emit("duel_reset_session", (response: DuelAckResponse) => { - resolve(response); - }); + return emitWithAck("duel_reset_session", { + ok: false, + error: ACK_TIMEOUT_ERROR, }); } async function timeSync(clientTime: number): Promise { - return new Promise((resolve) => { - Socket.emit( - "duel_time_sync", - { clientTime }, - (response: TimeSyncResponse) => { - resolve(response); - }, - ); - }); + return emitWithAck( + "duel_time_sync", + { clientTime, serverTime: Date.now() }, + { clientTime }, + ); } // --- IN (Server -> Client) --- diff --git a/frontend/src/ts/tribe/tribe-socket/socket.ts b/frontend/src/ts/tribe/tribe-socket/socket.ts index 63c2a1526f50..3f800ab739de 100644 --- a/frontend/src/ts/tribe/tribe-socket/socket.ts +++ b/frontend/src/ts/tribe/tribe-socket/socket.ts @@ -1,29 +1,5 @@ import { io } from "socket.io-client"; - -// Determine tribes server URL based on environment -function getTribesServerUrl(): string { - const hostname = window.location.hostname; - - // Production - if ( - hostname === "monkeytype.rbh.makerspace.tools" || - hostname === "www.monkeytype.rbh.makerspace.tools" - ) { - return "https://tribe.monkeytype.rbh.makerspace.tools"; - } - - // Dev/Test deployment - if ( - hostname === "monkeytype-test.rbh.makerspace.tools" || - hostname === "www.monkeytype-test.rbh.makerspace.tools" - ) { - return "https://tribe.monkeytype-test.rbh.makerspace.tools"; - } - - // Local development - use same host as frontend but on port 3005 - // This works for localhost, 127.0.0.1, and LAN IPs (e.g., 192.168.x.x) - return `http://${hostname}:3005`; -} +import { getTribesServerUrl } from "../../utils/tribe"; console.log(`Looking for Tribes Server at: ${getTribesServerUrl()}`); diff --git a/frontend/src/ts/tribe/tribe.ts b/frontend/src/ts/tribe/tribe.ts index 09175f62f4c3..52c786ba9bf6 100644 --- a/frontend/src/ts/tribe/tribe.ts +++ b/frontend/src/ts/tribe/tribe.ts @@ -45,10 +45,6 @@ export const expectedVersion = isDevEnvironment() ? "dev" : "25.12.4"; function updateClientState(state: TribeTypes.ClientState): void { TribeState.setState(state); - - $("#tribeStateDisplay").text( - `${TribeState.getState()} - ${TribeState.getRoom()?.state}`, - ); } function updateRoomState(state: TribeTypes.RoomState): void { @@ -59,10 +55,6 @@ function updateRoomState(state: TribeTypes.RoomState): void { return; } - $("#tribeStateDisplay").text( - `${TribeState.getState()} - ${TribeState.getRoom()?.state}`, - ); - if (state === TribeTypes.ROOM_STATE.LOBBY) { TribePageLobby.enableNameVisibilityButtons(); TribeBars.hide("tribe"); diff --git a/frontend/src/ts/utils/tribe.ts b/frontend/src/ts/utils/tribe.ts index e0c5956dc152..0427b757e201 100644 --- a/frontend/src/ts/utils/tribe.ts +++ b/frontend/src/ts/utils/tribe.ts @@ -1,6 +1,29 @@ import { envConfig } from "virtual:env-config"; import { configurationPromise, get } from "../ape/server-configuration"; +/** + * Resolve the Tribes Server base URL for socket + HTTP duel endpoints. + */ +export function getTribesServerUrl(): string { + const hostname = window.location.hostname; + + if ( + hostname === "monkeytype.rbh.makerspace.tools" || + hostname === "www.monkeytype.rbh.makerspace.tools" + ) { + return "https://tribe.monkeytype.rbh.makerspace.tools"; + } + + if ( + hostname === "monkeytype-test.rbh.makerspace.tools" || + hostname === "www.monkeytype-test.rbh.makerspace.tools" + ) { + return "https://tribe.monkeytype-test.rbh.makerspace.tools"; + } + + return `http://${hostname}:3005`; +} + export function getTribeMode(): "disabled" | "enabled" | "enabled_stealth" { if (envConfig.forceTribe) return "enabled"; return get()?.tribe?.mode ?? "disabled"; diff --git a/tribes-server/duel-otp.json b/tribes-server/duel-otp.json index 6e98e6eeb5a7..5a2cc250a4df 100644 --- a/tribes-server/duel-otp.json +++ b/tribes-server/duel-otp.json @@ -1,8 +1,68 @@ { - "000000": "ayush", + "101010": "Test 1", "111111": "sai", + "126225": "Abaan Ali Ansari", + "202020": "Test 2", + "216739": "Roshni Agarwal", "222222": "sanchak", + "246316": "Kumari Anushka", "333333": "abhinav", + "334053": "Vaibhav Shokeen", + "356787": "Kushal B R", + "388389": "Anirvaan Kar", "444444": "ojas", - "555555": "kanishk" + "514449": "Kunal Jain", + "514450": "Dolar Jay Subhashbhai", + "514451": "Mohd Zahaib Eqbal", + "514454": "Dibakar Bala", + "514455": "Sonigra Bhavya Hemendrabhai", + "514456": "Chhelu Singh Lal singh rathore", + "514457": "Aarushi Chawla", + "514458": "Madhuri Narayan Merugu", + "514459": "Keshav Sadabrij Saroj", + "514460": "Abhishek Kumar Chauhan", + "514462": "Arunachalam Vijayanand", + "514463": "Prince Kumar", + "514464": "Moksh sib", + "514465": "Mukund Saraf", + "514466": "Alina Siddiqui", + "535004": "Sher Partap Singh", + "535006": "Rajit Ghatak", + "535011": "Uransh", + "537773": "Shubhi", + "537779": "Akshita Sharma", + "537781": "Ankush Jha", + "537783": "Jahnavi Devadas Pamu", + "537787": "Rudraksh Bairagi", + "555555": "kanishk", + "556796": "Palak Yadav", + "561421": "Hrijul Chauhan", + "584727": "Madhav Pratap Singh", + "584728": "Apurv Bhushan", + "584729": "Jaanya Goyal", + "584731": "Anish Adamane", + "584732": "Amartya Singh", + "584734": "Siddhayak Goyal", + "584735": "Harshit Gautam", + "584736": "Affan Danish", + "584737": "Aman Kr Jaiswal", + "584738": "Raaghav Saboo", + "584739": "Arhaan Bahadur", + "584740": "Unnati Asthana", + "584741": "Sagar khemji patidar", + "584742": "Arnav Praneet", + "584743": "Mohit Chauhan", + "584744": "Dhruv Kunzru", + "584745": "Rohit Rajagangadhar Adepu", + "584746": "Anurag Singh", + "594961": "Divyam Jindal", + "594965": "Haridas Mahato", + "594971": "Kanav Meena", + "594972": "Karan Singh", + "770487": "Mihir Aggarwal", + "877572": "Saumya Mishra", + "888888": "Ansh", + "888889": "Kaustubh", + "999999": "Kailash", + "000000": "ayush" } diff --git a/tribes-server/duel-results.json b/tribes-server/duel-results.json new file mode 100644 index 000000000000..a4ba320f1565 --- /dev/null +++ b/tribes-server/duel-results.json @@ -0,0 +1,501 @@ +{ + "results": [], + "leaderboard": { + "111111": { + "name": "sai", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "126225": { + "name": "Abaan Ali Ansari", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "216739": { + "name": "Roshni Agarwal", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "222222": { + "name": "sanchak", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "246316": { + "name": "Kumari Anushka", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "333333": { + "name": "abhinav", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "334053": { + "name": "Vaibhav Shokeen", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "356787": { + "name": "Kushal B R", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "388389": { + "name": "Anirvaan Kar", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "444444": { + "name": "ojas", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514449": { + "name": "Kunal Jain", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514450": { + "name": "Dolar Jay Subhashbhai", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514451": { + "name": "Mohd Zahaib Eqbal", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514454": { + "name": "Dibakar Bala", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514455": { + "name": "Sonigra Bhavya Hemendrabhai", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514456": { + "name": "Chhelu Singh Lal singh rathore", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514457": { + "name": "Aarushi Chawla", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514458": { + "name": "Madhuri Narayan Merugu", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514459": { + "name": "Keshav Sadabrij Saroj", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514460": { + "name": "Abhishek Kumar Chauhan", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514462": { + "name": "Arunachalam Vijayanand", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514463": { + "name": "Prince Kumar", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514464": { + "name": "Moksh sib", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514465": { + "name": "Mukund Saraf", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "514466": { + "name": "Alina Siddiqui", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "535004": { + "name": "Sher Partap Singh", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "535006": { + "name": "Rajit Ghatak", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "535011": { + "name": "Uransh", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "537773": { + "name": "Shubhi", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "537779": { + "name": "Akshita Sharma", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "537781": { + "name": "Ankush Jha", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "537783": { + "name": "Jahnavi Devadas Pamu", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "537787": { + "name": "Rudraksh Bairagi", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "555555": { + "name": "kanishk", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "556796": { + "name": "Palak Yadav", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "561421": { + "name": "Hrijul Chauhan", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584727": { + "name": "Madhav Pratap Singh", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584728": { + "name": "Apurv Bhushan", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584729": { + "name": "Jaanya Goyal", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584731": { + "name": "Anish Adamane", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584732": { + "name": "Amartya Singh", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584734": { + "name": "Siddhayak Goyal", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584735": { + "name": "Harshit Gautam", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584736": { + "name": "Affan Danish", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584737": { + "name": "Aman Kr Jaiswal", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584738": { + "name": "Raaghav Saboo", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584739": { + "name": "Arhaan Bahadur", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584740": { + "name": "Unnati Asthana", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584741": { + "name": "Sagar khemji patidar", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584742": { + "name": "Arnav Praneet", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584743": { + "name": "Mohit Chauhan", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584744": { + "name": "Dhruv Kunzru", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584745": { + "name": "Rohit Rajagangadhar Adepu", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "584746": { + "name": "Anurag Singh", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "594961": { + "name": "Divyam Jindal", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "594965": { + "name": "Haridas Mahato", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "594971": { + "name": "Kanav Meena", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "594972": { + "name": "Karan Singh", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "770487": { + "name": "Mihir Aggarwal", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "877572": { + "name": "Saumya Mishra", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "999999": { + "name": "Kailash", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + }, + "000000": { + "name": "ayush", + "wpm": -1, + "raw": -1, + "acc": -1, + "consistency": -1, + "date": 0 + } + } +} diff --git a/tribes-server/src/app.ts b/tribes-server/src/app.ts index 6b44896e7265..ec7b98aced0a 100644 --- a/tribes-server/src/app.ts +++ b/tribes-server/src/app.ts @@ -8,10 +8,21 @@ import { registerUserHandlers } from "./controllers/user-controller.js"; import { registerDevHandlers } from "./controllers/dev-controller.js"; import { registerDuelHandlers } from "./controllers/duel-controller.js"; import { startMatchmaking } from "./services/matchmaking-service.js"; -import { loadOtpMap } from "./utils/duel-otp.js"; +import { + loadOtpMap, + getOtpMap, + setOtp, + removeOtp, + setOtpMap, +} from "./utils/duel-otp.js"; import { duelStore } from "./stores/duel-store.js"; import { DUEL_CONFIG } from "./config.js"; import * as duelService from "./services/duel-service.js"; +import { + buildDuelSpectatorState, + emitDuelLeaderboardSnapshot, + emitDuelSpectatorState, +} from "./services/duel-spectator-service.js"; import type { ClientToServerEvents, ServerToClientEvents, @@ -30,7 +41,7 @@ const isDevMode = mode === "dev" || mode === "development"; const corsOptions = isDevMode ? { origin: true, // Allow all origins in dev mode - methods: ["GET", "POST"], + methods: ["GET", "POST", "PUT", "DELETE"], credentials: true, } : { @@ -40,7 +51,7 @@ const corsOptions = isDevMode "https://monkeytype-test.rbh.makerspace.tools", "https://monkeytype.rbh.makerspace.tools", ], - methods: ["GET", "POST"], + methods: ["GET", "POST", "PUT", "DELETE"], credentials: true, }; @@ -78,6 +89,207 @@ app.get("/duel/results", (_req, res): void => { res.json(duelStore.getResults()); }); +app.get("/duel/leaderboard", (_req, res): void => { + if (!DUEL_CONFIG.ENABLED) { + res.status(404).json({ error: "Duel mode disabled" }); + return; + } + res.json(duelStore.getLeaderboard()); +}); + +app.get("/duel/spectator", (_req, res): void => { + if (!DUEL_CONFIG.ENABLED) { + res.status(404).json({ error: "Duel mode disabled" }); + return; + } + res.json(buildDuelSpectatorState(io)); +}); + +// --- Admin endpoints --- + +// GET /duel/admin/users — list all OTP users +app.get("/duel/admin/users", (_req, res): void => { + if (!DUEL_CONFIG.ENABLED) { + res.status(404).json({ error: "Duel mode disabled" }); + return; + } + res.json(getOtpMap()); +}); + +// POST /duel/admin/users — add/update users +// Body: { "123456": "alice", "654321": "bob" } +// Merges into existing map. To replace entirely, use PUT. +app.post("/duel/admin/users", (req, res): void => { + if (!DUEL_CONFIG.ENABLED) { + res.status(404).json({ error: "Duel mode disabled" }); + return; + } + + const body = req.body as Record; + if (!body || typeof body !== "object") { + res.status(400).json({ error: "Body must be an object of otp: username" }); + return; + } + + let count = 0; + for (const [otp, username] of Object.entries(body)) { + if (typeof username !== "string" || username.length === 0) { + res.status(400).json({ error: `Invalid username for OTP "${otp}"` }); + return; + } + setOtp(otp, username); + count++; + } + + // Seed leaderboard for new users + duelStore.seedLeaderboardFromOtpMap(getOtpMap()); + + res.json({ ok: true, added: count, total: Object.keys(getOtpMap()).length }); +}); + +// PUT /duel/admin/users — replace entire OTP map +app.put("/duel/admin/users", (req, res): void => { + if (!DUEL_CONFIG.ENABLED) { + res.status(404).json({ error: "Duel mode disabled" }); + return; + } + + const body = req.body as Record; + if (!body || typeof body !== "object") { + res.status(400).json({ error: "Body must be an object of otp: username" }); + return; + } + + const newMap: Record = {}; + for (const [otp, username] of Object.entries(body)) { + if (typeof username !== "string" || username.length === 0) { + res.status(400).json({ error: `Invalid username for OTP "${otp}"` }); + return; + } + newMap[otp] = username; + } + + setOtpMap(newMap); + duelStore.seedLeaderboardFromOtpMap(getOtpMap()); + + res.json({ ok: true, total: Object.keys(newMap).length }); +}); + +// DELETE /duel/admin/users/:otp — remove a single user +app.delete("/duel/admin/users/:otp", (req, res): void => { + if (!DUEL_CONFIG.ENABLED) { + res.status(404).json({ error: "Duel mode disabled" }); + return; + } + + const removed = removeOtp(req.params["otp"] ?? ""); + if (!removed) { + res.status(404).json({ error: "OTP not found" }); + return; + } + res.json({ ok: true, total: Object.keys(getOtpMap()).length }); +}); + +// GET /duel/admin/config — view mutable config +app.get("/duel/admin/config", (_req, res): void => { + if (!DUEL_CONFIG.ENABLED) { + res.status(404).json({ error: "Duel mode disabled" }); + return; + } + res.json({ + PRACTICE_COUNT: DUEL_CONFIG.PRACTICE_COUNT, + RACE_DURATION_SECONDS: DUEL_CONFIG.RACE_DURATION_SECONDS, + START_DELAY_MS: DUEL_CONFIG.START_DELAY_MS, + }); +}); + +// POST /duel/admin/config — update mutable config fields +// Body: { "RACE_DURATION_SECONDS": 60, "PRACTICE_COUNT": 1 } +app.post("/duel/admin/config", (req, res): void => { + if (!DUEL_CONFIG.ENABLED) { + res.status(404).json({ error: "Duel mode disabled" }); + return; + } + + const body = req.body as Record; + const updated: string[] = []; + + if (typeof body["PRACTICE_COUNT"] === "number") { + (DUEL_CONFIG as { PRACTICE_COUNT: number }).PRACTICE_COUNT = + body["PRACTICE_COUNT"]; + updated.push("PRACTICE_COUNT"); + } + if (typeof body["RACE_DURATION_SECONDS"] === "number") { + (DUEL_CONFIG as { RACE_DURATION_SECONDS: number }).RACE_DURATION_SECONDS = + body["RACE_DURATION_SECONDS"]; + updated.push("RACE_DURATION_SECONDS"); + } + if (typeof body["START_DELAY_MS"] === "number") { + (DUEL_CONFIG as { START_DELAY_MS: number }).START_DELAY_MS = + body["START_DELAY_MS"]; + updated.push("START_DELAY_MS"); + } + + if (updated.length === 0) { + res.status(400).json({ + error: + "No valid fields. Use: PRACTICE_COUNT, RACE_DURATION_SECONDS, START_DELAY_MS", + }); + return; + } + + Logger.info(`Config updated: ${updated.join(", ")}`); + res.json({ + ok: true, + updated, + config: { + PRACTICE_COUNT: DUEL_CONFIG.PRACTICE_COUNT, + RACE_DURATION_SECONDS: DUEL_CONFIG.RACE_DURATION_SECONDS, + START_DELAY_MS: DUEL_CONFIG.START_DELAY_MS, + }, + }); +}); + +// POST /duel/admin/leaderboard — set leaderboard entries +// Body: { "123456": { "name": "alice", "wpm": 120, "acc": 98, "raw": 125, "consistency": 85 } } +app.post("/duel/admin/leaderboard", (req, res): void => { + if (!DUEL_CONFIG.ENABLED) { + res.status(404).json({ error: "Duel mode disabled" }); + return; + } + + const body = req.body as Record; + if (!body || typeof body !== "object") { + res.status(400).json({ error: "Body must be an object of userId: entry" }); + return; + } + + let count = 0; + for (const [userId, raw] of Object.entries(body)) { + if (!raw || typeof raw !== "object") { + res.status(400).json({ error: `Invalid entry for "${userId}"` }); + return; + } + const entry = raw as Record; + if (typeof entry["name"] !== "string") { + res.status(400).json({ error: `Missing name for "${userId}"` }); + return; + } + duelStore.setLeaderboardEntry(userId, { + name: entry["name"], + wpm: typeof entry["wpm"] === "number" ? entry["wpm"] : -1, + acc: typeof entry["acc"] === "number" ? entry["acc"] : -1, + raw: typeof entry["raw"] === "number" ? entry["raw"] : -1, + consistency: + typeof entry["consistency"] === "number" ? entry["consistency"] : -1, + date: typeof entry["date"] === "number" ? entry["date"] : Date.now(), + }); + count++; + } + + res.json({ ok: true, updated: count }); +}); + // Create Socket.IO server const io = new Server< ClientToServerEvents, @@ -93,6 +305,21 @@ const io = new Server< // Load OTP map and persisted results for duel mode at startup loadOtpMap(); duelStore.loadResults(); +duelStore.seedLeaderboardFromOtpMap(getOtpMap()); + +// Socket push loops for spectator clients (replaces per-client HTTP polling). +if (DUEL_CONFIG.ENABLED) { + const DUEL_STATE_PUSH_INTERVAL_MS = 250; + const DUEL_LEADERBOARD_PUSH_INTERVAL_MS = 1000; + + setInterval(() => { + emitDuelSpectatorState(io); + }, DUEL_STATE_PUSH_INTERVAL_MS); + + setInterval(() => { + emitDuelLeaderboardSnapshot(io); + }, DUEL_LEADERBOARD_PUSH_INTERVAL_MS); +} // Handle new socket connections io.on("connection", (socket) => { diff --git a/tribes-server/src/config.ts b/tribes-server/src/config.ts index 331ad16db66c..c7efb1a2853e 100644 --- a/tribes-server/src/config.ts +++ b/tribes-server/src/config.ts @@ -15,10 +15,10 @@ export const DUEL_CONFIG = { START_DELAY_MS: parseInt(process.env["DUEL_START_DELAY_MS"] ?? "10000", 10), // Number of practice runs required before joining lobby - PRACTICE_COUNT: 2, + PRACTICE_COUNT: 2 as number, // Race duration in seconds - RACE_DURATION_SECONDS: 30, + RACE_DURATION_SECONDS: 30 as number, // Fixed room configuration for duel races ROOM_CONFIG: { @@ -48,6 +48,6 @@ export const DUEL_CONFIG = { delimiter: " ", }, }, -} as const; +}; export type DuelSide = "L" | "R"; diff --git a/tribes-server/src/controllers/duel-controller.ts b/tribes-server/src/controllers/duel-controller.ts index 84e21677441a..2dbd991ce202 100644 --- a/tribes-server/src/controllers/duel-controller.ts +++ b/tribes-server/src/controllers/duel-controller.ts @@ -1,6 +1,12 @@ // Duel socket event handlers import type { Server, Socket } from "socket.io"; import * as duelService from "../services/duel-service.js"; +import { + DUEL_SPECTATORS_ROOM, + emitDuelLeaderboardSnapshot, + emitDuelSpectatorState, + getDuelSpectatorSnapshot, +} from "../services/duel-spectator-service.js"; import { DUEL_CONFIG } from "../config.js"; import Logger from "../utils/logger.js"; import type { @@ -86,6 +92,23 @@ export function registerDuelHandlers( }); }); + // ============================================================ + // Spectator subscription (socket push feed) + // ============================================================ + socket.on("duel_spectator_subscribe", (callback) => { + void socket.join(DUEL_SPECTATORS_ROOM); + callback({ + ok: true, + ...getDuelSpectatorSnapshot(io), + }); + emitDuelSpectatorState(io, true); + emitDuelLeaderboardSnapshot(io, true); + }); + + socket.on("duel_spectator_unsubscribe", () => { + void socket.leave(DUEL_SPECTATORS_ROOM); + }); + // ============================================================ // Handle disconnect - release side and cleanup // ============================================================ diff --git a/tribes-server/src/services/duel-service.ts b/tribes-server/src/services/duel-service.ts index 6601ad20a08b..a706a093246d 100644 --- a/tribes-server/src/services/duel-service.ts +++ b/tribes-server/src/services/duel-service.ts @@ -7,6 +7,7 @@ import type { Room, UserProgress } from "../types/room.js"; import { DUEL_CONFIG, type DuelSide } from "../config.js"; import { getDefaultRoomConfig } from "../types/config.js"; import { timerService, TimerType } from "./timer-service.js"; +import { transitionRoom } from "./race-service.js"; import Logger from "../utils/logger.js"; import type { ClientToServerEvents, @@ -45,6 +46,18 @@ const disconnectGracePeriods: Map< > = new Map(); const DISCONNECT_GRACE_MS = 10_000; // 10 seconds +// Pending duel race start timeout (cleared on disconnect to prevent 1-player races) +let duelStartTimeout: ReturnType | undefined; +type OtpAttemptState = { + attempts: number; + windowStart: number; + blockedUntil: number; +}; +const otpAttemptsBySide: Map = new Map(); +const OTP_ATTEMPT_WINDOW_MS = 60_000; +const OTP_MAX_ATTEMPTS_PER_WINDOW = 8; +const OTP_BLOCK_MS = 120_000; + function clearGracePeriodForSocket(socketId: string): void { const timer = disconnectGracePeriods.get(socketId); if (!timer) return; @@ -52,6 +65,164 @@ function clearGracePeriodForSocket(socketId: string): void { disconnectGracePeriods.delete(socketId); } +function clearPendingDuelStart(reason?: string): void { + if (!duelStartTimeout) return; + clearTimeout(duelStartTimeout); + duelStartTimeout = undefined; + if (reason) { + Logger.info(`Cleared pending duel race start ${reason}`); + } +} + +function clearOtpRateLimit(side: DuelSide): void { + otpAttemptsBySide.delete(side); +} + +function getOtpBlockRemainingMs(side: DuelSide, now: number): number { + const state = otpAttemptsBySide.get(side); + if (!state) return 0; + if (state.blockedUntil <= now) return 0; + return state.blockedUntil - now; +} + +function getOtpAttemptState(side: DuelSide, now: number): OtpAttemptState { + const existing = otpAttemptsBySide.get(side); + if (!existing) { + const initial: OtpAttemptState = { + attempts: 0, + windowStart: now, + blockedUntil: 0, + }; + otpAttemptsBySide.set(side, initial); + return initial; + } + + if ( + existing.blockedUntil <= now && + now - existing.windowStart >= OTP_ATTEMPT_WINDOW_MS + ) { + existing.attempts = 0; + existing.windowStart = now; + existing.blockedUntil = 0; + } + + return existing; +} + +function recordOtpFailure(side: DuelSide, now: number): number { + const state = getOtpAttemptState(side, now); + + if (state.blockedUntil > now) { + return state.blockedUntil - now; + } + + state.attempts += 1; + + if (state.attempts >= OTP_MAX_ATTEMPTS_PER_WINDOW) { + state.blockedUntil = now + OTP_BLOCK_MS; + state.windowStart = now; + state.attempts = 0; + return OTP_BLOCK_MS; + } + + otpAttemptsBySide.set(side, state); + return 0; +} + +function hasValidDuelPairInRoom(room: Room): boolean { + const left = duelStore.getParticipant("L"); + const right = duelStore.getParticipant("R"); + + if (!left || !right) return false; + if (!left.isAuthenticated || !right.isAuthenticated) return false; + if (left.practiceCount < DUEL_CONFIG.PRACTICE_COUNT) return false; + if (right.practiceCount < DUEL_CONFIG.PRACTICE_COUNT) return false; + + if (!room.users[left.socketId] || !room.users[right.socketId]) return false; + + return Object.keys(room.users).length === 2; +} + +function scheduleDuelRace(io: TribesServer, room: Room): boolean { + if (room.type !== "duel") return false; + if (room.state !== "LOBBY") return false; + if (!hasValidDuelPairInRoom(room)) return false; + if (duelStartTimeout) return false; + + const startAt = Date.now() + DUEL_CONFIG.START_DELAY_MS; + const seed = Math.floor(Math.random() * 1000000); + + Logger.info( + `Scheduling duel race in room ${room.id} at ${startAt} (in ${DUEL_CONFIG.START_DELAY_MS}ms), seed: ${seed}`, + ); + + // Clear stale WPM from previous race + duelStore.clearLiveWpm(); + + // Initialize the room for race: set seed, reset user states, transition to RACE_ONGOING + room.seed = seed; + room.duelResultRecorded = false; + room.maxRaw = 0; + room.maxWpm = 0; + room.minRaw = Infinity; + room.minWpm = Infinity; + room.startAt = startAt; + + Object.values(room.users).forEach((user) => { + user.isReady = false; + user.isFinished = false; + user.isTyping = true; + user.result = undefined; + user.progress = undefined; + }); + + // Emit to all players in the room (including the joiner) + io.to(room.id).emit("duel_race_scheduled", { + startAt, + seed, + raceDuration: DUEL_CONFIG.RACE_DURATION_SECONDS, + }); + + // Transition to RACE_ONGOING after the start delay so progress broadcasts work + const duelRoomId = room.id; + duelStartTimeout = setTimeout(() => { + duelStartTimeout = undefined; + const liveRoom = roomStore.getRoom(duelRoomId); + + if (liveRoom?.type !== "duel") { + Logger.warning(`Duel race aborted — room ${duelRoomId} no longer exists`); + return; + } + + if (liveRoom.state === "LOBBY" && hasValidDuelPairInRoom(liveRoom)) { + liveRoom.state = "RACE_ONGOING"; + io.to(duelRoomId).emit("room_state_changed", { + state: "RACE_ONGOING", + }); + + // Start progress broadcast interval for duel room + const PROGRESS_UPDATE_INTERVAL = 100; + timerService.start(duelRoomId, TimerType.PROGRESS, { + duration: Infinity, + interval: PROGRESS_UPDATE_INTERVAL, + onTick: () => { + broadcastDuelProgress(io, liveRoom); + }, + onComplete: (): void => { + // Progress updates run until manually stopped + }, + }); + } else { + liveRoom.startAt = undefined; + Logger.warning( + `Duel race aborted — invalid room state (${liveRoom.state}) or participants for ${duelRoomId}`, + ); + } + }, DUEL_CONFIG.START_DELAY_MS); + + return true; +} + function buildJoinLobbyResponse(room: Room): DuelAckResponse { return { ok: true, @@ -117,23 +288,25 @@ export function registerSystem( ); if (!existingSocket || existingSocket.disconnected) { // Dead socket detected — transfer side to new socket + const oldSocketId = existingParticipant.socketId; Logger.info( - `Dead socket ${existingParticipant.socketId} detected on side ${side}, transferring to ${socket.id}`, - ); - clearGracePeriodForSocket(existingParticipant.socketId); - const transferred = duelStore.transferSide( - existingParticipant.socketId, - socket.id, + `Dead socket ${oldSocketId} detected on side ${side}, transferring to ${socket.id}`, ); + clearGracePeriodForSocket(oldSocketId); + const transferred = duelStore.transferSide(oldSocketId, socket.id); if (transferred) { (socket.data as SocketData & { duelSide?: DuelSide }).duelSide = side; socket.data.name = transferred.username || socket.data.name; const roomTransfer = roomStore.transferUserSocket( - existingParticipant.socketId, + oldSocketId, socket.id, ); if (roomTransfer) { + // Notify peers the old socket ID is gone (prevents ghost entries) + socket + .to(roomTransfer.room.id) + .emit("room_player_left", { userId: oldSocketId }); socket.data.roomId = roomTransfer.room.id; void socket.join(roomTransfer.room.id); } @@ -151,31 +324,31 @@ export function registerSystem( } } else { // Check if in reconnect grace period - const gracePeriod = disconnectGracePeriods.get( - existingParticipant.socketId, - ); + const oldSocketId = existingParticipant.socketId; + const gracePeriod = disconnectGracePeriods.get(oldSocketId); if (gracePeriod) { // Cancel grace period timer and transfer clearTimeout(gracePeriod); - disconnectGracePeriods.delete(existingParticipant.socketId); + disconnectGracePeriods.delete(oldSocketId); Logger.info( - `Grace period active for ${existingParticipant.socketId} on side ${side}, transferring to ${socket.id}`, - ); - const transferred = duelStore.transferSide( - existingParticipant.socketId, - socket.id, + `Grace period active for ${oldSocketId} on side ${side}, transferring to ${socket.id}`, ); + const transferred = duelStore.transferSide(oldSocketId, socket.id); if (transferred) { (socket.data as SocketData & { duelSide?: DuelSide }).duelSide = side; socket.data.name = transferred.username || socket.data.name; const roomTransfer = roomStore.transferUserSocket( - existingParticipant.socketId, + oldSocketId, socket.id, ); if (roomTransfer) { + // Notify peers the old socket ID is gone (prevents ghost entries) + socket + .to(roomTransfer.room.id) + .emit("room_player_left", { userId: oldSocketId }); socket.data.roomId = roomTransfer.room.id; void socket.join(roomTransfer.room.id); } @@ -235,9 +408,10 @@ export function authenticate( }; } + const normalizedOtp = otp.trim(); const participant = duelStore.getParticipantBySocket(socket.id); if (participant?.isAuthenticated) { - if (participant.userId === otp) { + if (participant.userId === normalizedOtp) { return { ok: true, data: { @@ -255,12 +429,29 @@ export function authenticate( }; } - const validation = validateOtp(otp); + const now = Date.now(); + const blockRemainingMs = getOtpBlockRemainingMs(side, now); + if (blockRemainingMs > 0) { + return { + ok: false, + error: `Too many OTP attempts. Try again in ${Math.ceil(blockRemainingMs / 1000)}s.`, + }; + } + + const validation = validateOtp(normalizedOtp); if (!validation.valid || !validation.username) { + const blockedForMs = recordOtpFailure(side, now); + if (blockedForMs > 0) { + return { + ok: false, + error: `Too many OTP attempts. Try again in ${Math.ceil(blockedForMs / 1000)}s.`, + }; + } return { ok: false, error: "Invalid OTP" }; } - duelStore.authenticate(socket.id, otp, validation.username); + duelStore.authenticate(socket.id, normalizedOtp, validation.username); + clearOtpRateLimit(side); // Update socket name for room display socket.data.name = validation.username; @@ -271,7 +462,7 @@ export function authenticate( return { ok: true, data: { - userId: otp, + userId: normalizedOtp, username: validation.username, side, }, @@ -359,6 +550,7 @@ export function joinLobby( const socketRoom = roomStore.getRoom(socketRoomId); if (socketRoom && socketRoom.type === "duel") { socket.data.roomId = socketRoom.id; + scheduleDuelRace(io, socketRoom); return buildJoinLobbyResponse(socketRoom); } } @@ -408,9 +600,14 @@ export function joinLobby( if (existingRoom.users[socket.id]) { socket.data.roomId = existingRoom.id; + scheduleDuelRace(io, existingRoom); return buildJoinLobbyResponse(existingRoom); } + if (existingRoom.size >= 2) { + return { ok: false, error: "Duel room is full" }; + } + const result = roomStore.addUserToRoom( roomId, socket.id, @@ -440,61 +637,19 @@ export function joinLobby( Logger.info(`${participant.username} joined duel room ${roomId}`); - // Both players are now in the room - auto-start the duel! - // Schedule race to start after countdown delay - const startAt = Date.now() + DUEL_CONFIG.START_DELAY_MS; - const seed = Math.floor(Math.random() * 1000000); - - Logger.info( - `Scheduling duel race at ${startAt} (in ${DUEL_CONFIG.START_DELAY_MS}ms), seed: ${seed}`, - ); - - // Emit to all players in the room (including the joiner) - io.to(roomId).emit("duel_race_scheduled", { - startAt, - seed, - raceDuration: DUEL_CONFIG.RACE_DURATION_SECONDS, - }); + // Only auto-start if room is in LOBBY state (prevent re-trigger on reconnect) + if (existingRoom.state !== "LOBBY") { + return buildJoinLobbyResponse(result.room); + } - // Initialize the room for race: set seed, reset user states, transition to RACE_ONGOING - existingRoom.seed = seed; - existingRoom.maxRaw = 0; - existingRoom.maxWpm = 0; - existingRoom.minRaw = Infinity; - existingRoom.minWpm = Infinity; - existingRoom.startAt = startAt; - - Object.values(existingRoom.users).forEach((user) => { - user.isReady = false; - user.isFinished = false; - user.isTyping = true; - user.result = undefined; - user.progress = undefined; - }); + if (!hasValidDuelPairInRoom(existingRoom)) { + Logger.warning( + `Duel race not scheduled for room ${roomId} due to invalid side mapping`, + ); + return buildJoinLobbyResponse(result.room); + } - // Transition to RACE_ONGOING after the start delay so progress broadcasts work - const duelRoomId = roomId; // Capture for closure (guaranteed non-null in else branch) - setTimeout(() => { - if (existingRoom.state === "LOBBY") { - existingRoom.state = "RACE_ONGOING"; - io.to(duelRoomId).emit("room_state_changed", { - state: "RACE_ONGOING", - }); - - // Start progress broadcast interval for duel room - const PROGRESS_UPDATE_INTERVAL = 100; - timerService.start(duelRoomId, TimerType.PROGRESS, { - duration: Infinity, - interval: PROGRESS_UPDATE_INTERVAL, - onTick: () => { - broadcastDuelProgress(io, existingRoom); - }, - onComplete: (): void => { - // Progress updates run until manually stopped - }, - }); - } - }, DUEL_CONFIG.START_DELAY_MS); + scheduleDuelRace(io, existingRoom); return buildJoinLobbyResponse(result.room); } @@ -517,8 +672,15 @@ export function handleDisconnect(io: TribesServer, socket: TribesSocket): void { `Socket ${socket.id} (Side ${side}) disconnected — starting ${DISCONNECT_GRACE_MS}ms grace period`, ); + // Cancel any pending race start — prevents 1-player race after disconnect + clearPendingDuelStart("due to disconnect"); + // Notify opponent of temporary disconnect const roomId = duelStore.getActiveRoom(); + const room = roomId ? roomStore.getRoom(roomId) : undefined; + if (room?.type === "duel" && room.state === "LOBBY") { + room.startAt = undefined; + } if (roomId) { socket.to(roomId).emit("duel_opponent_left", { side }); } @@ -534,6 +696,7 @@ export function handleDisconnect(io: TribesServer, socket: TribesSocket): void { const releasedSide = duelStore.releaseSide(socket.id); if (!releasedSide) return; + clearOtpRateLimit(releasedSide); // Clean up room if (roomId) { @@ -548,6 +711,12 @@ export function handleDisconnect(io: TribesServer, socket: TribesSocket): void { duelStore.clearActiveRoom(); duelStore.resetForNextDuel(); Logger.info(`Duel room ${roomId} deleted, duel state reset`); + } else if (updatedRoom.state !== "LOBBY") { + // Room still has users but is stuck in a race state — reset to lobby + Logger.info( + `Resetting duel room ${roomId} to LOBBY after grace expiry (was ${updatedRoom.state})`, + ); + transitionRoom(io, roomId, "LOBBY"); } } } @@ -569,6 +738,8 @@ export function resetSession( return { ok: false, error: "Not registered to a side" }; } + clearPendingDuelStart("due to session reset"); + const success = duelStore.deauthenticate(socket.id); if (!success) { return { ok: false, error: "Failed to reset session" }; @@ -576,6 +747,10 @@ export function resetSession( socket.data.name = "Guest"; const roomId = roomStore.getRoomIdBySocketId(socket.id); + const room = roomId ? roomStore.getRoom(roomId) : undefined; + if (room?.type === "duel" && room.state === "LOBBY") { + room.startAt = undefined; + } if (roomId) { const result = roomStore.removeUserFromRoom(socket.id); diff --git a/tribes-server/src/services/duel-spectator-service.ts b/tribes-server/src/services/duel-spectator-service.ts new file mode 100644 index 000000000000..1fabb5ce572f --- /dev/null +++ b/tribes-server/src/services/duel-spectator-service.ts @@ -0,0 +1,126 @@ +import type { Server } from "socket.io"; +import { duelStore } from "../stores/duel-store.js"; +import { roomStore } from "../stores/room-store.js"; +import { DUEL_CONFIG } from "../config.js"; +import type { + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData, + DuelSpectatorPayload, +} from "../types/events.js"; + +type TribesServer = Server< + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents, + SocketData +>; + +export const DUEL_SPECTATORS_ROOM = "duel_spectators"; + +const DEFAULT_LEFT_NAME = "System Left"; +const DEFAULT_RIGHT_NAME = "System Right"; + +let lastStateSignature: string | undefined; +let lastLeaderboardSignature: string | undefined; + +function hasSpectators(io: TribesServer): boolean { + const room = io.sockets.adapter.rooms.get(DUEL_SPECTATORS_ROOM); + return room !== undefined && room.size > 0; +} + +export function buildDuelSpectatorState( + io: TribesServer, +): DuelSpectatorPayload { + const participants = duelStore.getParticipants(); + const liveWpm = duelStore.getLiveWpm(); + const activeRoomId = duelStore.getActiveRoom(); + const room = activeRoomId ? roomStore.getRoom(activeRoomId) : undefined; + + const raceStartAt = + room?.type === "duel" && room.startAt !== undefined ? room.startAt : null; + const duelRaceScheduled = room?.type === "duel" && raceStartAt !== null; + + const leftName = + participants.L?.username && participants.L.username.trim().length > 0 + ? participants.L.username + : DEFAULT_LEFT_NAME; + const rightName = + participants.R?.username && participants.R.username.trim().length > 0 + ? participants.R.username + : DEFAULT_RIGHT_NAME; + + return { + serverTime: Date.now(), + roomId: room?.id ?? null, + roomState: room?.state ?? null, + active: duelRaceScheduled, + race: { + startAt: raceStartAt, + duration: DUEL_CONFIG.RACE_DURATION_SECONDS, + }, + sides: { + L: participants.L + ? { + id: participants.L.userId, + name: leftName, + wpm: liveWpm.L?.wpm ?? 0, + connected: + io.sockets.sockets.get(participants.L.socketId)?.connected ?? + false, + } + : null, + R: participants.R + ? { + id: participants.R.userId, + name: rightName, + wpm: liveWpm.R?.wpm ?? 0, + connected: + io.sockets.sockets.get(participants.R.socketId)?.connected ?? + false, + } + : null, + }, + }; +} + +export function getDuelSpectatorSnapshot(io: TribesServer): { + state: DuelSpectatorPayload; + leaderboard: ReturnType; +} { + return { + state: buildDuelSpectatorState(io), + leaderboard: duelStore.getLeaderboard(), + }; +} + +export function emitDuelSpectatorState(io: TribesServer, force = false): void { + if (!force && !hasSpectators(io)) return; + + const payload = buildDuelSpectatorState(io); + const signature = JSON.stringify(payload); + if (!force && signature === lastStateSignature) return; + + lastStateSignature = signature; + io.to(DUEL_SPECTATORS_ROOM).emit("duel_spectator_state", payload); +} + +export function emitDuelLeaderboardSnapshot( + io: TribesServer, + force = false, +): void { + if (!force && !hasSpectators(io)) return; + + const leaderboard = duelStore.getLeaderboard(); + const signature = JSON.stringify(leaderboard); + if (!force && signature === lastLeaderboardSignature) return; + + lastLeaderboardSignature = signature; + io.to(DUEL_SPECTATORS_ROOM).emit("duel_leaderboard_snapshot", leaderboard); +} + +export function resetDuelSpectatorSignatures(): void { + lastStateSignature = undefined; + lastLeaderboardSignature = undefined; +} diff --git a/tribes-server/src/services/race-service.ts b/tribes-server/src/services/race-service.ts index 5cca71e85acf..a9f70de1e437 100644 --- a/tribes-server/src/services/race-service.ts +++ b/tribes-server/src/services/race-service.ts @@ -20,7 +20,6 @@ import type { SocketData, } from "../types/events.js"; import { generateSeed } from "../utils/id-generator.js"; -import { DUEL_CONFIG } from "../config.js"; import Logger from "../utils/logger.js"; type TribesServer = Server< @@ -240,6 +239,9 @@ function handleReadyToContinue(io: TribesServer, room: Room): void { function handleBackToLobby(io: TribesServer, room: Room): void { timerService.clearAllForRoom(room.id); + if (room.type === "duel") { + duelStore.clearLiveWpm(); + } roomStore.resetRoomForNextRace(room.id); io.to(room.id).emit("room_back_to_lobby"); } @@ -287,6 +289,10 @@ export function updateProgress( const room = roomStore.getRoomBySocketId(socketId); if (!room) return; + // Only accept progress during active race phases + if (room.state !== "RACE_ONGOING" && room.state !== "RACE_ONE_FINISHED") + return; + const user = room.users[socketId]; if (!user) return; @@ -319,6 +325,17 @@ export function submitResult( const user = room.users[socketId]; if (!user) return; + // State guard: only accept results during active race phases + const activeStates: RoomState[] = [ + "RACE_ONGOING", + "RACE_ONE_FINISHED", + "RACE_AWAITING_RESULTS", + ]; + if (!activeStates.includes(room.state)) return; + + // Idempotency guard: ignore duplicate submissions + if (user.isFinished) return; + user.result = result; user.isFinished = true; user.isTyping = false; @@ -338,40 +355,50 @@ export function submitResult( room.state !== "RACE_AWAITING_RESULTS" ) { transitionRoom(io, room.id, "RACE_AWAITING_RESULTS"); + } - // For duel rooms, record paired result when both finish - if (room.type === "duel") { - const L = duelStore.getParticipant("L"); - const R = duelStore.getParticipant("R"); - - if (L && R) { - const LUser = room.users[L.socketId]; - const RUser = room.users[R.socketId]; - - if (LUser?.result && RUser?.result) { - duelStore.addResult( - { - wpm: LUser.result.wpm, - raw: LUser.result.raw, - acc: LUser.result.acc, - consistency: LUser.result.consistency, - }, - { - wpm: RUser.result.wpm, - raw: RUser.result.raw, - acc: RUser.result.acc, - consistency: RUser.result.consistency, - }, - ); - Logger.info( - `Duel result recorded: L=${LUser.result.wpm}wpm, R=${RUser.result.wpm}wpm`, - ); - } - } - } + // For duel rooms, try to record result after every submission + if (room.type === "duel") { + tryRecordDuelResult(room); } } +function tryRecordDuelResult(room: Room): void { + if (room.duelResultRecorded) return; + + const L = duelStore.getParticipant("L"); + const R = duelStore.getParticipant("R"); + if (!L || !R) return; + if (!L.isAuthenticated || !R.isAuthenticated) return; + + const LUser = room.users[L.socketId]; + const RUser = room.users[R.socketId]; + if (!LUser?.result || !RUser?.result) return; + + room.duelResultRecorded = true; + duelStore.addResult( + { + userId: L.userId, + username: L.username || LUser.name, + wpm: LUser.result.wpm, + raw: LUser.result.raw, + acc: LUser.result.acc, + consistency: LUser.result.consistency, + }, + { + userId: R.userId, + username: R.username || RUser.name, + wpm: RUser.result.wpm, + raw: RUser.result.raw, + acc: RUser.result.acc, + consistency: RUser.result.consistency, + }, + ); + Logger.info( + `Duel result recorded: L=${LUser.result.wpm}wpm, R=${RUser.result.wpm}wpm`, + ); +} + export function forceFinishRace( io: TribesServer, room: Room, diff --git a/tribes-server/src/services/room-service.ts b/tribes-server/src/services/room-service.ts index 7cc53da8559d..f86a263b23c2 100644 --- a/tribes-server/src/services/room-service.ts +++ b/tribes-server/src/services/room-service.ts @@ -51,6 +51,12 @@ export function joinRoom( return { status: "Room not found" }; } + // Duel rooms are managed exclusively through duel-specific handlers. + // Keep them inaccessible from the generic room join path. + if (existingRoom.type === "duel") { + return { status: "Room not found" }; + } + if (existingRoom.state !== "LOBBY") { return { status: "Room is not in lobby state" }; } @@ -102,6 +108,8 @@ export function leaveRoom(io: TribesServer, socket: TribesSocket): void { } export function handleDisconnect(io: TribesServer, socket: TribesSocket): void { + const room = roomStore.getRoomBySocketId(socket.id); + if (room?.type === "duel") return; // Duel handler manages grace period leaveRoom(io, socket); } diff --git a/tribes-server/src/stores/duel-store.ts b/tribes-server/src/stores/duel-store.ts index 410167d21f95..0ae4332ec31a 100644 --- a/tribes-server/src/stores/duel-store.ts +++ b/tribes-server/src/stores/duel-store.ts @@ -1,8 +1,12 @@ // Duel state management - singleton store for duel-specific state -import { readFileSync, writeFileSync, existsSync } from "fs"; +import { readFileSync, existsSync } from "fs"; +import { writeFile } from "fs/promises"; import { DUEL_CONFIG, type DuelSide } from "../config.js"; import Logger from "../utils/logger.js"; +const MAX_STORED_RESULTS = 1000; +const PERSIST_DEBOUNCE_MS = 25; + /** * Represents a participant in a duel (one per side L/R) */ @@ -27,14 +31,123 @@ export interface DuelLiveWpm { updatedAt: number; } +export interface DuelResultSide { + userId: string; + username: string; + wpm: number; + raw: number; + acc: number; + consistency: number; +} + +type DuelResultWinner = DuelSide | "TIE"; + /** * A completed duel result record */ export interface DuelResult { timestamp: number; - L: { wpm: number; raw: number; acc: number; consistency: number }; - R: { wpm: number; raw: number; acc: number; consistency: number }; - winner: DuelSide | "TIE"; + L: DuelResultSide; + R: DuelResultSide; + winner: DuelResultWinner; +} + +export interface DuelLeaderboardEntry { + name: string; + wpm: number; + acc: number; + raw: number; + consistency: number; + date: number; +} + +export type DuelLeaderboard = Record; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function sanitizeIdentity(value: unknown, fallback: string): string { + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : fallback; +} + +function normalizeResultSide( + side: DuelSide, + value: unknown, +): DuelResultSide | null { + if (value === null || value === undefined || typeof value !== "object") { + return null; + } + + const resultSide = value as Record; + const wpm = resultSide["wpm"]; + const raw = resultSide["raw"]; + const acc = resultSide["acc"]; + const consistency = resultSide["consistency"]; + + if ( + !isFiniteNumber(wpm) || + !isFiniteNumber(raw) || + !isFiniteNumber(acc) || + !isFiniteNumber(consistency) + ) { + return null; + } + + const username = sanitizeIdentity(resultSide["username"], `System ${side}`); + const userId = sanitizeIdentity(resultSide["userId"], `${side}:${username}`); + + return { + userId, + username, + wpm, + raw, + acc, + consistency, + }; +} + +function computeWinner(L: DuelResultSide, R: DuelResultSide): DuelResultWinner { + return L.wpm > R.wpm ? "L" : R.wpm > L.wpm ? "R" : "TIE"; +} + +function normalizeLeaderboardEntry( + value: unknown, +): DuelLeaderboardEntry | null { + if (value === null || value === undefined || typeof value !== "object") { + return null; + } + + const entry = value as Record; + const name = entry["name"]; + const wpm = entry["wpm"]; + const raw = entry["raw"]; + const acc = entry["acc"]; + const consistency = entry["consistency"]; + const date = entry["date"]; + + if ( + typeof name !== "string" || + name.trim().length === 0 || + !isFiniteNumber(wpm) || + !isFiniteNumber(raw) || + !isFiniteNumber(acc) || + !isFiniteNumber(consistency) || + !isFiniteNumber(date) + ) { + return null; + } + + return { + name: name.trim(), + wpm, + raw, + acc, + consistency, + date, + }; } class DuelStore { @@ -56,6 +169,14 @@ class DuelStore { // Result history (persists across duels, cleared on server restart) private results: DuelResult[] = []; + // Latest final race result per OTP/user ID + private leaderboard: DuelLeaderboard = {}; + + // Async persistence state (debounced + coalesced) + private persistDebounceTimer: ReturnType | undefined; + private persistInFlight = false; + private persistRequested = false; + // ============================================================ // Side Management // ============================================================ @@ -339,21 +460,18 @@ class DuelStore { /** * Add a completed duel result and persist to disk. */ - addResult( - L: { wpm: number; raw: number; acc: number; consistency: number }, - R: { wpm: number; raw: number; acc: number; consistency: number }, - ): DuelResult { - const winner: DuelSide | "TIE" = - L.wpm > R.wpm ? "L" : R.wpm > L.wpm ? "R" : "TIE"; - + addResult(L: DuelResultSide, R: DuelResultSide): DuelResult { const result: DuelResult = { timestamp: Date.now(), L, R, - winner, + winner: computeWinner(L, R), }; this.results.push(result); + this.trimResultsIfNeeded(); + this.upsertLeaderboardEntry(L, result.timestamp); + this.upsertLeaderboardEntry(R, result.timestamp); this.persistResults(); return result; } @@ -365,6 +483,14 @@ class DuelStore { return [...this.results]; } + /** + * Project duel results into a stable identity map keyed by OTP/user ID. + * Each entry represents the latest final race result for that user. + */ + getLeaderboard(): DuelLeaderboard { + return { ...this.leaderboard }; + } + /** * Get the most recent result. */ @@ -372,6 +498,33 @@ class DuelStore { return this.results[this.results.length - 1]; } + /** + * Seed the leaderboard with placeholder entries for all OTP users. + * Placeholder entries have wpm: -1 and appear greyed out on the frontend. + * Only adds entries for users not already in the leaderboard. + */ + seedLeaderboardFromOtpMap(otpMap: Readonly>): void { + let seeded = 0; + for (const [otpCode, username] of Object.entries(otpMap)) { + if (this.leaderboard[otpCode]) continue; + this.leaderboard[otpCode] = { + name: username, + wpm: -1, + acc: -1, + raw: -1, + consistency: -1, + date: 0, + }; + seeded++; + } + if (seeded > 0) { + this.persistResults(); + Logger.info( + `Seeded ${seeded} placeholder leaderboard entries from OTP map`, + ); + } + } + /** * Load results from disk. Safe to call at startup. */ @@ -386,7 +539,38 @@ class DuelStore { const raw = readFileSync(path, "utf-8"); const parsed: unknown = JSON.parse(raw); if (Array.isArray(parsed)) { - this.results = parsed as DuelResult[]; + const normalized = parsed + .map((value, index) => this.normalizeResult(value, index)) + .filter((value): value is DuelResult => value !== null); + this.results = normalized; + this.trimResultsIfNeeded(); + this.rebuildLeaderboardFromResults(); + Logger.success( + `Loaded ${this.results.length} duel results from ${path}`, + ); + } else if ( + parsed !== null && + parsed !== undefined && + typeof parsed === "object" + ) { + const record = parsed as Record; + const rawResults = record["results"]; + const rawLeaderboard = record["leaderboard"]; + + if (Array.isArray(rawResults)) { + this.results = rawResults + .map((value, index) => this.normalizeResult(value, index)) + .filter((value): value is DuelResult => value !== null); + this.trimResultsIfNeeded(); + } else { + this.results = []; + } + + this.leaderboard = this.normalizeLeaderboard(rawLeaderboard); + if (Object.keys(this.leaderboard).length === 0) { + this.rebuildLeaderboardFromResults(); + } + Logger.success( `Loaded ${this.results.length} duel results from ${path}`, ); @@ -400,15 +584,136 @@ class DuelStore { * Persist results to disk. */ persistResults(): void { - try { - writeFileSync( - DUEL_CONFIG.RESULTS_PATH, - JSON.stringify(this.results, null, 2), - "utf-8", + this.persistRequested = true; + + if (this.persistDebounceTimer || this.persistInFlight) { + return; + } + + this.persistDebounceTimer = setTimeout(() => { + this.persistDebounceTimer = undefined; + this.flushPersist(); + }, PERSIST_DEBOUNCE_MS); + } + + private flushPersist(): void { + if (!this.persistRequested || this.persistInFlight) return; + + this.persistRequested = false; + this.persistInFlight = true; + + const payload = JSON.stringify( + { + results: this.results, + leaderboard: this.leaderboard, + }, + null, + 2, + ); + + void writeFile(DUEL_CONFIG.RESULTS_PATH, payload, "utf-8") + .catch((error) => { + Logger.warning(`Failed to persist duel results: ${error}`); + }) + .finally(() => { + this.persistInFlight = false; + if (this.persistRequested) { + this.flushPersist(); + } + }); + } + + private normalizeLeaderboard(value: unknown): DuelLeaderboard { + if (value === null || value === undefined || typeof value !== "object") { + return {}; + } + + const leaderboard: DuelLeaderboard = {}; + for (const [id, entry] of Object.entries(value)) { + const normalizedId = sanitizeIdentity(id, ""); + if (normalizedId === "") continue; + + const normalizedEntry = normalizeLeaderboardEntry(entry); + if (!normalizedEntry) continue; + + leaderboard[normalizedId] = normalizedEntry; + } + return leaderboard; + } + + /** + * Set a leaderboard entry directly (admin use). + */ + setLeaderboardEntry(userId: string, entry: DuelLeaderboardEntry): void { + this.leaderboard[userId] = entry; + this.persistResults(); + } + + private upsertLeaderboardEntry( + side: DuelResultSide, + timestamp: number, + ): void { + this.leaderboard[side.userId] = { + name: side.username, + wpm: side.wpm, + acc: side.acc, + raw: side.raw, + consistency: side.consistency, + date: timestamp, + }; + } + + private rebuildLeaderboardFromResults(): void { + this.leaderboard = {}; + for (const result of this.results) { + this.upsertLeaderboardEntry(result.L, result.timestamp); + this.upsertLeaderboardEntry(result.R, result.timestamp); + } + } + + private normalizeResult(value: unknown, index: number): DuelResult | null { + if (value === null || value === undefined || typeof value !== "object") { + Logger.warning(`Skipping invalid duel result at index ${index}`); + return null; + } + + const record = value as Record; + const L = normalizeResultSide("L", record["L"]); + const R = normalizeResultSide("R", record["R"]); + + if (!L || !R) { + Logger.warning( + `Skipping duel result at index ${index} due to malformed side data`, ); - } catch (error) { - Logger.warning(`Failed to persist duel results: ${error}`); + return null; } + + const winnerRaw = record["winner"]; + const winner: DuelResultWinner = + winnerRaw === "L" || winnerRaw === "R" || winnerRaw === "TIE" + ? winnerRaw + : computeWinner(L, R); + + const timestamp = isFiniteNumber(record["timestamp"]) + ? record["timestamp"] + : Date.now(); + + return { + timestamp, + L, + R, + winner, + }; + } + + private trimResultsIfNeeded(): void { + if (this.results.length <= MAX_STORED_RESULTS) return; + + const overflow = this.results.length - MAX_STORED_RESULTS; + this.results.splice(0, overflow); + Logger.info( + `Trimmed ${overflow} old duel result(s); keeping latest ${MAX_STORED_RESULTS}`, + ); } // ============================================================ @@ -446,6 +751,7 @@ class DuelStore { clearAll(): void { this.fullReset(); this.results = []; + this.leaderboard = {}; } } diff --git a/tribes-server/src/stores/room-store.ts b/tribes-server/src/stores/room-store.ts index fc4742967087..ad6a120e932d 100644 --- a/tribes-server/src/stores/room-store.ts +++ b/tribes-server/src/stores/room-store.ts @@ -271,6 +271,8 @@ class RoomStore { if (!room) return; room.seed = generateSeed(); + room.startAt = undefined; + room.duelResultRecorded = false; room.maxRaw = 0; room.maxWpm = 0; room.minRaw = Infinity; diff --git a/tribes-server/src/types/events.ts b/tribes-server/src/types/events.ts index 13766f644f99..326995a1a4cf 100644 --- a/tribes-server/src/types/events.ts +++ b/tribes-server/src/types/events.ts @@ -11,6 +11,7 @@ import type { } from "./room.js"; import type { RoomConfig } from "./config.js"; import type { DuelSide } from "../config.js"; +import type { DuelLeaderboard } from "../stores/duel-store.js"; /** * Response format for duel acknowledgments @@ -21,6 +22,28 @@ export interface DuelAckResponse { data?: unknown; } +export type DuelSpectatorSide = { + id: string; + name: string; + wpm: number; + connected: boolean; +}; + +export type DuelSpectatorPayload = { + serverTime: number; + roomId: string | null; + roomState: string | null; + active: boolean; + race: { + startAt: number | null; + duration: number; + }; + sides: { + L: DuelSpectatorSide | null; + R: DuelSpectatorSide | null; + }; +}; + // Client -> Server Events /** * Represents all events that can be emitted from the client to the server. @@ -121,6 +144,14 @@ export type ClientToServerEvents = { data: { clientTime: number }, callback: (response: { clientTime: number; serverTime: number }) => void, ) => void; + duel_spectator_subscribe: ( + callback: (response: { + ok: boolean; + state: DuelSpectatorPayload; + leaderboard: DuelLeaderboard; + }) => void, + ) => void; + duel_spectator_unsubscribe: () => void; }; // Server -> Client Events @@ -194,6 +225,8 @@ export type ServerToClientEvents = { seed: number; raceDuration: number; }) => void; + duel_spectator_state: (data: DuelSpectatorPayload) => void; + duel_leaderboard_snapshot: (data: DuelLeaderboard) => void; }; // Inter-server events (none for now) diff --git a/tribes-server/src/types/room.ts b/tribes-server/src/types/room.ts index c2ffc1306c35..e55583cf2101 100644 --- a/tribes-server/src/types/room.ts +++ b/tribes-server/src/types/room.ts @@ -113,6 +113,7 @@ export type Room = { // Duel-specific fields type?: RoomType; startAt?: number; // Server timestamp for synchronized start + duelResultRecorded?: boolean; // Idempotency flag for duel result recording }; export type PublicRoomData = { diff --git a/tribes-server/src/utils/duel-otp.ts b/tribes-server/src/utils/duel-otp.ts index d7f0e3b51ade..d880f6e489e4 100644 --- a/tribes-server/src/utils/duel-otp.ts +++ b/tribes-server/src/utils/duel-otp.ts @@ -1,6 +1,6 @@ // OTP loader and validator for duel authentication import { z } from "zod"; -import { readFileSync, existsSync } from "fs"; +import { readFileSync, writeFileSync, existsSync } from "fs"; import { DUEL_CONFIG } from "../config.js"; import Logger from "./logger.js"; @@ -81,3 +81,43 @@ export function getOtpMap(): Readonly> { export function isLoaded(): boolean { return loaded; } + +/** + * Add or update an OTP entry and persist to disk. + */ +export function setOtp(otp: string, username: string): void { + otpMap[otp] = username; + loaded = true; + persistOtpMap(); + Logger.info(`OTP set: ${otp} -> ${username}`); +} + +/** + * Remove an OTP entry and persist to disk. + */ +export function removeOtp(otp: string): boolean { + if (otpMap[otp] === undefined) return false; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete otpMap[otp]; + persistOtpMap(); + Logger.info(`OTP removed: ${otp}`); + return true; +} + +/** + * Replace the entire OTP map and persist to disk. + */ +export function setOtpMap(newMap: Record): void { + otpMap = { ...newMap }; + loaded = true; + persistOtpMap(); + Logger.info(`OTP map replaced with ${Object.keys(otpMap).length} entries`); +} + +function persistOtpMap(): void { + try { + writeFileSync(DUEL_CONFIG.OTP_PATH, JSON.stringify(otpMap, null, 2)); + } catch (error) { + Logger.error(`Failed to persist OTP map: ${error}`); + } +}