From 65be63677973362f2523abbb7e4fad960bcf4257 Mon Sep 17 00:00:00 2001 From: Psybah Date: Sat, 17 Oct 2026 20:04:57 +0100 Subject: [PATCH 1/4] Keep friend races open for the field and remove automated typists. A first-place finish no longer ends the room, 200 wpm bans the session, and the main nav is a floating icon dock. --- app/codepace.tsx | 125 ++++++++++++++++++++++++++++++++--- app/immersive.css | 124 +++++++++++++++++++++++++++------- app/workspace.css | 42 +----------- lib/fair-play.ts | 37 +++++++++++ lib/room-client.ts | 2 +- lib/room-race.ts | 26 ++++++++ lib/rooms-server.ts | 57 +++++++++++++--- public/llms.txt | 2 + tests/coding-ui.test.tsx | 3 +- tests/fair-play-ui.test.tsx | 61 +++++++++++++++++ tests/fair-play.test.ts | 87 ++++++++++++++++++++++++ tests/navigation-ui.test.tsx | 2 +- 12 files changed, 479 insertions(+), 89 deletions(-) create mode 100644 lib/fair-play.ts create mode 100644 lib/room-race.ts create mode 100644 tests/fair-play-ui.test.tsx create mode 100644 tests/fair-play.test.ts diff --git a/app/codepace.tsx b/app/codepace.tsx index 788b4e2..1fb8071 100644 --- a/app/codepace.tsx +++ b/app/codepace.tsx @@ -82,6 +82,13 @@ import { makePassage } from "@/lib/passages"; import type { RoomSession, RoomSnapshot } from "@/lib/room-types"; import { ROOM_CAPACITY } from "@/lib/room-limits"; import { readSavedValue, removeSavedValue } from "@/lib/storage"; +import { + BAN_MESSAGE, + destroyLocalPlay, + isBanned, + isImpossibleProgress, + MAX_FAIR_WPM, +} from "@/lib/fair-play"; type Tab = "code" | "race" | "practice" | "friends" | "progress"; type Phase = "ready" | "countdown" | "running" | "finished"; @@ -159,6 +166,7 @@ export default function CodePace({ const [connection, setConnection] = useState(true); const [hydrated, setHydrated] = useState(false); const [storageWorks, setStorageWorks] = useState(true); + const [banned, setBanned] = useState(false); const inputRef = useRef(null); const passageBox = useRef(null); const dialogRef = useRef(null); @@ -203,6 +211,18 @@ export default function CodePace({ seqRef.current = 0; if (passageBox.current) passageBox.current.scrollTop = 0; }, []); + const punishCheat = useCallback(() => { + destroyLocalPlay(localStorage, sessionStorage); + setBanned(true); + setSession(null); + setRoom(null); + setHistory([]); + setResult(null); + snapshotRef.current = null; + sessionRef.current = null; + changePhase("ready"); + clearEngine(); + }, [changePhase, clearEngine]); const codingStorageError = useCallback(() => setStorageWorks(false), []); const selectCodeExercise = useCallback( (exercise: CodeExercise | null) => { @@ -222,6 +242,10 @@ export default function CodePace({ savedRef.current = true; const config = configRef.current; const current = stats(engineRef.current, passageRef.current, time); + if (isImpossibleProgress(current.correct, time)) { + punishCheat(); + return; + } const joined = sessionRef.current; const completed: RaceResult = { id: joined @@ -265,7 +289,7 @@ export default function CodePace({ : [completed, ...previous].slice(0, 100), ); }, - [changePhase], + [changePhase, punishCheat], ); const acceptRoom = useCallback( (snapshot: RoomSnapshot, sentAt: number) => { @@ -316,6 +340,11 @@ export default function CodePace({ const load = async () => { await Promise.resolve(); try { + if (isBanned(localStorage)) { + setBanned(true); + setHydrated(true); + return; + } setHistory( readHistory(readSavedValue(localStorage, "codepace:history")), ); @@ -384,10 +413,16 @@ export default function CodePace({ if (!mounted) return; if ( error instanceof RequestError && - [400, 401, 404].includes(error.status) + [400, 401, 403, 404].includes(error.status) ) { removeSavedValue(sessionStorage, "codepace:room"); - setRoomError(error.message); + if ( + error instanceof RequestError && + error.status === 403 && + error.message === BAN_MESSAGE + ) + punishCheat(); + else setRoomError(error.message); } } finally { if (mounted) setPending(false); @@ -403,7 +438,7 @@ export default function CodePace({ mounted = false; controller.abort(); }; - }, [acceptRoom]); + }, [acceptRoom, punishCheat]); useEffect(() => { if (!hydrated) return; try { @@ -481,6 +516,9 @@ export default function CodePace({ setRoom(null); changePhase("ready"); clearEngine(); + } else if (error instanceof RequestError && error.status === 403) { + if (error.message === BAN_MESSAGE) punishCheat(); + else notify(error.message); } else if (error instanceof RequestError && error.status === 400) notify(error.message); }, @@ -490,7 +528,7 @@ export default function CodePace({ synchronizer.stop(); roomSyncRef.current = null; }; - }, [session, acceptRoom, changePhase, clearEngine, notify]); + }, [session, acceptRoom, changePhase, clearEngine, notify, punishCheat]); useEffect(() => { const timer = setInterval(() => { const currentPhase = phaseRef.current; @@ -513,7 +551,7 @@ export default function CodePace({ ); secondRef.current = second; } - if (time >= limit) finish(limit); + if (time >= limit && !sessionRef.current) finish(limit); }, 100); return () => clearInterval(timer); }, [changePhase, finish]); @@ -627,7 +665,17 @@ export default function CodePace({ oscillator.start(); oscillator.stop(context.currentTime + 0.04); } - if (next.typed === passage) finish(time); + if (isImpossibleProgress(stats(next, passage, time).correct, time)) { + punishCheat(); + return; + } + if (next.typed === passage) { + if (sessionRef.current) { + roomSyncRef.current?.push(); + return; + } + finish(time); + } } function navigate(next: Tab) { if (session && next !== "friends") { @@ -676,9 +724,16 @@ export default function CodePace({ url.searchParams.set("room", data.session.code); window.history.replaceState(null, "", url); } catch (error) { - setRoomError( - error instanceof Error ? error.message : "Could not join the room.", - ); + if ( + error instanceof RequestError && + error.status === 403 && + error.message === BAN_MESSAGE + ) + punishCheat(); + else + setRoomError( + error instanceof Error ? error.message : "Could not join the room.", + ); } finally { setPending(false); } @@ -1015,6 +1070,17 @@ export default function CodePace({ event.preventDefault(); notify("Type each character to keep the race fair."); }} + onBeforeInput={(event) => { + const type = event.nativeEvent.inputType; + if ( + type === "insertFromPaste" || + type === "insertFromDrop" || + type === "insertReplacementText" + ) { + event.preventDefault(); + notify("Type each character to keep the race fair."); + } + }} onDrop={(event) => event.preventDefault()} onKeyDown={(event) => { if ( @@ -1055,6 +1121,15 @@ export default function CodePace({

Hands on the keys.

)} + {room && + phase === "running" && + passage.length > 0 && + engine.typed === passage && ( +

+ You finished. The race stays open until the last racer is + done. +

+ )} {phase === "running" && !focused && ( ))} diff --git a/app/immersive.css b/app/immersive.css index be1def9..c3f5099 100644 --- a/app/immersive.css +++ b/app/immersive.css @@ -2,6 +2,7 @@ position: relative; isolation: isolate; background: transparent; + padding-left: 80px; } .slick-codepace::before { @@ -24,7 +25,7 @@ width: 100%; max-width: none; display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) auto; padding: 28px 4.5%; gap: 32px; background: transparent; @@ -54,26 +55,88 @@ height: 28px; } .slick-codepace .header nav { - background: transparent; - padding: 0; + position: fixed; + left: 18px; + top: 50%; + z-index: 20; + display: flex; + flex-direction: column; + justify-self: auto; gap: 6px; + padding: 8px 6px; + border: 1px solid var(--line); + border-radius: 22px; + background: color-mix(in srgb, var(--surface) 82%, transparent); + backdrop-filter: blur(16px); + transform: translateY(-50%); + transition: + transform 220ms var(--ease), + background 220ms var(--ease), + opacity 220ms var(--ease); } .slick-codepace .header nav button { - min-height: 42px; - border-radius: 10px; + position: relative; + width: 44px; + min-height: 44px; + padding: 0; + justify-content: center; + border-radius: 14px; font-size: 13px; - padding: 9px 14px; font-weight: 450; + transition: + background 180ms var(--ease), + color 180ms var(--ease), + transform 180ms var(--ease); } .slick-codepace .header nav button > svg { - width: 17px; - height: 17px; + width: 19px; + height: 19px; +} +.slick-codepace .header nav button:hover, +.slick-codepace .header nav button:focus-visible { + transform: translateY(-1px); } .slick-codepace .header nav .nav-active { background: #a0b9eb12; box-shadow: none; color: var(--ink); } +.slick-codepace .header nav .nav-label { + position: absolute; + left: calc(100% + 10px); + top: 50%; + padding: 5px 9px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + color: var(--ink); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.01em; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transform: translate(-4px, -50%); + transition: + opacity 160ms var(--ease), + transform 160ms var(--ease); +} +.slick-codepace .header nav button:hover .nav-label, +.slick-codepace .header nav button:focus-visible .nav-label { + opacity: 1; + transform: translate(0, -50%); +} +.slick-codepace .main { + padding-left: 0; +} +.slick-codepace .field-wait { + margin: 16px 28px 0; + color: var(--muted); + font-size: 14px; +} +.slick-codepace.banned-session { + padding-left: 0; +} .slick-codepace .header-tools { justify-self: end; gap: 8px; @@ -651,11 +714,9 @@ padding-inline: 4%; gap: 20px; } - .slick-codepace .header nav button { - padding-inline: 10px; - } .slick-codepace .main { width: 88%; + padding-left: 20px; } } @@ -663,11 +724,6 @@ .slick-codepace .header { grid-template-columns: minmax(0, 1fr) auto; } - .slick-codepace .header nav { - grid-column: 1 / -1; - grid-row: 2; - justify-self: center; - } } @media (max-width: 760px) { @@ -703,18 +759,36 @@ height: 44px; } .slick-codepace .header nav { - order: 2; - width: 100%; - flex-basis: 100%; + left: 50%; + top: auto; + bottom: 18px; + flex-direction: row; + width: auto; + flex-basis: auto; justify-content: center; - gap: 8px; + transform: translateX(-50%); } .slick-codepace .header nav button { - flex: 1; - justify-content: center; - padding: 10px 6px; - min-height: 44px; - font-size: 12px; + flex: 0 0 auto; + width: 44px; + padding: 0; + } + .slick-codepace .header nav .nav-label { + left: 50%; + top: auto; + bottom: calc(100% + 8px); + transform: translate(-50%, 4px); + } + .slick-codepace .header nav button:hover .nav-label, + .slick-codepace .header nav button:focus-visible .nav-label { + transform: translate(-50%, 0); + } + .slick-codepace { + padding-left: 0; + } + .slick-codepace .main { + padding-left: 0; + padding-bottom: 72px; } .slick-codepace .main { width: 90%; diff --git a/app/workspace.css b/app/workspace.css index 78ab5cd..71303c8 100644 --- a/app/workspace.css +++ b/app/workspace.css @@ -1,43 +1,15 @@ .slick-codepace .header { padding: 20px 4%; gap: 20px; - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) auto; } .slick-codepace .brand { font-size: 24px; letter-spacing: -0.035em; } -.slick-codepace .header nav { - justify-self: center; - gap: 4px; - padding: 4px; - border: 1px solid var(--line); - border-radius: 999px; - background: color-mix(in srgb, var(--surface) 94%, transparent); -} -.slick-codepace .header nav button { - padding: 9px 14px; - min-height: 40px; - font-size: 14px; - border-radius: 999px; -} .slick-codepace .header nav button:hover { background: var(--surface-raised); } -.slick-codepace .header nav .nav-active { - background: var(--surface-hover); -} -@media (max-width: 480px) { - .slick-codepace .header nav button { - flex: 1 1 0; - min-width: 0; - gap: 0; - padding-inline: 4px; - } - .slick-codepace .header nav button > svg { - display: none; - } -} .slick-codepace .header-tools { gap: 6px; } @@ -89,10 +61,6 @@ .slick-codepace .header { grid-template-columns: minmax(0, 1fr) auto; } - .slick-codepace .header nav { - grid-column: 1 / -1; - grid-row: 2; - } } .slick-codepace .header-tools > .icon-button, .slick-codepace .audio-trigger, @@ -589,13 +557,7 @@ height: 44px; } .slick-codepace .header nav { - gap: 4px; - } - .slick-codepace .header nav button { - font-size: 12px; - min-height: 44px; - padding: 10px 4px; - gap: 5px; + gap: 6px; } .slick-codepace .main { margin-top: 18px; diff --git a/lib/fair-play.ts b/lib/fair-play.ts new file mode 100644 index 0000000..b29bc34 --- /dev/null +++ b/lib/fair-play.ts @@ -0,0 +1,37 @@ +import { wpm } from "./scoring.ts"; +import { removeSavedValue } from "./storage.ts"; + +export const MAX_FAIR_WPM = 200; +export const BAN_STORAGE_KEY = "codepace:banned" as const; +export const BAN_MESSAGE = + "Impossible speed. This session was removed for automated typing."; + +const playKeys = [ + "codepace:history", + "codepace:settings:v2", + "codepace:coding:v1", + "codepace:music:v1", + "codepace:room", +] as const; + +export function isImpossibleProgress(correct: number, elapsedMs: number) { + if (correct <= 0 || elapsedMs < 2000) return false; + return wpm(correct, elapsedMs) >= MAX_FAIR_WPM; +} + +export function fairCharacterLimit(elapsedMs: number) { + return Math.max(8, (Math.max(elapsedMs, 0) / 60000) * MAX_FAIR_WPM * 5); +} + +export function isBanned(storage: Pick) { + return storage.getItem(BAN_STORAGE_KEY) === "1"; +} + +export function destroyLocalPlay( + storage: Pick, + session?: Pick, +) { + for (const key of playKeys) removeSavedValue(storage, key); + session?.removeItem("codepace:room"); + storage.setItem(BAN_STORAGE_KEY, "1"); +} diff --git a/lib/room-client.ts b/lib/room-client.ts index 234baa1..cbf8a11 100644 --- a/lib/room-client.ts +++ b/lib/room-client.ts @@ -199,7 +199,7 @@ export async function restoreRoom( if ( signal.aborted || (error instanceof RequestError && - [400, 401, 404].includes(error.status)) + [400, 401, 403, 404].includes(error.status)) ) throw error; onRetry(); diff --git a/lib/room-race.ts b/lib/room-race.ts new file mode 100644 index 0000000..af97927 --- /dev/null +++ b/lib/room-race.ts @@ -0,0 +1,26 @@ +export const PLAYER_CONNECTED_MS = 30_000; + +export function roomRaceFinished({ + startAt, + now, + players, +}: { + startAt: number | null; + now: number; + players: { finishedAt: number | null; seenAt: number }[]; +}) { + if (startAt === null || now < startAt) return false; + const connected = players.filter( + (player) => player.seenAt > now - PLAYER_CONNECTED_MS, + ); + const field = connected.length ? connected : players; + return field.every((player) => player.finishedAt !== null); +} + +export function roomTimedOut( + startAt: number | null, + duration: number, + now: number, +) { + return startAt !== null && now >= startAt + duration * 1000; +} diff --git a/lib/rooms-server.ts b/lib/rooms-server.ts index b11312f..9db20c9 100644 --- a/lib/rooms-server.ts +++ b/lib/rooms-server.ts @@ -17,6 +17,8 @@ import { scheduleProblem, scheduledStartTime, } from "./room-schedule"; +import { BAN_MESSAGE, fairCharacterLimit, isImpossibleProgress } from "./fair-play"; +import { roomRaceFinished, roomTimedOut } from "./room-race"; type StoredPlayer = { id: string; @@ -153,14 +155,19 @@ async function body(request: Request) { } } -async function rateLimit(request: Request, bucket: string, maximum: number) { - const ip = +function clientAddress(request: Request) { + return ( request.headers .get(process.env.VERCEL ? "x-vercel-forwarded-for" : "x-forwarded-for") ?.split(",")[0] - .trim() || "local"; + .trim() || "local" + ); +} +async function rateLimit(request: Request, bucket: string, maximum: number) { const now = Date.now(); - const key = await hash(`${bucket}:${ip}:${Math.floor(now / 3600000)}`); + const key = await hash( + `${bucket}:${clientAddress(request)}:${Math.floor(now / 3600000)}`, + ); const { rows } = await getPool().query<{ count: number }>( "INSERT INTO rate_limits (key, count, expires_at) VALUES ($1, 1, $2) ON CONFLICT(key) DO UPDATE SET count = rate_limits.count + 1 RETURNING count", [key, now + 3600000], @@ -168,6 +175,19 @@ async function rateLimit(request: Request, bucket: string, maximum: number) { if (rows[0].count > maximum) throw new ApiError(429, "Too many attempts. Try again in an hour."); } +async function assertNotBanned(request: Request) { + const { rows } = await getPool().query( + "SELECT 1 FROM rate_limits WHERE key = $1 AND expires_at > $2", + [`ban:${clientAddress(request)}`, Date.now()], + ); + if (rows[0]) throw new ApiError(403, BAN_MESSAGE); +} +async function banClient(request: Request) { + await getPool().query( + "INSERT INTO rate_limits (key, count, expires_at) VALUES ($1, 1, $2) ON CONFLICT(key) DO UPDATE SET expires_at = EXCLUDED.expires_at, count = 1", + [`ban:${clientAddress(request)}`, Date.now() + 315_360_000_000], + ); +} function checkCode(code: string) { if (!/^[A-Z2-9]{6}$/.test(code)) throw new ApiError(404, "Room not found. Check the six-character code."); @@ -227,8 +247,15 @@ function snapshot(room: StoredRoom): RoomSnapshot { const started = room.start_at !== null && now >= room.start_at; const finished = started && - (now >= room.start_at! + room.duration * 1000 || - room.players.every((p) => p.finished_at !== null)); + (roomRaceFinished({ + startAt: room.start_at, + now, + players: room.players.map((player) => ({ + finishedAt: player.finished_at, + seenAt: player.seen_at, + })), + }) || + roomTimedOut(room.start_at, room.duration, now)); return { code: room.code, hostId: room.host_id, @@ -332,6 +359,7 @@ export async function createRoom(request: Request) { content: roomContentSchema.default({ mode: "quotes" }), }) .parse(await body(request)); + await assertNotBanned(request); await rateLimit(request, "create", 30); const now = Date.now(); await getPool().query( @@ -380,6 +408,7 @@ export async function createRoom(request: Request) { throw new ApiError(503, "Could not create a room. Try again."); } export async function readRoom(request: Request, code: string) { + await assertNotBanned(request); let room = await getRoom(code); let player = await member(request, room); if ( @@ -409,6 +438,7 @@ export async function readRoom(request: Request, code: string) { export async function updateRoom(request: Request, code: string) { const input = actionSchema.parse(await body(request)); checkCode(code); + await assertNotBanned(request); if (input.action === "join") await rateLimit(request, "join", 100); const result = await transaction(async (db) => { const room = await getRoom(code, db, true); @@ -567,11 +597,17 @@ export async function updateRoom(request: Request, code: string) { ) throw new ApiError(400, "Invalid typing progress."); const correct = correctPrefix(input.typed, room.passage); - if (correct > Math.max(30, (elapsed / 60000) * 400 * 5)) - throw new ApiError( - 400, - "Progress arrived too quickly. Type the passage without pasting.", + if ( + correct > Math.max(30, fairCharacterLimit(elapsed)) || + isImpossibleProgress(correct, Math.max(0, elapsed)) + ) { + await banClient(request); + await db.query( + "DELETE FROM players WHERE id = $1 AND room_code = $2", + [player.id, code], ); + return { banned: true }; + } const finishedAt = correct === room.passage.length ? Math.min(now, room.start_at! + room.duration * 1000) @@ -633,5 +669,6 @@ export async function updateRoom(request: Request, code: string) { ); return { room: snapshot(await getRoom(code, db)) }; }); + if ("banned" in result && result.banned) throw new ApiError(403, BAN_MESSAGE); return reply(result, input.action === "join" ? 201 : 200); } diff --git a/public/llms.txt b/public/llms.txt index 72a68a9..9aa2cae 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -6,6 +6,8 @@ Practice Python, JavaScript, TypeScript, Java, C++, Go and Rust with built-in sn Code exercises can run to completion or use a 60-second or 180-second timer. Speed and accuracy are tracked. Text modes include words, passages and custom text, with 15-second, 30-second and 60-second races against labelled pace bots. +Friend-room races stay open until every connected player finishes or the selected time runs out. Speeds of 200 wpm or more are treated as automated typing and remove that session. + Friends can create a private room for up to 50 players, including the host. Code rooms support short exercises, custom code and LeetCode problems from the same NeetCode solution library. Every player types the same complete solution in the selected language; code is not executed or graded. Hosts can start immediately or schedule a start up to seven days ahead. Scheduled races start automatically with at least two connected players and wait if fewer are ready; the host tab does not need to stay open. Players can join until the shared countdown begins. The host can change or cancel a schedule before the countdown, or start now. Rooms expire after two hours, or two hours after a scheduled start, and are intended for casual play. No account is needed. Room links and API endpoints are temporary gameplay resources, not separate public content pages. Results and preferences stay in the player's browser. They do not sync between devices or domains. Players can review their history and export their results. There is no global leaderboard. Typing speed treats five correct characters as one word; corrected mistakes still count toward accuracy. diff --git a/tests/coding-ui.test.tsx b/tests/coding-ui.test.tsx index a55626b..14cad31 100644 --- a/tests/coding-ui.test.tsx +++ b/tests/coding-ui.test.tsx @@ -95,6 +95,7 @@ test("Rust Two Sum accepts Enter on blank lines without typing invisible indenta fireEvent.keyDown(input, { key: "Tab" }); assert.equal(input.value.endsWith("\n "), true); assert.equal(document.querySelectorAll(".wrong-char").length, 0); + await tick(120000); fireEvent.change(input, { target: { value: target() } }); await waitFor(() => assert.ok(screen.getByText("Code typed. Nicely done.")), @@ -215,7 +216,7 @@ test("filter real LeetCode solutions, save completion and distinguish timed part fireEvent.click( screen.getByRole("button", { name: /Start coding practice/ }), ); - await tick(3100); + await tick(123100); fireEvent.change( screen.getByRole("textbox", { name: /^Type this passage:/ }), { target: { value: text } }, diff --git a/tests/fair-play-ui.test.tsx b/tests/fair-play-ui.test.tsx new file mode 100644 index 0000000..0115de3 --- /dev/null +++ b/tests/fair-play-ui.test.tsx @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import React from "react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import CodePace from "../app/codepace"; +import { BAN_STORAGE_KEY, MAX_FAIR_WPM } from "../lib/fair-play.ts"; + +test("a stored ban shows the removed session instead of the game", async () => { + localStorage.clear(); + sessionStorage.clear(); + localStorage.setItem(BAN_STORAGE_KEY, "1"); + try { + await act(async () => { + render(); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + assert.ok(screen.getByRole("heading", { name: "This session was removed." })); + assert.ok(screen.getByText(new RegExp(`${MAX_FAIR_WPM} wpm`))); + assert.equal(screen.queryByRole("navigation", { name: "Main navigation" }), null); + } finally { + cleanup(); + localStorage.clear(); + } +}); + +test("dumping a long passage at impossible speed removes the local session", async () => { + const clock = Date.now; + let offset = 0; + Date.now = () => clock() + offset; + localStorage.clear(); + sessionStorage.clear(); + try { + await act(async () => { + render(); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + fireEvent.click(screen.getByRole("button", { name: "Race" })); + fireEvent.click(screen.getByRole("button", { name: /Start race/ })); + offset += 3100; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 130)); + }); + const input = screen.getByRole("textbox", { + name: /^Type this passage:/, + }) as HTMLTextAreaElement; + const text = input.labels![0].textContent!.replace( + "Type this passage: ", + "", + ); + offset += 2000; + fireEvent.change(input, { target: { value: text.slice(0, 200) } }); + assert.ok(screen.getByRole("heading", { name: "This session was removed." })); + assert.equal(localStorage.getItem(BAN_STORAGE_KEY), "1"); + const history = localStorage.getItem("codepace:history"); + assert.ok(history === null || history === "[]"); + } finally { + Date.now = clock; + cleanup(); + localStorage.clear(); + } +}); diff --git a/tests/fair-play.test.ts b/tests/fair-play.test.ts new file mode 100644 index 0000000..9fb4b3b --- /dev/null +++ b/tests/fair-play.test.ts @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + BAN_STORAGE_KEY, + destroyLocalPlay, + fairCharacterLimit, + isBanned, + isImpossibleProgress, + MAX_FAIR_WPM, +} from "../lib/fair-play.ts"; +import { roomRaceFinished } from "../lib/room-race.ts"; + +test("200 wpm or faster is treated as automated typing", () => { + assert.equal(isImpossibleProgress(0, 30000), false); + assert.equal(isImpossibleProgress(50, 15000), false); + assert.equal(isImpossibleProgress(250, 30000), false); + assert.equal(isImpossibleProgress(250, 15000), true); + assert.equal(isImpossibleProgress(50, 500), false); + assert.equal(isImpossibleProgress(120, 500), false); + assert.equal(isImpossibleProgress(58, 2000), true); + assert.equal(MAX_FAIR_WPM, 200); + assert.ok(fairCharacterLimit(60000) >= 1000); +}); + +test("a friend room stays open until every connected racer finishes", () => { + const now = 1_000_000; + assert.equal( + roomRaceFinished({ + startAt: now - 2000, + now, + players: [ + { finishedAt: now - 500, seenAt: now }, + { finishedAt: null, seenAt: now }, + ], + }), + false, + ); + assert.equal( + roomRaceFinished({ + startAt: now - 2000, + now, + players: [ + { finishedAt: now - 500, seenAt: now }, + { finishedAt: now - 200, seenAt: now }, + ], + }), + true, + ); + assert.equal( + roomRaceFinished({ + startAt: now - 2000, + now, + players: [ + { finishedAt: now - 500, seenAt: now }, + { finishedAt: null, seenAt: now - 60_000 }, + ], + }), + true, + ); +}); + +test("a banned session wipes local play data", () => { + const store = new Map([ + ["codepace:history", "[]"], + ["codepace:settings:v2", "{}"], + ["keylane:history", "[]"], + ]); + const storage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }; + const session = { + removeItem: (key: string) => { + store.delete(key); + }, + }; + destroyLocalPlay(storage, session); + assert.equal(isBanned(storage), true); + assert.equal(store.get("codepace:history"), undefined); + assert.equal(store.get("keylane:history"), undefined); + assert.equal(store.get(BAN_STORAGE_KEY), "1"); +}); diff --git a/tests/navigation-ui.test.tsx b/tests/navigation-ui.test.tsx index 42618af..c8b1979 100644 --- a/tests/navigation-ui.test.tsx +++ b/tests/navigation-ui.test.tsx @@ -24,7 +24,7 @@ test("Code is the first navigation item, opens by default and is the home destin assert.deepEqual( within(navigation) .getAllByRole("button") - .map((button) => button.textContent), + .map((button) => button.getAttribute("aria-label")), ["Code", "Race", "Practice", "Friends", "Progress"], ); assert.equal( From 352cc6a81d74908c7175919bcd5e49777a1d73cc Mon Sep 17 00:00:00 2001 From: Psybah Date: Sat, 17 Oct 2026 20:46:52 +0100 Subject: [PATCH 2/4] Add checkered flag component and enhance timing logic in CodePace - Introduced a CheckeredFlag component for visual representation. - Improved time formatting with a new formatClock function. - Refined elapsed time calculations for solo coding sessions. - Adjusted live stats display for better clarity and accuracy. - Updated CSS styles for consistency across various components. --- app/codepace.tsx | 446 ++++++++++++++++++---------------- app/coding.css | 3 +- app/globals.css | 18 +- app/immersive.css | 101 ++++---- app/workspace.css | 25 +- components/checkered-flag.tsx | 31 +++ lib/rooms-server.ts | 21 +- tests/fair-play.test.ts | 14 +- tests/quick-race-ui.test.tsx | 11 +- tests/results-ui.test.tsx | 19 +- tests/rooms-leetcode.mjs | 50 +++- tests/rooms-load.mjs | 37 ++- tests/rooms-schedule.mjs | 16 +- tests/rooms-smoke.mjs | 30 ++- tests/ui.test.tsx | 17 +- 15 files changed, 495 insertions(+), 344 deletions(-) create mode 100644 components/checkered-flag.tsx diff --git a/app/codepace.tsx b/app/codepace.tsx index 1fb8071..f1c739a 100644 --- a/app/codepace.tsx +++ b/app/codepace.tsx @@ -41,6 +41,7 @@ import { type CodeExercise, } from "@/lib/coding"; import { useBackgroundMusic } from "@/components/use-background-music"; +import CheckeredFlag from "@/components/checkered-flag"; import { FlagIcon } from "@solar-icons/react/linear/flag"; import { TargetIcon } from "@solar-icons/react/linear/target"; import { Chart2Icon } from "@solar-icons/react/linear/chart-2"; @@ -113,6 +114,10 @@ const formatDate = (date: string) => month: "short", day: "numeric", }); +const formatClock = (totalSeconds: number) => { + const safe = Math.max(0, Math.floor(totalSeconds)); + return `${String(Math.floor(safe / 60)).padStart(2, "0")}:${String(safe % 60).padStart(2, "0")}`; +}; export default function CodePace({ initialRoom = "", @@ -322,12 +327,7 @@ export default function CodePace({ } if (savedRef.current) return; if (snapshot.phase === "finished") - finish( - Math.min( - snapshot.duration * 1000, - Math.max(0, Date.now() - startRef.current), - ), - ); + finish(Math.max(0, Date.now() - startRef.current)); else changePhase(snapshot.phase === "countdown" ? "countdown" : "running"); }, @@ -539,19 +539,25 @@ export default function CodePace({ return; } if (currentPhase === "countdown") changePhase("running"); - const limit = configRef.current.duration - ? configRef.current.duration * 1000 - : Infinity; - setElapsed(Math.min(time, limit)); + const timedCode = + configRef.current.tab === "code" && + !sessionRef.current && + configRef.current.duration + ? configRef.current.duration * 1000 + : 0; + setElapsed(timedCode ? Math.min(time, timedCode) : Math.max(0, time)); const second = Math.floor(time / 1000); if (second > secondRef.current) { samplesRef.current.push( - stats(engineRef.current, passageRef.current, Math.min(time, limit)) - .wpm, + stats( + engineRef.current, + passageRef.current, + timedCode ? Math.min(time, timedCode) : Math.max(0, time), + ).wpm, ); secondRef.current = second; } - if (time >= limit && !sessionRef.current) finish(limit); + if (timedCode && time >= timedCode) finish(timedCode); }, 100); return () => clearInterval(timer); }, [changePhase, finish]); @@ -639,7 +645,12 @@ export default function CodePace({ function onInput(value: string) { if (phaseRef.current !== "running") return; const time = Date.now() - startRef.current; - if (effectiveDuration && time >= effectiveDuration * 1000) { + if ( + tab === "code" && + !sessionRef.current && + effectiveDuration && + time >= effectiveDuration * 1000 + ) { finish(effectiveDuration * 1000); return; } @@ -809,10 +820,11 @@ export default function CodePace({ const isBusy = phase === "running" || phase === "countdown"; const isFriends = tab === "friends"; const host = !!room && room.hostId === session?.id; - const remaining = effectiveDuration - ? Math.max(0, Math.ceil(effectiveDuration - elapsed / 1000)) - : Math.floor(elapsed / 1000); const isSoloCoding = tab === "code" && !room; + const timedSoloCode = isSoloCoding && !!effectiveDuration; + const clockSeconds = timedSoloCode + ? Math.max(0, Math.ceil(effectiveDuration - elapsed / 1000)) + : Math.max(0, Math.floor(elapsed / 1000)); const isCoding = isSoloCoding || room?.content?.mode === "code"; const activeCodeLanguage = room?.content?.mode === "code" @@ -826,6 +838,7 @@ export default function CodePace({ correct: botProgress(Math.max(20, pace), elapsed, passage.length), finishedMs: (passage.length / (Math.max(20, pace) * 5)) * 60000, wpm: phase === "ready" || phase === "countdown" ? 0 : Math.max(20, pace), + accuracy: 100, bot: true, }), ); @@ -834,6 +847,7 @@ export default function CodePace({ ...p, correct: p.id === session?.id ? live.correct : p.correct, wpm: p.id === session?.id ? live.wpm : p.wpm, + accuracy: p.id === session?.id ? live.accuracy : p.accuracy, bot: false, finishedMs: p.finishedAt && room.startAt @@ -849,6 +863,7 @@ export default function CodePace({ name: settings.name, correct: live.correct, wpm: live.wpm, + accuracy: live.accuracy, bot: false, finishedMs: engine.typed === passage ? elapsed : Infinity, color: "cobalt", @@ -923,6 +938,35 @@ export default function CodePace({ ); + const liveStats = ( +
event.preventDefault()} + > + + {formatClock(clockSeconds)} + Time + + + {live.wpm} + WPM + + + + {live.accuracy} + % + + Accuracy + +
+ ); const typingPanel = (
)} -
-
- - - {String(Math.floor(remaining / 60)).padStart(2, "0")}: - {String(remaining % 60).padStart(2, "0")} - - {effectiveDuration ? "Time" : "Elapsed"} - - - {live.wpm} - WPM - - - - {live.accuracy} - % - - Accuracy - -
-
@@ -1243,6 +1259,176 @@ export default function CodePace({
); + const trackDrawer = ( +
+ + + + {room + ? "Room grid" + : tab === "practice" || isCoding + ? "Your lane" + : "Race grid"} + + + {racers.length}{" "} + {racers.length === 1 ? "racer" : "racers"} + + View + Hide + + + +
+
+

+ {room + ? "Starting grid" + : tab === "practice" || isCoding + ? "Your lane" + : "Meet your competition"} +

+ + + {phase === "running" + ? "Race in progress" + : phase === "countdown" + ? "Starting…" + : phase === "finished" + ? "Finish line" + : room + ? "Waiting for racers" + : tab === "race" + ? "You + 3 pace bots" + : "Just you, no pressure"} + +
+
+ {gridRacers.map((racer) => ( +
+
+ + {racer.id === ownId ? ( + + ) : racer.bot ? ( + + ) : ( + racer.name.charAt(0).toUpperCase() + )} + +
+ + {racer.id === ownId ? "You" : racer.name} + {room?.hostId === racer.id && ( + + )} + + + {racer.bot + ? `${"pace" in racer ? racer.pace : ""} wpm · bot` + : racer.id === ownId + ? "This is your lane" + : (room?.serverNow || 0) - + (room?.players.find( + (p) => p.id === racer.id, + )?.seenAt || 0) > + 10000 + ? "Away" + : "Ready to race"} + +
+
+
+
+
+ + +
+
+ {racer.wpm} + wpm +
+
+ ))} +
+ {!room && tab === "race" && ( +
+ Pick your competition +
+ {[ + [50, "Chill"], + [80, "Quick"], + [110, "Turbo"], + ].map(([pace, label]) => ( + + ))} +
+
+ )} +
+
+ ); + if (banned) { return (
@@ -1807,7 +1993,7 @@ export default function CodePace({ aria-label={ isCoding ? "Code practice duration" - : "Race duration" + : "Passage length" } > {(isCoding ? [0, 60, 180] : [15, 30, 60]).map( @@ -1903,6 +2089,7 @@ export default function CodePace({
)}
+ {trackDrawer} {typingPanel} {roomError && (

@@ -1968,173 +2155,6 @@ export default function CodePace({ content={room.content} /> )} -

- - - - {room - ? "Room grid" - : tab === "practice" || isCoding - ? "Your lane" - : "Race grid"} - - - {racers.length}{" "} - {racers.length === 1 ? "racer" : "racers"} - - View - Hide - - - -
-
-

- {room - ? "Starting grid" - : tab === "practice" || isCoding - ? "Your lane" - : "Meet your competition"} -

- - - {phase === "running" - ? "Race in progress" - : phase === "countdown" - ? "Starting…" - : phase === "finished" - ? "Finish line" - : room - ? "Waiting for racers" - : tab === "race" - ? "You + 3 pace bots" - : "Just you, no pressure"} - -
-
- {gridRacers.map((racer) => ( -
-
- - {racer.id === ownId ? ( - - ) : racer.bot ? ( - - ) : ( - racer.name.charAt(0).toUpperCase() - )} - -
- - {racer.id === ownId ? "You" : racer.name} - {room?.hostId === racer.id && ( - - )} - - - {racer.bot - ? `${"pace" in racer ? racer.pace : ""} wpm · bot` - : racer.id === ownId - ? "This is your lane" - : (room?.serverNow || 0) - - (room?.players.find( - (p) => p.id === racer.id, - )?.seenAt || 0) > - 10000 - ? "Away" - : "Ready to race"} - -
-
-
-
-
- - -
-
- {racer.wpm} - wpm -
-
- ))} -
- {!room && tab === "race" && ( -
- Pick your competition -
- {[ - [50, "Chill"], - [80, "Quick"], - [110, "Turbo"], - ].map(([pace, label]) => ( - - ))} -
-
- )} -
-
{phase === "finished" && result && result.errors > 0 && diff --git a/app/coding.css b/app/coding.css index 0d06e70..accab14 100644 --- a/app/coding.css +++ b/app/coding.css @@ -13,7 +13,6 @@ row-gap: 0; } .code-workspace > .race-toolbar, -.code-workspace > .track-drawer, .code-workspace > .mistake-drill { grid-column: 1 / -1; } @@ -515,7 +514,7 @@ display: none; } .slick-codepace .coding-panel .live-stats b { - font: 500 22px var(--sans); + font: 500 16px var(--sans); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; } diff --git a/app/globals.css b/app/globals.css index c4773b4..0c2d658 100644 --- a/app/globals.css +++ b/app/globals.css @@ -475,7 +475,7 @@ kbd { font-size: 11px; } .live-stats b { - font: 500 20px var(--display); + font: 500 16px var(--display); color: var(--ink); margin-right: 3px; font-variant-numeric: tabular-nums; @@ -865,10 +865,12 @@ kbd { .finish-flag { position: absolute; right: 4px; - top: 6px; - width: 16px; - height: 16px; - color: var(--muted); + top: 12px; + width: 22px; + height: 15px; + color: unset; + filter: drop-shadow(0 1px 1px #0006); + pointer-events: none; } .lane-speed { text-align: right; @@ -1993,10 +1995,10 @@ td { height: 27px; } .finish-flag { - height: 13px; - width: 13px; + height: 12px; + width: 18px; right: 3px; - top: 6px; + top: 14px; } .pace-select { font-size: 10px; diff --git a/app/immersive.css b/app/immersive.css index c3f5099..7bddca6 100644 --- a/app/immersive.css +++ b/app/immersive.css @@ -62,10 +62,12 @@ display: flex; flex-direction: column; justify-self: auto; - gap: 6px; - padding: 8px 6px; + align-items: center; + gap: 8px; + padding: 10px 8px; border: 1px solid var(--line); - border-radius: 22px; + border-radius: 999px; + overflow: visible; background: color-mix(in srgb, var(--surface) 82%, transparent); backdrop-filter: blur(16px); transform: translateY(-50%); @@ -80,22 +82,17 @@ min-height: 44px; padding: 0; justify-content: center; - border-radius: 14px; + border-radius: 999px; font-size: 13px; font-weight: 450; transition: background 180ms var(--ease), - color 180ms var(--ease), - transform 180ms var(--ease); + color 180ms var(--ease); } .slick-codepace .header nav button > svg { width: 19px; height: 19px; } -.slick-codepace .header nav button:hover, -.slick-codepace .header nav button:focus-visible { - transform: translateY(-1px); -} .slick-codepace .header nav .nav-active { background: #a0b9eb12; box-shadow: none; @@ -245,7 +242,7 @@ border-radius: 0; box-shadow: none; overflow: visible; - padding-top: clamp(36px, 6svh, 76px); + padding-top: clamp(16px, 3svh, 28px); } .slick-codepace .arena:focus-within, .slick-codepace .arena:has(.has-error) { @@ -257,7 +254,7 @@ } .slick-codepace .live-stats { gap: 0; - width: min(100%, 680px); + width: min(100%, 360px); justify-content: center; } .slick-codepace .live-stats > span { @@ -265,7 +262,7 @@ display: flex; align-items: center; flex-direction: column; - gap: 8px; + gap: 2px; min-width: 0; position: relative; } @@ -273,32 +270,32 @@ content: ""; position: absolute; left: 0; - top: 10px; - height: 38px; + top: 3px; + height: 18px; width: 1px; background: var(--line); } .slick-codepace .live-stats b { - font: 400 clamp(30px, 3.1vw, 40px)/1.1 var(--mono); + font: 400 16px/1.1 var(--mono); margin: 0; - letter-spacing: -0.06em; + letter-spacing: -0.04em; color: var(--ink); } .slick-codepace .live-stats > span:last-child b { color: var(--ink); } .slick-codepace .live-stats small { - font-size: 0.74em; + font-size: 0.7em; } .slick-codepace .live-stats > span > span { - font-size: 11px; + font-size: 8px; color: var(--muted); - letter-spacing: 0.15em; + letter-spacing: 0.12em; text-transform: uppercase; } .slick-codepace .play-hud { padding: 0; - margin: clamp(40px, 7svh, 80px) 0 22px; + margin: 18px 0 14px; gap: 16px; min-height: 24px; } @@ -474,9 +471,10 @@ font-size: 13px; } .slick-codepace .track-drawer { - margin-top: auto; + margin-top: 0; + margin-bottom: 8px; width: 100%; - padding-top: 16px; + padding-top: 0; } .slick-codepace .track-drawer > summary { list-style: none; @@ -484,12 +482,12 @@ display: flex; align-items: center; justify-content: space-between; - gap: 16px; - min-height: 48px; - padding: 12px 16px; - font-size: 12px; + gap: 10px; + min-height: 28px; + padding: 4px 10px; + font-size: 11px; color: var(--muted); - border-radius: 12px; + border-radius: 8px; background: #a0b9eb0a; } .slick-codepace .track-drawer > summary::-webkit-details-marker { @@ -501,8 +499,8 @@ gap: 10px; } .slick-codepace .track-drawer > summary svg { - width: 18px; - height: 18px; + width: 14px; + height: 14px; color: var(--accent); } .slick-codepace .track-drawer > summary:focus-visible { @@ -511,8 +509,8 @@ } .slick-codepace .grid-toggle { color: var(--ink); - margin-left: 12px; - font-size: 11px; + margin-left: 8px; + font-size: 10px; } .slick-codepace .grid-hide-label, .slick-codepace .track-drawer[open] .grid-show-label { @@ -522,18 +520,21 @@ display: inline; } .slick-codepace .track-panel { - margin-top: 8px; - padding: 20px 24px; - border-radius: 18px; + margin-top: 4px; + padding: 8px 12px 10px; + border-radius: 12px; background: var(--surface); box-shadow: none; } +.slick-codepace .track-heading { + margin-bottom: 4px; +} .slick-codepace .track-heading h2 { font-weight: 500; - font-size: 14px; + font-size: 12px; } .slick-codepace .track-status { - font-size: 11px; + font-size: 9px; } .slick-codepace .lane { grid-template-columns: 150px minmax(68px, 1fr) 65px; @@ -819,21 +820,21 @@ font-size: 12px; } .slick-codepace .arena { - padding-top: 32px; + padding-top: 16px; } .slick-codepace .live-stats { - max-width: 480px; + max-width: 320px; } .slick-codepace .live-stats b { - font-size: 30px; + font-size: 15px; } .slick-codepace .live-stats > span > span { - font-size: 9px; - letter-spacing: 0.12em; + font-size: 8px; + letter-spacing: 0.1em; } .slick-codepace .live-stats > span + span::before { - height: 30px; - top: 8px; + height: 16px; + top: 2px; } .slick-codepace .play-hud { margin-top: 38px; @@ -892,14 +893,14 @@ font-size: 9px; } .slick-codepace .track-drawer { - padding-top: 12px; + padding-top: 4px; } .slick-codepace .track-drawer > summary { - min-height: 48px; - padding-inline: 12px; + min-height: 32px; + padding-inline: 10px; } .slick-codepace .track-panel { - padding: 20px 16px; + padding: 8px 12px; } .slick-codepace .track-status { font-size: 9px; @@ -970,10 +971,10 @@ margin-top: 16px; } .slick-codepace .arena { - padding-top: 32px; + padding-top: 16px; } .slick-codepace .play-hud { - margin-top: 36px; + margin-top: 16px; } .slick-codepace .typing-surface { height: 154px; diff --git a/app/workspace.css b/app/workspace.css index 71303c8..0a2e698 100644 --- a/app/workspace.css +++ b/app/workspace.css @@ -243,23 +243,23 @@ background: var(--surface); } .slick-codepace .live-stats { - width: min(100%, 540px); + width: min(100%, 360px); } .slick-codepace .live-stats b { - font-size: 34px; + font-size: 16px; font-weight: 400; letter-spacing: -0.04em; } .slick-codepace .live-stats > span { - gap: 5px; + gap: 2px; } .slick-codepace .live-stats > span > span { - font-size: 12px; + font-size: 8px; letter-spacing: 0; } .slick-codepace .live-stats > span + span::before { - top: 5px; - height: 32px; + top: 3px; + height: 18px; } .slick-codepace .play-hud { margin: 28px 0 18px; @@ -312,14 +312,15 @@ box-shadow: 0 3px 1px #b8912e, 0 5px 9px #0003; } .slick-codepace .track-drawer { - padding-top: 18px; + padding-top: 0; margin-top: 0; + margin-bottom: 12px; } .slick-codepace .track-drawer > summary { border: 0; background: var(--surface); - border-radius: 20px; - font-size: 13px; + border-radius: 10px; + font-size: 11px; } .slick-codepace .track-drawer[open] > summary { border-radius: 20px 20px 0 0; @@ -327,8 +328,8 @@ .slick-codepace .track-panel { margin-top: 0; border: 0; - border-radius: 0 0 20px 20px; - padding: 20px 24px; + border-radius: 0 0 12px 12px; + padding: 8px 12px 10px; } .slick-codepace .lane { min-height: 56px; @@ -573,7 +574,7 @@ padding: 22px 18px 0; } .slick-codepace .live-stats b { - font-size: 28px; + font-size: 15px; } .slick-codepace .typing-surface { height: 205px; diff --git a/components/checkered-flag.tsx b/components/checkered-flag.tsx new file mode 100644 index 0000000..7a6201b --- /dev/null +++ b/components/checkered-flag.tsx @@ -0,0 +1,31 @@ +export default function CheckeredFlag({ + className, +}: { + className?: string; +}) { + return ( + + ); +} diff --git a/lib/rooms-server.ts b/lib/rooms-server.ts index 9db20c9..d3e08cb 100644 --- a/lib/rooms-server.ts +++ b/lib/rooms-server.ts @@ -18,7 +18,7 @@ import { scheduledStartTime, } from "./room-schedule"; import { BAN_MESSAGE, fairCharacterLimit, isImpossibleProgress } from "./fair-play"; -import { roomRaceFinished, roomTimedOut } from "./room-race"; +import { roomRaceFinished } from "./room-race"; type StoredPlayer = { id: string; @@ -247,15 +247,14 @@ function snapshot(room: StoredRoom): RoomSnapshot { const started = room.start_at !== null && now >= room.start_at; const finished = started && - (roomRaceFinished({ + roomRaceFinished({ startAt: room.start_at, now, players: room.players.map((player) => ({ finishedAt: player.finished_at, seenAt: player.seen_at, })), - }) || - roomTimedOut(room.start_at, room.duration, now)); + }); return { code: room.code, hostId: room.host_id, @@ -289,13 +288,7 @@ function snapshot(room: StoredRoom): RoomSnapshot { wpm: started ? wpm( p.correct, - Math.max( - 0, - Math.min( - p.finished_at ?? now, - room.start_at! + room.duration * 1000, - ) - room.start_at!, - ), + Math.max(0, (p.finished_at ?? now) - room.start_at!), ) : 0, accuracy: accuracy(p.strokes, p.errors), @@ -584,7 +577,7 @@ export async function updateRoom(request: Request, code: string) { const elapsed = room.start_at === null ? -1 : now - room.start_at; if ( elapsed >= 0 && - elapsed <= room.duration * 1000 + 2000 && + now < room.expires_at && player.finished_at === null && input.seq > player.seq ) { @@ -609,9 +602,7 @@ export async function updateRoom(request: Request, code: string) { return { banned: true }; } const finishedAt = - correct === room.passage.length - ? Math.min(now, room.start_at! + room.duration * 1000) - : null; + correct === room.passage.length ? now : null; if ( input.typed !== player.typed || input.strokes !== player.strokes || diff --git a/tests/fair-play.test.ts b/tests/fair-play.test.ts index 9fb4b3b..87de7bf 100644 --- a/tests/fair-play.test.ts +++ b/tests/fair-play.test.ts @@ -8,7 +8,7 @@ import { isImpossibleProgress, MAX_FAIR_WPM, } from "../lib/fair-play.ts"; -import { roomRaceFinished } from "../lib/room-race.ts"; +import { roomRaceFinished, roomTimedOut } from "../lib/room-race.ts"; test("200 wpm or faster is treated as automated typing", () => { assert.equal(isImpossibleProgress(0, 30000), false); @@ -57,6 +57,18 @@ test("a friend room stays open until every connected racer finishes", () => { }), true, ); + assert.equal( + roomRaceFinished({ + startAt: now - 45_000, + now, + players: [ + { finishedAt: now - 1000, seenAt: now }, + { finishedAt: null, seenAt: now }, + ], + }), + false, + ); + assert.equal(roomTimedOut(now - 45_000, 30, now), true); }); test("a banned session wipes local play data", () => { diff --git a/tests/quick-race-ui.test.tsx b/tests/quick-race-ui.test.tsx index 7209902..ef222b8 100644 --- a/tests/quick-race-ui.test.tsx +++ b/tests/quick-race-ui.test.tsx @@ -9,6 +9,7 @@ import { screen, } from "@testing-library/react"; import CodePace from "../app/codepace"; +import { wpm } from "../lib/scoring"; test("a 15-second race records clean-key streaks without the daily sprint", async () => { const clock = Date.now; @@ -48,11 +49,17 @@ test("a 15-second race records clean-key streaks without the daily sprint", asyn fireEvent.change(input, { target: { value: text.slice(0, 50) } }); assert.equal(document.querySelector(".combo-chip b")?.textContent, "50"); await tick(15000); + assert.deepEqual( + JSON.parse(localStorage.getItem("codepace:history") || "[]"), + [], + ); + fireEvent.change(input, { target: { value: text } }); const history = JSON.parse(localStorage.getItem("codepace:history")!); assert.equal(history[0].mode, "Race"); - assert.equal(history[0].wpm, 40); + assert.equal(history[0].correct, text.length); + assert.equal(history[0].wpm, wpm(text.length, history[0].duration * 1000)); assert.equal(history[0].accuracy, 100); - assert.equal(history[0].bestStreak, 50); + assert.equal(history[0].bestStreak, text.length); fireEvent.click(screen.getByRole("button", { name: /Race again/ })); const next = screen.getByRole("textbox", { name: /^Type this passage:/, diff --git a/tests/results-ui.test.tsx b/tests/results-ui.test.tsx index f037d47..026eeb2 100644 --- a/tests/results-ui.test.tsx +++ b/tests/results-ui.test.tsx @@ -27,7 +27,7 @@ const result: RaceResult = { missed: { a: 2 }, }; -test("an untouched timed round shows a retry state without scores, placing, reward or a zero chart", async () => { +test("an untouched race stays open after the listed time until the passage is finished", async () => { const clock = Date.now; let offset = 0; Date.now = () => clock() + offset; @@ -49,22 +49,15 @@ test("an untouched timed round shows a retry state without scores, placing, rewa fireEvent.click(screen.getByRole("button", { name: /Start race/ })); await tick(3100); await tick(15000); - const summary = screen.getByRole("region", { name: "Your result" }); - assert.ok( - within(summary).getByRole("heading", { name: "No typing recorded" }), - ); - assert.equal(summary.querySelector(".result-scoreboard"), null); - assert.equal(summary.querySelector(".result-context"), null); - assert.equal(summary.querySelector(".result-award"), null); - assert.equal(within(summary).queryByRole("img"), null); - assert.ok(within(summary).getByText(/No result was saved/)); + const input = screen.getByRole("textbox", { + name: /^Type this passage:/, + }) as HTMLTextAreaElement; + assert.equal(input.disabled, false); + assert.equal(screen.queryByRole("region", { name: "Your result" }), null); assert.deepEqual( JSON.parse(localStorage.getItem("codepace:history") || "[]"), [], ); - fireEvent.click(screen.getByRole("button", { name: /Race again/ })); - assert.equal(screen.queryByRole("region", { name: "Your result" }), null); - assert.ok(document.querySelector(".countdown")); } finally { Date.now = clock; cleanup(); diff --git a/tests/rooms-leetcode.mjs b/tests/rooms-leetcode.mjs index a03e270..7ed0a59 100644 --- a/tests/rooms-leetcode.mjs +++ b/tests/rooms-leetcode.mjs @@ -188,21 +188,57 @@ try { 3, ); await sleep(Math.max(0, started.startAt + 15100 - Date.now())); - assert.equal((await request(guest)).room.phase, "finished"); - const next = await request(host, { action: "rematch", round: 1 }); + assert.equal((await request(guest)).room.phase, "running"); + await request(host, { action: "rematch", round: 1 }, 409); + const short = await request( + null, + { + name: "Short host", + duration: 15, + content: content("python"), + }, + 201, + ); + const shortGuest = ( + await request(short.session, { action: "join", name: "Short guest" }, 201) + ).session; + const shortStart = await request(short.session, { + action: "start", + round: 1, + }); + await sleep(Math.max(0, shortStart.room.startAt + 19000 - Date.now())); + const shortPassage = shortStart.room.passage; + await request(short.session, { + action: "sync", + round: 1, + seq: 1, + typed: shortPassage, + strokes: shortPassage.length, + errors: 0, + }); + const closed = await request(shortGuest, { + action: "sync", + round: 1, + seq: 1, + typed: shortPassage, + strokes: shortPassage.length, + errors: 0, + }); + assert.equal(closed.room.phase, "finished"); + const next = await request(short.session, { action: "rematch", round: 1 }); assert.equal(next.room.round, 2); - assert.equal(next.room.passage, fullCode); - assert.deepEqual(next.room.content, content("javascript", longestId)); + assert.equal(next.room.passage, shortPassage); + assert.deepEqual(next.room.content, content("python")); assert.equal(next.room.scheduledAt, null); - assert.equal((await request(host)).self.typed, ""); - const ordinary = await request(host, { + assert.equal((await request(short.session)).self.typed, ""); + const ordinary = await request(short.session, { action: "configure", round: 2, duration: 15, content: { mode: "code", language: "python", snippet: 1 }, }); assert.equal(ordinary.room.content.problemId, undefined); - assert.notEqual(ordinary.room.passage, fullCode); + assert.notEqual(ordinary.room.passage, shortPassage); console.log( `Passed ${checks} HTTP checks across seven languages, scheduled start, long input, shared progress and rematch.`, ); diff --git a/tests/rooms-load.mjs b/tests/rooms-load.mjs index c203bda..ea39d46 100644 --- a/tests/rooms-load.mjs +++ b/tests/rooms-load.mjs @@ -239,19 +239,32 @@ try { } }), ); - await sleep( - Math.max( - 100, - started.room.startAt + duration * 1000 - Date.now() - clockOffset + 150, - ), + await all( + sessions.map(async (session) => { + const previous = progress.get(session.id); + const typed = created.room.passage; + const result = await request( + roomPath, + { + action: "sync", + round: 1, + seq: (previous?.seq || 0) + 1, + typed, + strokes: typed.length, + errors: 0, + }, + session.token, + ); + checkRoom(result.room, started); + progress.set(session.id, { + typed, + seq: (previous?.seq || 0) + 1, + correct: typed.length, + updates: (previous?.updates || 0) + 1, + }); + }), ); - const finishDeadline = performance.now() + 5000; - let finished; - do { - finished = await request(roomPath, undefined, host.token); - if (finished.room.phase === "finished") break; - await sleep(50); - } while (performance.now() < finishDeadline); + const finished = await request(roomPath, undefined, host.token); assert.equal(finished.room.phase, "finished"); const restored = await all( sessions.map((session) => request(roomPath, undefined, session.token)), diff --git a/tests/rooms-schedule.mjs b/tests/rooms-schedule.mjs index 7288c37..5f33aa7 100644 --- a/tests/rooms-schedule.mjs +++ b/tests/rooms-schedule.mjs @@ -107,8 +107,20 @@ try { typing.room.players.find((player) => player.id === guest.id).correct, 3, ); - await sleep(Math.max(0, typing.room.startAt + 15100 - typing.room.serverNow)); - assert.equal((await request(guest)).room.phase, "finished"); + await sleep(Math.max(0, typing.room.startAt + 10000 - typing.room.serverNow)); + const finish = (session, seq) => + request(session, { + action: "sync", + round: 1, + seq, + typed: room.passage, + strokes: room.passage.length, + errors: 0, + }); + await finish(guest, 2); + await finish(peer, 1); + await finish(late, 1); + assert.equal((await finish(host, 1)).room.phase, "finished"); const rematch = await request(host, { action: "rematch", round: 1 }); assert.equal(rematch.room.phase, "lobby"); assert.equal(rematch.room.scheduledAt, null); diff --git a/tests/rooms-smoke.mjs b/tests/rooms-smoke.mjs index 6583e8d..3acf79d 100644 --- a/tests/rooms-smoke.mjs +++ b/tests/rooms-smoke.mjs @@ -243,10 +243,34 @@ try { ); assert.equal(expanded.room.players.length, 9); checks++; - await sleep(Math.max(0, started.room.startAt + 15050 - Date.now())); - const done = await request(`/api/rooms/${code}`, undefined, friend.session); + const passage = host.room.passage; + await sleep(Math.max(0, started.room.startAt + 10000 - Date.now())); + await request( + `/api/rooms/${code}`, + { + action: "sync", + round: 1, + seq: 7, + typed: passage, + strokes: Math.max(19, passage.length), + errors: 1, + }, + host.session, + ); + const done = await request( + `/api/rooms/${code}`, + { + action: "sync", + round: 1, + seq: 1, + typed: passage, + strokes: passage.length, + errors: 0, + }, + friend.session, + ); assert.equal(done.room.phase, "finished"); - assert.equal(done.room.players.find((p) => p.id === host.session.id).wpm, 14); + assert.ok(done.room.players.find((p) => p.id === host.session.id).wpm > 0); const rematches = await Promise.all( [0, 1].map(async () => { const response = await fetch(`${origin}/api/rooms/${code}`, { diff --git a/tests/ui.test.tsx b/tests/ui.test.tsx index e0cd5cd..25e7beb 100644 --- a/tests/ui.test.tsx +++ b/tests/ui.test.tsx @@ -11,6 +11,7 @@ import { within, } from "@testing-library/react"; import CodePace from "../app/codepace"; +import { accuracy, wpm } from "../lib/scoring"; const testOrigin = process.env.CODEPACE_TEST_URL || "http://127.0.0.1:5189"; const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -60,13 +61,19 @@ test("complete a corrected race, save it, inspect progress and reload it", async assert.equal(pasted.defaultPrevented, true); assert.equal(input.value, text.slice(0, 30)); await tick(15000); + assert.deepEqual( + JSON.parse(localStorage.getItem("codepace:history") || "[]"), + [], + ); + fireEvent.change(input, { target: { value: text } }); const saved = JSON.parse(localStorage.getItem("codepace:history")!); assert.equal(saved.length, 1); - assert.equal(saved[0].wpm, 24); - assert.equal(saved[0].accuracy, 96.8); + assert.equal(saved[0].correct, text.length); + assert.equal(saved[0].wpm, wpm(text.length, saved[0].duration * 1000)); + assert.equal(saved[0].accuracy, accuracy(text.length + 1, 1)); fireEvent.click(screen.getByRole("button", { name: "Progress" })); assert.ok(screen.getByRole("table")); - assert.ok(screen.getByRole("cell", { name: "24 wpm" })); + assert.ok(screen.getByRole("cell", { name: `${saved[0].wpm} wpm` })); cleanup(); await act(async () => { render(); @@ -74,7 +81,9 @@ test("complete a corrected race, save it, inspect progress and reload it", async }); fireEvent.click(screen.getByRole("button", { name: "Race" })); assert.ok( - document.querySelector(".personal-best")?.textContent?.includes("24"), + document + .querySelector(".personal-best") + ?.textContent?.includes(String(saved[0].wpm)), ); fireEvent.click( screen.getByRole("button", { name: "Open profile and settings" }), From dbdb68561c3135afc9ce54f2341f2ca4351ac31b Mon Sep 17 00:00:00 2001 From: Psybah Date: Sat, 17 Oct 2026 20:49:28 +0100 Subject: [PATCH 3/4] Refactor CodePace layout and enhance live stats display - Added live stats to the CodePace component for improved real-time feedback. - Refactored the track drawer summary layout from flex to grid for better alignment. - Adjusted CSS styles for summary elements to enhance visual clarity and spacing. --- app/codepace.tsx | 26 +------------------------- app/immersive.css | 28 +++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/app/codepace.tsx b/app/codepace.tsx index f1c739a..a8895e7 100644 --- a/app/codepace.tsx +++ b/app/codepace.tsx @@ -1270,6 +1270,7 @@ export default function CodePace({ ? "Your lane" : "Race grid"} + {liveStats} {racers.length}{" "} {racers.length === 1 ? "racer" : "racers"} @@ -1280,31 +1281,6 @@ export default function CodePace({
-
-

- {room - ? "Starting grid" - : tab === "practice" || isCoding - ? "Your lane" - : "Meet your competition"} -

- - - {phase === "running" - ? "Race in progress" - : phase === "countdown" - ? "Starting…" - : phase === "finished" - ? "Finish line" - : room - ? "Waiting for racers" - : tab === "race" - ? "You + 3 pace bots" - : "Just you, no pressure"} - -
summary { list-style: none; cursor: pointer; - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; - justify-content: space-between; - gap: 10px; + gap: 12px; min-height: 28px; padding: 4px 10px; font-size: 11px; @@ -498,6 +498,28 @@ align-items: center; gap: 10px; } +.slick-codepace .track-drawer > summary > span:last-of-type { + justify-self: end; +} +.slick-codepace .track-drawer > summary > .live-stats { + width: auto; + gap: 16px; +} +.slick-codepace .track-drawer > summary > .live-stats > span { + flex: 0 0 auto; + flex-direction: row; + align-items: baseline; + gap: 5px; +} +.slick-codepace .track-drawer > summary > .live-stats > span + span::before { + display: none; +} +.slick-codepace .track-drawer > summary > .live-stats b { + font-size: 13px; +} +.slick-codepace .track-drawer > summary > .live-stats > span > span { + font-size: 8px; +} .slick-codepace .track-drawer > summary svg { width: 14px; height: 14px; From a48ebe0ef32e73a042c2329d1ee83c3c6fbd42e2 Mon Sep 17 00:00:00 2001 From: Psybah Date: Sat, 17 Oct 2026 20:51:35 +0100 Subject: [PATCH 4/4] Enhance live stats styling in CodePace component - Increased gap sizes for better spacing in live stats display. - Adjusted font sizes for improved readability of live stats elements. --- app/immersive.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/immersive.css b/app/immersive.css index d1c9dbc..48b60c8 100644 --- a/app/immersive.css +++ b/app/immersive.css @@ -503,22 +503,22 @@ } .slick-codepace .track-drawer > summary > .live-stats { width: auto; - gap: 16px; + gap: 20px; } .slick-codepace .track-drawer > summary > .live-stats > span { flex: 0 0 auto; flex-direction: row; align-items: baseline; - gap: 5px; + gap: 6px; } .slick-codepace .track-drawer > summary > .live-stats > span + span::before { display: none; } .slick-codepace .track-drawer > summary > .live-stats b { - font-size: 13px; + font-size: 16px; } .slick-codepace .track-drawer > summary > .live-stats > span > span { - font-size: 8px; + font-size: 10px; } .slick-codepace .track-drawer > summary svg { width: 14px;