Skip to content

Commit 1ebcae1

Browse files
TMHSDigitalclaude
andcommitted
Detect challenge links and bests recorded on different passage text
Challenge links (now version 2) and bests store a hash of the run's first passage. When the seed no longer gives that passage after a change to texts.js, a challenge is still played but without a ghost or winner, and a best is not replayed until a new best is set. Version 1 links and bests without a hash are played without the check. Closes #43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b7d10d4 commit 1ebcae1

3 files changed

Lines changed: 191 additions & 28 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ Serve the folder with `npm run serve` (see Development) or any static file serve
3333
- **Stop on error**: with this setting on, a wrong key is counted as a mistake but does not move the caret, so each mistake has to be fixed with the right key before you can continue.
3434
- **Caps Lock warning**: a notice below the text while Caps Lock is on.
3535
- **Hard Mode**: blurs the characters you have already typed, so you cannot see your mistakes. The text ahead stays readable.
36-
- **Ghost Mode**: records the timing of your best run and replays its position as a second cursor while you type. Bests record the seed their passages were drawn from, so with Ghost Mode on you type the same passages as your best run. In Time mode, when the ghost is on a later or earlier passage than you, its cursor waits at the end or start of yours with an arrow pointing the way. A line below the text shows how many characters ahead of or behind the ghost you are, or says that no ghost exists yet for the current settings. In Weak Keys, a ghost run uses the letters the best run practiced, and the note above the text says so.
36+
- **Ghost Mode**: records the timing of your best run and replays its position as a second cursor while you type. Bests record the seed their passages were drawn from, so with Ghost Mode on you type the same passages as your best run. In Time mode, when the ghost is on a later or earlier passage than you, its cursor waits at the end or start of yours with an arrow pointing the way. A line below the text shows how many characters ahead of or behind the ghost you are, or says that no ghost exists yet for the current settings. In Weak Keys, a ghost run uses the letters the best run practiced, and the note above the text says so. Bests also record a hash of their first passage. If a change to `texts.js` means the seed no longer gives that passage, Ghost Mode says so and does not replay the best until a new best is set.
3737
- **Results**: a modal with your WPM and accuracy; raw WPM (all typed characters, right or wrong); correct and wrong character counts; elapsed time; whether the run set a new best for its settings, and the previous best; the keys you missed most in this run; a line chart of WPM at the end of each second of the run; a line chart of your last ten runs at the current settings once you have two or more; a Share button; and, once you have missed letters often enough, a button that starts a Weak Keys run. The shared text has the WPM, accuracy, settings, and a link to the site. On touch devices it opens the system share sheet; elsewhere it copies to the clipboard.
38-
- **Challenge links**: after a run of a second or longer, Copy challenge link puts a URL on the clipboard, or on touch devices opens the system share sheet. If copying fails, the link is shown, selected, in the result dialog. The URL's hash holds the run's settings, including Stop on error and Skip indentation, the seed of its passages, its WPM, and its keystroke timings, compressed. Opening it, also in a tab that already has the page open, starts a run on the same passages with that run as the ghost, and the result dialog says who was faster. The receiver's saved settings are not changed; after the run, Play Again goes back to them. Links made before a change to the passages in `texts.js` may show different text.
38+
- **Challenge links**: after a run of a second or longer, Copy challenge link puts a URL on the clipboard, or on touch devices opens the system share sheet. If copying fails, the link is shown, selected, in the result dialog. The URL's hash holds the run's settings, including Stop on error and Skip indentation, the seed of its passages, its WPM, and its keystroke timings, compressed. Opening it, also in a tab that already has the page open, starts a run on the same passages with that run as the ghost, and the result dialog says who was faster. The receiver's saved settings are not changed; after the run, Play Again goes back to them. Links also carry a hash of the run's first passage. If a change to `texts.js` means the seed no longer gives that passage, the run is still played, but without a ghost, and the result dialog does not say who won. Links made before this hash was added are played without the check.
3939
- **Stats dialog**: opened from the chart icon in the header, with your best WPM for every setting you have played, the average of your last ten runs, and a list of recorded runs. Export saves everything in `localStorage` to a JSON file, Import loads such a file and replaces the stored data, and Clear data (two clicks) removes history, bests, and key stats.
4040
- **Themes**: System (follows the OS light/dark and increased-contrast settings), Dark, Light, and High contrast, chosen in Settings. `theme.js`, loaded in `<head>`, applies the saved theme before the page paints.
4141
- **Offline and install**: `sw.js`, a service worker, saves the app's files on the first visit, so later visits load without a network connection. It tries the network first, so a new version is used as soon as you are online. `manifest.webmanifest` and the icons in `icons/` let browsers install the page as an app.
@@ -49,20 +49,20 @@ Pasting into the input is blocked, and the caret stays at the end: arrow keys, H
4949

5050
## Privacy
5151

52-
There is no account and no backend: GitHub Pages serves the static files, and the page makes no requests to other sites, which a test checks. Settings, run history, best scores, and key stats are stored in this browser's `localStorage`, under the keys listed below, and are not sent anywhere. Challenge links carry one run's settings, seed, WPM, and keystroke timings in the URL hash, which browsers do not send to the server; they leave the browser only if you share the link.
52+
There is no account and no backend: GitHub Pages serves the static files, and the page makes no requests to other sites, which a test checks. Settings, run history, best scores, and key stats are stored in this browser's `localStorage`, under the keys listed below, and are not sent anywhere. Challenge links carry one run's settings, seed, first passage hash, WPM, and keystroke timings in the URL hash, which browsers do not send to the server; they leave the browser only if you share the link.
5353

5454
Clearing the browser's site data deletes everything. To keep a copy or move to another device, use Export in the Stats dialog and Import on the other side. Clear data in the same dialog removes history, bests, and key stats.
5555

5656
## Settings and data
5757

5858
Everything is stored in `localStorage` under these keys:
5959

60-
| Key | Contents |
61-
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
62-
| `typingGameSettings` | mode, duration, word count, category, and the feature toggles |
63-
| `typingGameHistory` | last 500 runs as `{wpm, accuracy, date, mode, key}` |
64-
| `typingGameBests` | for each settings key, the best run as `{wpm, keystrokes, seed, weakKeys?}`: keystroke timings and the passage seed for Ghost Mode, and the letters practiced for Weak Keys runs |
65-
| `typingGameKeyStats` | attempts and misses for each expected character across recorded runs |
60+
| Key | Contents |
61+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
62+
| `typingGameSettings` | mode, duration, word count, category, and the feature toggles |
63+
| `typingGameHistory` | last 500 runs as `{wpm, accuracy, date, mode, key}` |
64+
| `typingGameBests` | for each settings key, the best run as `{wpm, keystrokes, seed, textHash, weakKeys?}`: keystroke timings, the passage seed, and a hash of the first passage for Ghost Mode, and the letters practiced for Weak Keys runs |
65+
| `typingGameKeyStats` | attempts and misses for each expected character across recorded runs |
6666

6767
A settings key combines mode, length, and category, for example `time:60s:common` or `words:25w:code-python`. Common Words with punctuation or numbers adds them to the category, as in `time:30s:common+punctuation+numbers`. Code Snippets with Skip indentation off adds `+noindent`, and Stop on error adds `+stop`. Best scores, Ghost Mode, and the chart only compare runs with the same key. Runs that finish in under a second are shown but not saved.
6868

‎script.js‎

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ const runtime = {
209209
seed: 0, // passages for the run are drawn from this seed
210210
random: Math.random, // seeded generator, replaced in resetGame
211211
weakKeys: [], // letters the Weak Keys category uses this run
212+
passageHash: '', // textHash of the run's first passage
213+
textChanged: false, // the replayed run was recorded on a different first passage
212214
ghostIndex: 0, // character index the ghost has reached
213215
challenge: null, // decoded challenge link, kept across resets until a run uses it
214216
indent: [], // per character of quoteText: true for indentation Skip indentation fills in
@@ -222,7 +224,8 @@ const CHART_POINTS = 10;
222224
let runHistory = loadJSON(STORAGE_KEYS.history, []);
223225
if (!Array.isArray(runHistory)) runHistory = [];
224226

225-
// Best run per settings combination: { [settingsKey]: { wpm, keystrokes } }.
227+
// Best run per settings combination:
228+
// { [settingsKey]: { wpm, keystrokes, seed, textHash, weakKeys? } }.
226229
let bests = loadJSON(STORAGE_KEYS.bests, {});
227230
if (!bests || typeof bests !== 'object' || Array.isArray(bests)) bests = {};
228231

@@ -487,6 +490,21 @@ function getPassage() {
487490
return pickQuote(categoryQuotes);
488491
}
489492

493+
// textHash of the first passage a seed gives under the current settings.
494+
// Bests and challenge links store it, so a replay can tell when a change to
495+
// texts.js has changed the passages a seed gives.
496+
function firstPassageHash(seed, weakKeys) {
497+
const saved = { random: runtime.random, weakKeys: runtime.weakKeys, lastQuoteIndex };
498+
runtime.random = seededRandom(seed);
499+
runtime.weakKeys = weakKeys;
500+
lastQuoteIndex = -1;
501+
const hash = textHash(getPassage().text);
502+
runtime.random = saved.random;
503+
runtime.weakKeys = saved.weakKeys;
504+
lastQuoteIndex = saved.lastQuoteIndex;
505+
return hash;
506+
}
507+
490508
function renderNewQuote() {
491509
const passage = getPassage();
492510
runtime.quoteText = passage.text;
@@ -695,10 +713,11 @@ function startGame() {
695713
runtime.interval = setInterval(tick, 250);
696714

697715
const best = currentBest();
716+
// A ghost recorded on other text would not match this passage.
698717
if (runtime.challenge) {
699718
runtime.challenge.used = true;
700-
startGhostPlayback({ keystrokes: runtime.challenge.strokes });
701-
} else if (gameState.isGhostEnabled && best) {
719+
if (!runtime.textChanged) startGhostPlayback({ keystrokes: runtime.challenge.strokes });
720+
} else if (gameState.isGhostEnabled && best && !runtime.textChanged) {
702721
startGhostPlayback(best);
703722
}
704723
updateGhostStatus();
@@ -769,6 +788,12 @@ function updateGhostStatus() {
769788
return;
770789
}
771790
ghostStatus.classList.remove('hidden');
791+
if (runtime.textChanged) {
792+
ghostStatus.textContent = challenge
793+
? 'This challenge was recorded on a different version of the text, so there is no ghost or winner.'
794+
: 'Your best for these settings was recorded on a different version of the text. Set a new best to race a ghost.';
795+
return;
796+
}
772797
const best = challenge || currentBest();
773798
if (challenge && !runtime.isStarted) {
774799
ghostStatus.textContent = `Challenge: beat ${challenge.wpm} WPM on this text`;
@@ -848,7 +873,12 @@ function endGame(endTime = performance.now()) {
848873
saveJSON(STORAGE_KEYS.keyStats, keyStats);
849874

850875
if (wpm > (previousBest ? previousBest.wpm : 0)) {
851-
bests[key] = { wpm, keystrokes: runtime.ghostStrokes, seed: runtime.seed };
876+
bests[key] = {
877+
wpm,
878+
keystrokes: runtime.ghostStrokes,
879+
seed: runtime.seed,
880+
textHash: runtime.passageHash,
881+
};
852882
if (gameState.category === 'weak') bests[key].weakKeys = runtime.weakKeys;
853883
saveJSON(STORAGE_KEYS.bests, bests);
854884
isNewBest = true;
@@ -928,14 +958,25 @@ function renderRunChart() {
928958
}
929959

930960
// The run whose passages the next run replays: an open challenge, or with
931-
// Ghost Mode on, the best for these settings if it recorded its seed.
932-
// Otherwise runs start from a new seed.
961+
// Ghost Mode on, the best for these settings if it recorded its seed and
962+
// was recorded on the same text. Otherwise runs start from a new seed.
933963
function replayedRun() {
934964
const best = currentBest();
935965
if (runtime.challenge) return runtime.challenge;
966+
if (runtime.textChanged) return null;
936967
return gameState.isGhostEnabled && best && Number.isInteger(best.seed) ? best : null;
937968
}
938969

970+
const replayedWeakKeys = (replay) =>
971+
replay && Array.isArray(replay.weakKeys) ? replay.weakKeys : weakestKeys();
972+
973+
// A replayed run with a stored hash that its seed no longer reproduces was
974+
// recorded before a change to texts.js. Runs without a hash are not checked.
975+
function textChangedSince(replay) {
976+
if (!replay || typeof replay.textHash !== 'string') return false;
977+
return replay.textHash !== firstPassageHash(replay.seed, replayedWeakKeys(replay));
978+
}
979+
939980
function resetGame() {
940981
if (runtime.challenge && runtime.challenge.used) endChallenge();
941982
clearInterval(runtime.interval);
@@ -956,12 +997,16 @@ function resetGame() {
956997
keyStats: {},
957998
ghostIndex: 0,
958999
challengeUrl: '',
1000+
textChanged: false,
9591001
});
9601002

1003+
// A challenge on changed text is still played, without a ghost or
1004+
// winner. A best on changed text is not replayed.
1005+
runtime.textChanged = textChangedSince(replayedRun());
9611006
const replay = replayedRun();
9621007
runtime.seed = replay ? replay.seed : newSeed();
9631008
runtime.random = seededRandom(runtime.seed);
964-
runtime.weakKeys = replay && Array.isArray(replay.weakKeys) ? replay.weakKeys : weakestKeys();
1009+
runtime.weakKeys = replayedWeakKeys(replay);
9651010
lastQuoteIndex = -1;
9661011

9671012
runtime.timer = gameState.mode === 'time' ? gameState.duration : 0;
@@ -979,6 +1024,7 @@ function resetGame() {
9791024
updateQuickBar();
9801025
updateHighScoreDisplay();
9811026
renderNewQuote();
1027+
runtime.passageHash = textHash(runtime.quoteText);
9821028
updateGhostStatus();
9831029
typingInputElement.focus();
9841030
}
@@ -1071,8 +1117,11 @@ function showResultModal(wpm, accuracy, { isNewBest, previousBest }) {
10711117
const challenge = runtime.challenge;
10721118
modalChallengeElement.classList.toggle('hidden', !challenge);
10731119
if (challenge) {
1074-
const outcome =
1120+
const verdict =
10751121
wpm > challenge.wpm ? 'You won.' : wpm === challenge.wpm ? 'A tie.' : 'They won.';
1122+
const outcome = runtime.textChanged
1123+
? 'Recorded on a different version of the text: no winner.'
1124+
: verdict;
10761125
modalChallengeElement.textContent = `Challenge: you ${wpm} WPM, them ${challenge.wpm} WPM. ${outcome}`;
10771126
}
10781127
modalSourceElement.textContent = runtime.quoteSource;
@@ -1121,10 +1170,11 @@ async function shareResult() {
11211170
}
11221171

11231172
// --- Challenge Links ---
1124-
// A link carries a finished run's settings, seed, WPM, and keystroke
1125-
// timings in the URL hash. Opening it plays those passages with the run as
1126-
// the ghost, without changing the receiver's saved settings.
1127-
const CHALLENGE_VERSION = 1;
1173+
// A link carries a finished run's settings, seed, first passage hash, WPM,
1174+
// and keystroke timings in the URL hash. Opening it plays those passages
1175+
// with the run as the ghost, without changing the receiver's saved
1176+
// settings. Version 1 links have no passage hash and are not checked.
1177+
const CHALLENGE_VERSION = 2;
11281178
const CHALLENGE_MAX_LENGTH = 20000; // characters in the hash
11291179
const CHALLENGE_MAX_STROKES = 5000;
11301180
const CHALLENGE_SETTINGS = [
@@ -1199,7 +1249,9 @@ async function decodeChallenge(text) {
11991249

12001250
// Checks every field of a decoded payload. Returns null if anything is off.
12011251
function parseChallenge(data) {
1202-
if (!data || data.v !== CHALLENGE_VERSION || !data.s || typeof data.s !== 'object') return null;
1252+
if (!data || ![1, CHALLENGE_VERSION].includes(data.v)) return null;
1253+
if (!data.s || typeof data.s !== 'object') return null;
1254+
if (data.v === CHALLENGE_VERSION && !isTextHash(data.h)) return null;
12031255
if (!Number.isInteger(data.seed) || data.seed < 0 || data.seed >= 4294967296) return null;
12041256
if (!isNumber(data.wpm) || data.wpm < 0 || data.wpm > 1000) return null;
12051257
if (!Array.isArray(data.d) || data.d.length % 2 !== 0) return null;
@@ -1222,7 +1274,16 @@ function parseChallenge(data) {
12221274
const weakKeys = Array.isArray(data.k)
12231275
? data.k.filter((key) => /^[a-z]$/.test(key)).slice(0, WEAK_KEY_COUNT)
12241276
: [];
1225-
return { settings, seed: data.seed, wpm: Math.round(data.wpm), weakKeys, strokes, used: false };
1277+
const challenge = {
1278+
settings,
1279+
seed: data.seed,
1280+
wpm: Math.round(data.wpm),
1281+
weakKeys,
1282+
strokes,
1283+
used: false,
1284+
};
1285+
if (data.v === CHALLENGE_VERSION) challenge.textHash = data.h;
1286+
return challenge;
12261287
}
12271288

12281289
function challengePayload() {
@@ -1241,6 +1302,7 @@ function challengePayload() {
12411302
v: CHALLENGE_VERSION,
12421303
s: settings,
12431304
seed: runtime.seed,
1305+
h: runtime.passageHash,
12441306
wpm: Number(modalWpmElement.textContent),
12451307
k: gameState.category === 'weak' ? runtime.weakKeys : [],
12461308
d: deltas,
@@ -1528,6 +1590,8 @@ function exportData() {
15281590
}
15291591

15301592
const isNumber = (value) => typeof value === 'number' && Number.isFinite(value);
1593+
// textHash output: a 32-bit number in base 36.
1594+
const isTextHash = (value) => typeof value === 'string' && /^[0-9a-z]{1,7}$/.test(value);
15311595

15321596
function validHistory(list) {
15331597
if (!Array.isArray(list)) return [];
@@ -1554,6 +1618,7 @@ function validBests(map) {
15541618
const keystrokes = best.keystrokes.filter((s) => s && isNumber(s.t) && isNumber(s.i));
15551619
result[key] = { wpm: best.wpm, keystrokes };
15561620
if (Number.isInteger(best.seed)) result[key].seed = best.seed;
1621+
if (isTextHash(best.textHash)) result[key].textHash = best.textHash;
15571622
if (Array.isArray(best.weakKeys)) {
15581623
result[key].weakKeys = best.weakKeys.filter((k) => /^[a-z]$/.test(k)).slice(0, 3);
15591624
}

0 commit comments

Comments
 (0)