From c406a8b518662758d84c1a2063f7f5201cf65d9a Mon Sep 17 00:00:00 2001 From: notkanishk Date: Fri, 6 Feb 2026 01:15:39 +0000 Subject: [PATCH 01/15] feat: introduce a countdown overlay with visual effects before the duel view transition. (@notkanishk) --- .../src/html/pages/rbh/spectator-screen.html | 8 +++ frontend/src/styles/rbh/spectator-screen.scss | 70 +++++++++++++++++++ frontend/src/ts/pages/rbh/spectator-screen.ts | 35 ++++++++-- 3 files changed, 107 insertions(+), 6 deletions(-) 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 @@ + + + -
${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) --- - container.empty(); - entries.forEach((e, i) => { - container.append(createRow(e, i + 1)); +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; + + 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.id === updatedEntry.id); - 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 key = row.native.dataset["key"]; - if (key !== undefined && key !== "") { - oldPositions.set(key, 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 key = row.native.dataset["key"]; - if (key === undefined || key === "") return; +// --- Public API: update (single-entry change, differential) --- - const oldTop = oldPositions.get(key); - 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 = key === updatedEntry.id; - - 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 ============ @@ -836,14 +1321,16 @@ 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; - stopLeaderboardPolling(); - stopDuelStatePolling(); + teardownSpectatorPush(); + stopFallbackPolling(); resetDuelClock(); reset(); }, @@ -876,12 +1363,11 @@ export const page = new Page({ reconcileLeaderboard(fallbackEntries); } - startDuelStatePolling(); - startLeaderboardPolling(); + setupSpectatorPush(); }, beforeHide: async () => { - stopLeaderboardPolling(); - stopDuelStatePolling(); + teardownSpectatorPush(); + stopFallbackPolling(); stopDuelClock(); }, }); diff --git a/frontend/src/ts/tribe/duel/duel-flow.ts b/frontend/src/ts/tribe/duel/duel-flow.ts index c1c39463a091..7627d084ebed 100644 --- a/frontend/src/ts/tribe/duel/duel-flow.ts +++ b/frontend/src/ts/tribe/duel/duel-flow.ts @@ -1331,7 +1331,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-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-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/tribes-server/src/app.ts b/tribes-server/src/app.ts index 3b40e227f5ab..c4a9c946c365 100644 --- a/tribes-server/src/app.ts +++ b/tribes-server/src/app.ts @@ -10,9 +10,13 @@ import { registerDuelHandlers } from "./controllers/duel-controller.js"; import { startMatchmaking } from "./services/matchmaking-service.js"; import { loadOtpMap, getOtpMap } from "./utils/duel-otp.js"; import { duelStore } from "./stores/duel-store.js"; -import { roomStore } from "./stores/room-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, @@ -92,58 +96,7 @@ app.get("/duel/spectator", (_req, res): void => { res.status(404).json({ error: "Duel mode disabled" }); return; } - - 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 leftName = - participants.L?.username && participants.L.username.trim().length > 0 - ? participants.L.username - : "System Left"; - const rightName = - participants.R?.username && participants.R.username.trim().length > 0 - ? participants.R.username - : "System Right"; - - res.json({ - serverTime: Date.now(), - roomId: room?.id ?? null, - roomState: room?.state ?? null, - active: - room?.type === "duel" && - (room.state === "RACE_ONGOING" || room.state === "RACE_ONE_FINISHED"), - 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, - }, - }); + res.json(buildDuelSpectatorState(io)); }); // Create Socket.IO server @@ -163,6 +116,20 @@ 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) => { // Get name from handshake query 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 ad677b34b063..a706a093246d 100644 --- a/tribes-server/src/services/duel-service.ts +++ b/tribes-server/src/services/duel-service.ts @@ -48,6 +48,15 @@ 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); @@ -56,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, @@ -241,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: { @@ -261,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; @@ -277,7 +462,7 @@ export function authenticate( return { ok: true, data: { - userId: otp, + userId: normalizedOtp, username: validation.username, side, }, @@ -365,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); } } @@ -414,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, @@ -451,72 +642,14 @@ export function joinLobby( return buildJoinLobbyResponse(result.room); } - // 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}`, - ); - - // Clear stale WPM from previous race - duelStore.clearLiveWpm(); - - // 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, - }); - - // 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) - duelStartTimeout = setTimeout(() => { - duelStartTimeout = undefined; - if ( - existingRoom.state === "LOBBY" && - Object.keys(existingRoom.users).length >= 2 - ) { - 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 - }, - }); - } else { - Logger.warning( - `Duel race aborted — ${Object.keys(existingRoom.users).length} users in room`, - ); - } - }, DUEL_CONFIG.START_DELAY_MS); + scheduleDuelRace(io, existingRoom); return buildJoinLobbyResponse(result.room); } @@ -540,14 +673,14 @@ export function handleDisconnect(io: TribesServer, socket: TribesSocket): void { ); // Cancel any pending race start — prevents 1-player race after disconnect - if (duelStartTimeout) { - clearTimeout(duelStartTimeout); - duelStartTimeout = undefined; - Logger.info(`Cleared pending duel race start due to 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 }); } @@ -563,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) { @@ -604,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" }; @@ -611,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/room-service.ts b/tribes-server/src/services/room-service.ts index bbd921e08e87..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" }; } diff --git a/tribes-server/src/stores/duel-store.ts b/tribes-server/src/stores/duel-store.ts index e18d16f98d7b..eaffffefab56 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) */ @@ -168,6 +172,11 @@ class DuelStore { // 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 // ============================================================ @@ -460,6 +469,7 @@ class DuelStore { }; this.results.push(result); + this.trimResultsIfNeeded(); this.upsertLeaderboardEntry(L, result.timestamp); this.upsertLeaderboardEntry(R, result.timestamp); this.persistResults(); @@ -533,6 +543,7 @@ class DuelStore { .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}`, @@ -550,6 +561,7 @@ class DuelStore { this.results = rawResults .map((value, index) => this.normalizeResult(value, index)) .filter((value): value is DuelResult => value !== null); + this.trimResultsIfNeeded(); } else { this.results = []; } @@ -572,22 +584,43 @@ class DuelStore { * Persist results to disk. */ persistResults(): void { - try { - writeFileSync( - DUEL_CONFIG.RESULTS_PATH, - JSON.stringify( - { - results: this.results, - leaderboard: this.leaderboard, - }, - null, - 2, - ), - "utf-8", - ); - } catch (error) { - Logger.warning(`Failed to persist duel results: ${error}`); + 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 { @@ -665,6 +698,16 @@ class DuelStore { }; } + 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}`, + ); + } + // ============================================================ // Reset // ============================================================ 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) From 795ce81f91a14ccee3dd6966efd982c7b0366011 Mon Sep 17 00:00:00 2001 From: psygos Date: Sat, 7 Feb 2026 03:02:05 +0530 Subject: [PATCH 05/15] fix: resolve OTP auth race condition when socket not yet connected Previously handleAuthenticate returned false immediately when the socket wasn't connected, causing submitOtp to clear inputs and hide the spinner. Now awaits the connection before returning the real auth result. Co-Authored-By: Claude Opus 4.6 --- frontend/src/ts/tribe/duel/duel-flow.ts | 72 ++++++++++++++++--------- 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/frontend/src/ts/tribe/duel/duel-flow.ts b/frontend/src/ts/tribe/duel/duel-flow.ts index 7627d084ebed..ca89c75636af 100644 --- a/frontend/src/ts/tribe/duel/duel-flow.ts +++ b/frontend/src/ts/tribe/duel/duel-flow.ts @@ -187,39 +187,61 @@ async function handleAuthenticate(otp: string): Promise { 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); + + // After connect, register side and then authenticate + const side = DuelState.getSide(); + if (!side) { + TribePageOtp.showError("No side selected"); + 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", - ); + // Register side + const registerResult = await registerCurrentSide(side, true); + if (!registerResult.ok) { + TribePageOtp.showError( + registerResult.error ?? "Failed to register side", + ); + TribePageOtp.setLoading(false); + settle(false); + return; + } + + // 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, just authenticate From 3d0b3731dde756f99cd6f776eb62f21677016b82 Mon Sep 17 00:00:00 2001 From: psygos Date: Sat, 7 Feb 2026 03:09:19 +0530 Subject: [PATCH 06/15] ui: remove ads, clean up EULA, grey out DNF participants like F1 - Hide all ad elements during duel flow - Remove "public profile" link from EULA page - Non-finishers shown at bottom with "DNF" label, greyed out Co-Authored-By: Claude Opus 4.6 --- frontend/src/html/pages/tribe.html | 5 ----- frontend/src/styles/tribe.scss | 30 ++++++++++++++++---------- frontend/src/ts/tribe/tribe-results.ts | 3 +++ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/frontend/src/html/pages/tribe.html b/frontend/src/html/pages/tribe.html index 0475a2268b57..6b8380a6a0c3 100644 --- a/frontend/src/html/pages/tribe.html +++ b/frontend/src/html/pages/tribe.html @@ -129,11 +129,6 @@ credentials provided. -

- View your - public profile - for past results and stats. -

Continuing in
diff --git a/frontend/src/styles/tribe.scss b/frontend/src/styles/tribe.scss index 13e9490f5e1c..9896f18cc746 100644 --- a/frontend/src/styles/tribe.scss +++ b/frontend/src/styles/tribe.scss @@ -723,6 +723,13 @@ &.faded { opacity: 0.25; } + &.dnf { + opacity: 0.35; + color: var(--sub-color); + .pos { + color: var(--sub-color); + } + } .progressAndGraph { width: 25%; } @@ -1026,17 +1033,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 +1200,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/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); } } From f7ac6a27902d0bc4994d572aa131df9624968743 Mon Sep 17 00:00:00 2001 From: psygos Date: Sat, 7 Feb 2026 03:35:35 +0530 Subject: [PATCH 07/15] fix: clean up FSM, fix OTP auth guard, show opponent results - Remove dead COUNTDOWN_1/COUNTDOWN_2 states and isInCountdown() - RESULTS now allows transition to LOBBY (opponent disconnect) - Remove strict OTP flow-state guard that blocked valid submissions - Always register side before authenticating (both connected paths) - Populate opponent WPM/graph on race complete (was skipped by shouldBlockUI early return during RACING state) Co-Authored-By: Claude Opus 4.6 --- frontend/src/ts/tribe/duel/duel-flow.ts | 44 ++++++++++++++---------- frontend/src/ts/tribe/duel/duel-state.ts | 26 ++++---------- 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/frontend/src/ts/tribe/duel/duel-flow.ts b/frontend/src/ts/tribe/duel/duel-flow.ts index ca89c75636af..b8eb2c6c1f16 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"; @@ -178,15 +180,14 @@ 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 then authenticate if (!TribeSocket.getId()) { TribePageOtp.setLoading(true); @@ -209,16 +210,7 @@ async function handleAuthenticate(otp: string): Promise { onConnectedCallback = async (): Promise => { clearTimeout(connectTimeout); - // After connect, register side and then authenticate - const side = DuelState.getSide(); - if (!side) { - TribePageOtp.showError("No side selected"); - TribePageOtp.setLoading(false); - settle(false); - return; - } - - // Register side + // Register side (may already be registered — server handles idempotently) const registerResult = await registerCurrentSide(side, true); if (!registerResult.ok) { TribePageOtp.showError( @@ -244,7 +236,14 @@ async function handleAuthenticate(otp: string): Promise { return authResult; } - // Already connected, just authenticate + // 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; + } + const result = await doAuthenticate(otp); if (result) { await showEulaPage(); @@ -754,9 +753,9 @@ export function onOpponentLeft(_side: DuelSide): void { duration: 3, }); - // If we're in lobby or racing, cancel everything and return to lobby + // 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(); hideDuelBanner(); @@ -1048,6 +1047,13 @@ 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(() => { showAutoAdvanceButton(); 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 { From 1965ddef01da44304d16675a38d6a54244d95e58 Mon Sep 17 00:00:00 2001 From: psygos Date: Sat, 7 Feb 2026 03:45:09 +0530 Subject: [PATCH 08/15] chore: remove debug state display (tribeStateDisplay) Co-Authored-By: Claude Opus 4.6 --- frontend/src/index.html | 1 - frontend/src/styles/tribe.scss | 5 ----- frontend/src/ts/tribe/tribe.ts | 8 -------- 3 files changed, 14 deletions(-) diff --git a/frontend/src/index.html b/frontend/src/index.html index 5aeb09fbff3a..471b5b17e1b9 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -16,7 +16,6 @@
-
diff --git a/frontend/src/styles/tribe.scss b/frontend/src/styles/tribe.scss index 9896f18cc746..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; 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"); From c62f20248c08fac8d6f289ec32d1fc06dad6159c Mon Sep 17 00:00:00 2001 From: psygos Date: Sat, 7 Feb 2026 04:09:23 +0530 Subject: [PATCH 09/15] feat: add admin endpoints for users, config, and leaderboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST/PUT/DELETE /duel/admin/users — manage OTP user map - GET/POST /duel/admin/config — view/update runtime config - POST /duel/admin/leaderboard — set leaderboard entries directly - All user changes persist to duel-otp.json - Leaderboard changes persist to duel-results.json Co-Authored-By: Claude Opus 4.6 --- tribes-server/src/app.ts | 197 ++++++++++++++++++++++++- tribes-server/src/config.ts | 6 +- tribes-server/src/stores/duel-store.ts | 8 + tribes-server/src/utils/duel-otp.ts | 42 +++++- 4 files changed, 246 insertions(+), 7 deletions(-) diff --git a/tribes-server/src/app.ts b/tribes-server/src/app.ts index c4a9c946c365..ec7b98aced0a 100644 --- a/tribes-server/src/app.ts +++ b/tribes-server/src/app.ts @@ -8,7 +8,13 @@ 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, getOtpMap } 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"; @@ -35,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, } : { @@ -45,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, }; @@ -99,6 +105,191 @@ app.get("/duel/spectator", (_req, res): void => { 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, 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/stores/duel-store.ts b/tribes-server/src/stores/duel-store.ts index eaffffefab56..0ae4332ec31a 100644 --- a/tribes-server/src/stores/duel-store.ts +++ b/tribes-server/src/stores/duel-store.ts @@ -641,6 +641,14 @@ class DuelStore { 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, 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}`); + } +} From 16cc18e4f9b1ef17c6953db1c10f49e72960e739 Mon Sep 17 00:00:00 2001 From: psygos Date: Sat, 7 Feb 2026 04:52:08 +0530 Subject: [PATCH 10/15] fix: prevent stale UI/state bleeding between duel rounds - Reset tribe results and destroy charts before each practice and race - Lock Tab/Enter/Escape during active duel flow to prevent test restarts - Lock config changes during duel flow unless explicitly overridden - Track and clean up showAutoAdvanceTimeout properly - Clean up auto-advance UI on opponent disconnect - Use tribeOverride for all config changes in duel flow Co-Authored-By: Claude Opus 4.6 --- frontend/src/ts/event-handlers/global.ts | 31 +++++++++- frontend/src/ts/tribe/duel/duel-flow.ts | 68 +++++++++++++++++---- frontend/src/ts/tribe/tribe-config-check.ts | 7 +++ 3 files changed, 94 insertions(+), 12 deletions(-) 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/tribe/duel/duel-flow.ts b/frontend/src/ts/tribe/duel/duel-flow.ts index b8eb2c6c1f16..7a78805334f1 100644 --- a/frontend/src/ts/tribe/duel/duel-flow.ts +++ b/frontend/src/ts/tribe/duel/duel-flow.ts @@ -38,6 +38,7 @@ let eulaTimeout: ReturnType | undefined; let waitingNavigationTimeout: ReturnType | undefined; let showLobbyTimeout: ReturnType | undefined; let raceCountdownInterval: ReturnType | undefined; +let showAutoAdvanceTimeout: ReturnType | undefined; /** * Initialize the duel flow. @@ -440,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() ?? "?"; @@ -758,6 +777,8 @@ export function onOpponentLeft(_side: DuelSide): void { 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 @@ -811,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. @@ -841,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..."); @@ -1055,7 +1092,8 @@ export function onDuelRaceComplete(): void { }); // Show auto-advance button below results after a short delay - setTimeout(() => { + showAutoAdvanceTimeout = setTimeout(() => { + showAutoAdvanceTimeout = undefined; showAutoAdvanceButton(); }, 500); } @@ -1135,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; @@ -1346,6 +1388,10 @@ function cleanupTransientTimers(): void { clearInterval(raceCountdownInterval); raceCountdownInterval = undefined; } + if (showAutoAdvanceTimeout) { + clearTimeout(showAutoAdvanceTimeout); + showAutoAdvanceTimeout = undefined; + } } async function registerCurrentSide( 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; From 3056e1f7db19d16fa56029ad4a49a6d5e2dd4abf Mon Sep 17 00:00:00 2001 From: psygos Date: Sat, 7 Feb 2026 08:38:51 +0530 Subject: [PATCH 11/15] chore: populate OTP map with 62 participants from rbh_list - 47 entries from xlsx gate pass codes - 8 generated codes for participants without gate pass - 7 test entries preserved (000000-555555, 999999) - Seeded leaderboard placeholders for all participants Co-Authored-By: Claude Opus 4.6 --- tribes-server/duel-otp.json | 60 ++++- tribes-server/duel-results.json | 450 +++++++++++++++++++++++++++++++- 2 files changed, 507 insertions(+), 3 deletions(-) diff --git a/tribes-server/duel-otp.json b/tribes-server/duel-otp.json index 6e98e6eeb5a7..0523395b553e 100644 --- a/tribes-server/duel-otp.json +++ b/tribes-server/duel-otp.json @@ -1,8 +1,64 @@ { - "000000": "ayush", "111111": "sai", + "126225": "Abaan Ali Ansari", + "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", + "999999": "Kailash", + "000000": "ayush" } diff --git a/tribes-server/duel-results.json b/tribes-server/duel-results.json index 78e653fb8e91..a4ba320f1565 100644 --- a/tribes-server/duel-results.json +++ b/tribes-server/duel-results.json @@ -4,48 +4,496 @@ "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, - "acc": -1, "raw": -1, + "acc": -1, "consistency": -1, "date": 0 } From ba6e36c7b6b657102dde252f342468f7608697e6 Mon Sep 17 00:00:00 2001 From: psygos Date: Sat, 7 Feb 2026 08:42:28 +0530 Subject: [PATCH 12/15] chore: add Ansh and Kaustubh to OTP map Co-Authored-By: Claude Opus 4.6 --- tribes-server/duel-otp.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tribes-server/duel-otp.json b/tribes-server/duel-otp.json index 0523395b553e..fa815e86432b 100644 --- a/tribes-server/duel-otp.json +++ b/tribes-server/duel-otp.json @@ -59,6 +59,8 @@ "594972": "Karan Singh", "770487": "Mihir Aggarwal", "877572": "Saumya Mishra", + "888888": "Ansh", + "888889": "Kaustubh", "999999": "Kailash", "000000": "ayush" } From 366a7964575b68010e89c1e2b3c6530b7c70c39a Mon Sep 17 00:00:00 2001 From: psygos Date: Sat, 7 Feb 2026 11:09:38 +0530 Subject: [PATCH 13/15] chore: add Test 1 and Test 2 dummy users to OTP map Co-Authored-By: Claude Opus 4.6 --- tribes-server/duel-otp.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tribes-server/duel-otp.json b/tribes-server/duel-otp.json index fa815e86432b..5a2cc250a4df 100644 --- a/tribes-server/duel-otp.json +++ b/tribes-server/duel-otp.json @@ -1,6 +1,8 @@ { + "101010": "Test 1", "111111": "sai", "126225": "Abaan Ali Ansari", + "202020": "Test 2", "216739": "Roshni Agarwal", "222222": "sanchak", "246316": "Kumari Anushka", From 4394c8e1cc42b2a67d7647b414343a651163b71d Mon Sep 17 00:00:00 2001 From: psygos Date: Sat, 7 Feb 2026 11:16:28 +0530 Subject: [PATCH 14/15] fix: harden duel flow against save failures and suppress ads - Suppress ads on result page during duel sessions - Force ads config off at duel flow init - Don't block duel progression on result-save latency/failures - Add error handling for save promise to prevent unhandled rejections - Capture duel state before async operations to avoid race conditions Co-Authored-By: Claude Opus 4.6 --- frontend/src/ts/test/result.ts | 15 ++- frontend/src/ts/test/test-logic.ts | 125 ++++++++++++++++++------ frontend/src/ts/tribe/duel/duel-flow.ts | 6 ++ 3 files changed, 115 insertions(+), 31 deletions(-) diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index dc5e69b52faa..66fb1e14191b 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -1148,7 +1148,16 @@ export async function update( } } - AdController.updateFooterAndVerticalAds(true); + const duelFlowState = DuelState.getFlowState(); + const isDuelSession = + duelFlowState !== "SYSTEM_SELECT" && duelFlowState !== "OTP"; + + if (isDuelSession) { + AdController.updateFooterAndVerticalAds(false); + AdController.destroyResult(); + } else { + AdController.updateFooterAndVerticalAds(true); + } void Funbox.clear(); $(".pageTest .loading").addClass("hidden"); @@ -1165,7 +1174,9 @@ export async function update( }); Misc.scrollToCenterOrTop(resultEl); - void AdController.renderResult(); + if (!isDuelSession) { + void AdController.renderResult(); + } TestUI.setResultCalculating(false); $("#words").empty(); ChartController.result.resize(); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 92f411d587e2..a01b59a6b1a0 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1223,24 +1223,44 @@ export async function finish(difficultyFailed = false): Promise { completedEvent.hash = objectHash(completedEvent); savingResultPromise = saveResult(completedEvent, false); - void savingResultPromise.then((promise) => { - if (promise.response && promise.response.status === 200) { - void AnalyticsController.log("testCompleted"); + void savingResultPromise + .then((promise) => { + if (promise.response && promise.response.status === 200) { + void AnalyticsController.log("testCompleted"); + resolveTestSavePromise({ + login: true, + bailedOut: completedEvent.bailedOut, + ...(promise.saved + ? { + saved: true, + isPb: promise.response.body.data?.isPb ?? false, + } + : { + saved: false, + saveFailedMessage: promise.message, + }), + }); + return; + } + resolveTestSavePromise({ login: true, bailedOut: completedEvent.bailedOut, - ...(promise.saved - ? { - saved: true, - isPb: promise.response.body.data?.isPb ?? false, - } - : { - saved: false, - saveFailedMessage: promise.message, - }), + saved: false, + saveFailedMessage: promise.message, }); - } - }); + }) + .catch((error: unknown) => { + resolveTestSavePromise({ + login: true, + bailedOut: completedEvent.bailedOut, + saved: false, + saveFailedMessage: Misc.createErrorMessage( + error, + "Failed to save result", + ), + }); + }); } } else { // logged out @@ -1277,26 +1297,49 @@ export async function finish(difficultyFailed = false): Promise { dontSave, ); - void TribeResults.send({ - wpm: completedEvent.wpm, - raw: completedEvent.rawWpm, - acc: completedEvent.acc, - consistency: completedEvent.consistency, - testDuration: completedEvent.testDuration, - charStats: completedEvent.charStats, - chartData: tribeChartData, - resolve: await testSavePromise, + const wasDuelPractice = DuelFlow.isPracticing(); + const wasDuelRace = DuelFlow.isRacing(); + + // Never block duel progression on result-save latency/failures. + void (async () => { + const resolve = await testSavePromise; + await TribeResults.send({ + wpm: completedEvent.wpm, + raw: completedEvent.rawWpm, + acc: completedEvent.acc, + consistency: completedEvent.consistency, + testDuration: completedEvent.testDuration, + charStats: completedEvent.charStats, + chartData: tribeChartData, + resolve, + }); + })().catch((error: unknown) => { + console.warn( + "[TestLogic] Failed to broadcast tribe result payload:", + error, + ); }); - await Promise.all([savingResultPromise, resultUpdatePromise]); + if (wasDuelPractice || wasDuelRace) { + try { + await resultUpdatePromise; + } catch (error) { + console.error( + "[TestLogic] Result update failed during duel flow:", + error, + ); + } + } else { + await Promise.all([savingResultPromise, resultUpdatePromise]); + } // Check if this was a duel practice test - if (DuelFlow.isPracticing()) { + if (wasDuelPractice && DuelFlow.isPracticing()) { console.log( "[TestLogic] Duel practice test completed, notifying duel flow", ); void DuelFlow.onPracticeComplete(); - } else if (DuelFlow.isRacing()) { + } else if (wasDuelRace && DuelFlow.isRacing()) { console.log("[TestLogic] Duel race completed, notifying duel flow"); DuelFlow.onDuelRaceComplete(); } @@ -1347,7 +1390,32 @@ async function saveResult( }; } - const response = await Ape.results.add({ body: { result: completedEvent } }); + let response: Awaited>; + try { + response = await Ape.results.add({ body: { result: completedEvent } }); + } catch (error) { + AccountButton.loading(false); + + if (!TribeState.isInARoom()) { + retrySaving.canRetry = true; + $("#retrySavingResultButton").removeClass("hidden"); + if (!isRetrying) { + retrySaving.completedEvent = completedEvent; + } + } + + const message = Misc.createErrorMessage(error, "Failed to save result"); + Notifications.add(message, -1, { + important: true, + duration: 4, + }); + + return { + saved: false, + message, + response: null, + }; + } AccountButton.loading(false); @@ -1684,8 +1752,7 @@ ConfigEvent.subscribe(({ key, newValue, nosave }) => { void KeymapEvent.highlight( Arrays.nthElementFromArray( // ignoring for now but this might need a different approach - // oxlint-disable-next-line no-misused-spread - [...TestWords.words.getCurrent()], + Array.from(TestWords.words.getCurrent()), 0, ) as string, ); diff --git a/frontend/src/ts/tribe/duel/duel-flow.ts b/frontend/src/ts/tribe/duel/duel-flow.ts index 7a78805334f1..fdb6f50317f6 100644 --- a/frontend/src/ts/tribe/duel/duel-flow.ts +++ b/frontend/src/ts/tribe/duel/duel-flow.ts @@ -52,6 +52,12 @@ export async function init(): Promise { hideCountdownBelowResults(); hideDuelBanner(); + // Duel events run ad-free: force ads off for this session. + UpdateConfig.setConfig("ads", "off", { + nosave: true, + tribeOverride: true, + }); + // Initialize state (checks localStorage for side) const initialState = DuelState.initDuelState(); From f04afb9f8f078cb3e34532cb0411dd9999ffc318 Mon Sep 17 00:00:00 2001 From: psygos Date: Sat, 7 Feb 2026 11:21:03 +0530 Subject: [PATCH 15/15] Revert "fix: harden duel flow against save failures and suppress ads" This reverts commit 4394c8e1cc42b2a67d7647b414343a651163b71d. --- frontend/src/ts/test/result.ts | 15 +-- frontend/src/ts/test/test-logic.ts | 125 ++++++------------------ frontend/src/ts/tribe/duel/duel-flow.ts | 6 -- 3 files changed, 31 insertions(+), 115 deletions(-) diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 66fb1e14191b..dc5e69b52faa 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -1148,16 +1148,7 @@ export async function update( } } - const duelFlowState = DuelState.getFlowState(); - const isDuelSession = - duelFlowState !== "SYSTEM_SELECT" && duelFlowState !== "OTP"; - - if (isDuelSession) { - AdController.updateFooterAndVerticalAds(false); - AdController.destroyResult(); - } else { - AdController.updateFooterAndVerticalAds(true); - } + AdController.updateFooterAndVerticalAds(true); void Funbox.clear(); $(".pageTest .loading").addClass("hidden"); @@ -1174,9 +1165,7 @@ export async function update( }); Misc.scrollToCenterOrTop(resultEl); - if (!isDuelSession) { - void AdController.renderResult(); - } + void AdController.renderResult(); TestUI.setResultCalculating(false); $("#words").empty(); ChartController.result.resize(); diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index a01b59a6b1a0..92f411d587e2 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -1223,44 +1223,24 @@ export async function finish(difficultyFailed = false): Promise { completedEvent.hash = objectHash(completedEvent); savingResultPromise = saveResult(completedEvent, false); - void savingResultPromise - .then((promise) => { - if (promise.response && promise.response.status === 200) { - void AnalyticsController.log("testCompleted"); - resolveTestSavePromise({ - login: true, - bailedOut: completedEvent.bailedOut, - ...(promise.saved - ? { - saved: true, - isPb: promise.response.body.data?.isPb ?? false, - } - : { - saved: false, - saveFailedMessage: promise.message, - }), - }); - return; - } - - resolveTestSavePromise({ - login: true, - bailedOut: completedEvent.bailedOut, - saved: false, - saveFailedMessage: promise.message, - }); - }) - .catch((error: unknown) => { + void savingResultPromise.then((promise) => { + if (promise.response && promise.response.status === 200) { + void AnalyticsController.log("testCompleted"); resolveTestSavePromise({ login: true, bailedOut: completedEvent.bailedOut, - saved: false, - saveFailedMessage: Misc.createErrorMessage( - error, - "Failed to save result", - ), + ...(promise.saved + ? { + saved: true, + isPb: promise.response.body.data?.isPb ?? false, + } + : { + saved: false, + saveFailedMessage: promise.message, + }), }); - }); + } + }); } } else { // logged out @@ -1297,49 +1277,26 @@ export async function finish(difficultyFailed = false): Promise { dontSave, ); - const wasDuelPractice = DuelFlow.isPracticing(); - const wasDuelRace = DuelFlow.isRacing(); - - // Never block duel progression on result-save latency/failures. - void (async () => { - const resolve = await testSavePromise; - await TribeResults.send({ - wpm: completedEvent.wpm, - raw: completedEvent.rawWpm, - acc: completedEvent.acc, - consistency: completedEvent.consistency, - testDuration: completedEvent.testDuration, - charStats: completedEvent.charStats, - chartData: tribeChartData, - resolve, - }); - })().catch((error: unknown) => { - console.warn( - "[TestLogic] Failed to broadcast tribe result payload:", - error, - ); + void TribeResults.send({ + wpm: completedEvent.wpm, + raw: completedEvent.rawWpm, + acc: completedEvent.acc, + consistency: completedEvent.consistency, + testDuration: completedEvent.testDuration, + charStats: completedEvent.charStats, + chartData: tribeChartData, + resolve: await testSavePromise, }); - if (wasDuelPractice || wasDuelRace) { - try { - await resultUpdatePromise; - } catch (error) { - console.error( - "[TestLogic] Result update failed during duel flow:", - error, - ); - } - } else { - await Promise.all([savingResultPromise, resultUpdatePromise]); - } + await Promise.all([savingResultPromise, resultUpdatePromise]); // Check if this was a duel practice test - if (wasDuelPractice && DuelFlow.isPracticing()) { + if (DuelFlow.isPracticing()) { console.log( "[TestLogic] Duel practice test completed, notifying duel flow", ); void DuelFlow.onPracticeComplete(); - } else if (wasDuelRace && DuelFlow.isRacing()) { + } else if (DuelFlow.isRacing()) { console.log("[TestLogic] Duel race completed, notifying duel flow"); DuelFlow.onDuelRaceComplete(); } @@ -1390,32 +1347,7 @@ async function saveResult( }; } - let response: Awaited>; - try { - response = await Ape.results.add({ body: { result: completedEvent } }); - } catch (error) { - AccountButton.loading(false); - - if (!TribeState.isInARoom()) { - retrySaving.canRetry = true; - $("#retrySavingResultButton").removeClass("hidden"); - if (!isRetrying) { - retrySaving.completedEvent = completedEvent; - } - } - - const message = Misc.createErrorMessage(error, "Failed to save result"); - Notifications.add(message, -1, { - important: true, - duration: 4, - }); - - return { - saved: false, - message, - response: null, - }; - } + const response = await Ape.results.add({ body: { result: completedEvent } }); AccountButton.loading(false); @@ -1752,7 +1684,8 @@ ConfigEvent.subscribe(({ key, newValue, nosave }) => { void KeymapEvent.highlight( Arrays.nthElementFromArray( // ignoring for now but this might need a different approach - Array.from(TestWords.words.getCurrent()), + // oxlint-disable-next-line no-misused-spread + [...TestWords.words.getCurrent()], 0, ) as string, ); diff --git a/frontend/src/ts/tribe/duel/duel-flow.ts b/frontend/src/ts/tribe/duel/duel-flow.ts index fdb6f50317f6..7a78805334f1 100644 --- a/frontend/src/ts/tribe/duel/duel-flow.ts +++ b/frontend/src/ts/tribe/duel/duel-flow.ts @@ -52,12 +52,6 @@ export async function init(): Promise { hideCountdownBelowResults(); hideDuelBanner(); - // Duel events run ad-free: force ads off for this session. - UpdateConfig.setConfig("ads", "off", { - nosave: true, - tribeOverride: true, - }); - // Initialize state (checks localStorage for side) const initialState = DuelState.initDuelState();