diff --git a/app/codepace.tsx b/app/codepace.tsx index 788b4e2..a8895e7 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"; @@ -82,6 +83,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"; @@ -106,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 = "", @@ -159,6 +171,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 +216,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 +247,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 +294,7 @@ export default function CodePace({ : [completed, ...previous].slice(0, 100), ); }, - [changePhase], + [changePhase, punishCheat], ); const acceptRoom = useCallback( (snapshot: RoomSnapshot, sentAt: number) => { @@ -298,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"); }, @@ -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; @@ -501,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) finish(limit); + if (timedCode && time >= timedCode) finish(timedCode); }, 100); return () => clearInterval(timer); }, [changePhase, finish]); @@ -601,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; } @@ -627,7 +676,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 +735,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); } @@ -754,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" @@ -771,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, }), ); @@ -779,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 @@ -794,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", @@ -868,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 - -
-
@@ -1015,6 +1086,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 +1137,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 && ( + ))} + + + )} +
+ + ); + + if (banned) { + return ( +
+
+ + + codepace. + +
+
+
+
+

This session was removed.

+

+ Speeds of {MAX_FAIR_WPM} wpm or more are treated as automated typing. + Local results were cleared and this browser cannot join rooms. +

+
+
+
+
+ ); + } + return (
navigate(value)} aria-current={tab === value ? "page" : undefined} + aria-label={label} + title={label} > - {label} + {label} ))} @@ -1704,7 +1969,7 @@ export default function CodePace({ aria-label={ isCoding ? "Code practice duration" - : "Race duration" + : "Passage length" } > {(isCoding ? [0, 60, 180] : [15, 30, 60]).map( @@ -1800,6 +2065,7 @@ export default function CodePace({
)}
+ {trackDrawer} {typingPanel} {roomError && (

@@ -1865,173 +2131,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 be1def9..48b60c8 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,85 @@ height: 28px; } .slick-codepace .header nav { - background: transparent; - padding: 0; - gap: 6px; + position: fixed; + left: 18px; + top: 50%; + z-index: 20; + display: flex; + flex-direction: column; + justify-self: auto; + align-items: center; + gap: 8px; + padding: 10px 8px; + border: 1px solid var(--line); + border-radius: 999px; + overflow: visible; + 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: 999px; font-size: 13px; - padding: 9px 14px; font-weight: 450; + transition: + background 180ms var(--ease), + color 180ms var(--ease); } .slick-codepace .header nav button > svg { - width: 17px; - height: 17px; + width: 19px; + height: 19px; } .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; @@ -182,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) { @@ -194,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 { @@ -202,7 +262,7 @@ display: flex; align-items: center; flex-direction: column; - gap: 8px; + gap: 2px; min-width: 0; position: relative; } @@ -210,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; } @@ -411,22 +471,23 @@ 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; cursor: pointer; - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; - justify-content: space-between; - gap: 16px; - min-height: 48px; - padding: 12px 16px; - font-size: 12px; + gap: 12px; + 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 { @@ -437,9 +498,31 @@ 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: 20px; +} +.slick-codepace .track-drawer > summary > .live-stats > span { + flex: 0 0 auto; + flex-direction: row; + align-items: baseline; + gap: 6px; +} +.slick-codepace .track-drawer > summary > .live-stats > span + span::before { + display: none; +} +.slick-codepace .track-drawer > summary > .live-stats b { + font-size: 16px; +} +.slick-codepace .track-drawer > summary > .live-stats > span > span { + font-size: 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 { @@ -448,8 +531,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 { @@ -459,18 +542,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; @@ -651,11 +737,9 @@ padding-inline: 4%; gap: 20px; } - .slick-codepace .header nav button { - padding-inline: 10px; - } .slick-codepace .main { width: 88%; + padding-left: 20px; } } @@ -663,11 +747,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 +782,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%; @@ -745,21 +842,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; @@ -818,14 +915,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; @@ -896,10 +993,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 78ab5cd..0a2e698 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, @@ -275,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; @@ -344,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; @@ -359,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; @@ -589,13 +558,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; @@ -611,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/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..d3e08cb 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 } 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,14 @@ 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, + })), + }); return { code: room.code, hostId: room.host_id, @@ -262,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), @@ -332,6 +352,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 +401,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 +431,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); @@ -554,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 ) { @@ -567,15 +590,19 @@ 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) - : null; + correct === room.passage.length ? now : null; if ( input.typed !== player.typed || input.strokes !== player.strokes || @@ -633,5 +660,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..87de7bf --- /dev/null +++ b/tests/fair-play.test.ts @@ -0,0 +1,99 @@ +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, roomTimedOut } 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, + ); + 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", () => { + 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( 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" }),