Files
time/typy/src/game.js
T
2026-10-24 23:27:50 +00:00

356 lines
14 KiB
JavaScript

import { generateWords, normalizePassage } from './words.js';
export const MODES = ['30', '60', '50', 'endless', 'tempo'];
export const MAX_REPLAY_EVENTS = 30000;
export const MAX_REPLAY_WORDS = 6000;
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
const rounded = value => Math.round(value * 10) / 10;
const lengthOf = word => Array.from(word || '').length;
const prefixLength = flags => {
const index = flags.indexOf(false);
return index < 0 ? flags.length : index;
};
const speed = (chars, durationMs) => durationMs > 0
? rounded(chars * 12000 / Math.max(1000, durationMs)) : 0;
/**
* Recompute public metrics from privacy-safe replay outcomes and word timings.
* A hit/miss records whether a key matched, never what the visitor typed.
* Fully complete words count one normalized separator in either advance setting.
* Backspacing never earns output; accuracy still reflects every typing attempt.
*/
export function summarizeRun(run) {
const words = Array.isArray(run.words) ? run.words : [];
const events = Array.isArray(run.events) ? run.events : [];
const timings = Array.isArray(run.wordTimings) ? run.wordTimings : [];
const durationMs = Math.max(0, Number(run.durationMs) || 0);
const states = new Map();
let hits = 0;
let errors = 0;
for (const event of events) {
if (!states.has(event.index)) states.set(event.index, { flags: [], errors: 0 });
const state = states.get(event.index);
if (event.type === 'hit') {
state.flags.push(true);
hits++;
} else if (event.type === 'miss') {
state.flags.push(false);
state.errors++;
errors++;
} else if (event.type === 'backspace') {
state.flags.pop();
}
}
let correctChars = 0;
let completedWords = 0;
let streak = 0;
let bestStreak = 0;
const wordSpeeds = [];
for (let index = 0; index < timings.length; index++) {
const timing = timings[index];
const word = words.length ? words[timing.index % words.length] : '';
const state = states.get(timing.index) || { flags: [], errors: 0 };
// The backend also validates timing structure. Here a claimed completion must
// agree with replayed character outcomes before it can earn output.
const complete = timing.completed === true && state.flags.length === lengthOf(word)
&& prefixLength(state.flags) === lengthOf(word) && lengthOf(word) > 0;
if (complete) {
correctChars += lengthOf(word) + 1;
completedWords++;
streak = state.errors === 0 ? streak + 1 : 0;
bestStreak = Math.max(bestStreak, streak);
const stop = Number.isFinite(timing.typedEnd) ? timing.typedEnd : timing.end;
const ms = stop - timing.start;
// A one-letter first word in automatic mode can finish at t=0. There is
// no measured interval for that word, so don't invent an infinite spike.
if (ms > 0) wordSpeeds.push({ word, index: timing.index, ms: Math.round(ms), wpm: rounded((lengthOf(word) + 1) * 12000 / ms) });
} else {
streak = 0;
if (index === timings.length - 1 && timing.missed !== true) {
correctChars += Math.min(lengthOf(word), prefixLength(state.flags));
}
}
}
const slowest = [...wordSpeeds].sort((a, b) => a.wpm - b.wpm || b.ms - a.ms).slice(0, 5);
// Preserve the shape of long runs without sending hundreds of graph columns.
const bucketSize = Math.max(1, Math.ceil(wordSpeeds.length / 80));
const sparkline = [];
for (let i = 0; i < wordSpeeds.length; i += bucketSize) {
const bucket = wordSpeeds.slice(i, i + bucketSize);
sparkline.push(rounded(bucket.reduce((sum, row) => sum + row.wpm, 0) / bucket.length));
}
return {
wpm: speed(correctChars, durationMs),
accuracy: hits + errors ? rounded(hits * 100 / (hits + errors)) : 100,
bestStreak, correctChars, keystrokes: hits + errors, errors,
completedWords, slowest, sparkline
};
}
export class TypingGame {
constructor({ words, mode = '30', advance = 'space', paceWpm = 40 } = {}) {
const supplied = Array.isArray(words) ? normalizePassage(words.join(' ')) : normalizePassage(words);
this.words = supplied.length ? supplied : generateWords();
this.mode = MODES.includes(String(mode)) ? String(mode) : '30';
this.advance = advance === 'letter' ? 'letter' : 'space';
this.paceWpm = clamp(Number(paceWpm) || 40, 10, 200);
this.status = 'ready';
this.wordIndex = 0;
this.buffer = [];
this.startedAt = null;
this.endedAt = null;
this.lastAt = null;
this.wordStartedAt = null;
this.typedEnd = null;
this.wordErrors = 0;
this.hits = 0;
this.errors = 0;
this.completedWords = 0;
this.completedChars = 0;
this.streak = 0;
this.bestStreak = 0;
this.misses = 0;
this.waitingForBeat = false;
this.allowOptionalSeparator = false;
this.beatDeadline = null;
this.beatDurationMs = null;
this.finishedReason = null;
this.events = [];
this.wordTimings = [];
this.replayTruncated = false;
}
get word() { return this.words[this.wordIndex % this.words.length]; }
_now(nowMs) {
const proposed = Number.isFinite(nowMs) ? nowMs : globalThis.performance?.now?.() ?? Date.now();
this.lastAt = this.lastAt === null ? proposed : Math.max(this.lastAt, proposed);
return this.lastAt;
}
_relative(time) { return Math.max(0, Math.round(time - this.startedAt)); }
_event(type, now) {
this.events.push({ t: this._relative(now), type, index: this.wordIndex });
if (this.events.length > MAX_REPLAY_EVENTS) {
this.events.splice(0, this.events.length - MAX_REPLAY_EVENTS);
this.replayTruncated = true;
}
}
_flags() {
const target = Array.from(this.word);
return this.buffer.map((character, index) => character === target[index]);
}
_prefix() { return Math.min(lengthOf(this.word), prefixLength(this._flags())); }
_setBeat(now) {
this.beatDurationMs = clamp((lengthOf(this.word) + 1) * 12000 / this.paceWpm, 350, 15000);
this.beatDeadline = now + this.beatDurationMs;
}
_start(now) {
this.status = 'running';
this.startedAt = now;
this.wordStartedAt = now;
if (this.mode === 'tempo') this._setBeat(now);
}
_recordWord(now, completed, missed = false) {
const timing = {
index: this.wordIndex,
start: this._relative(this.wordStartedAt),
end: this._relative(now),
completed,
errors: this.wordErrors
};
if (this.typedEnd !== null) timing.typedEnd = this._relative(this.typedEnd);
if (missed) timing.missed = true;
this.wordTimings.push(timing);
if (this.wordTimings.length > MAX_REPLAY_WORDS) {
this.wordTimings.splice(0, this.wordTimings.length - MAX_REPLAY_WORDS);
this.replayTruncated = true;
}
}
_advance(now, completed, isBeat = false) {
this._event(isBeat ? 'beat' : 'advance', now);
this._recordWord(now, completed, isBeat && !completed);
if (completed) {
this.completedWords++;
this.completedChars += lengthOf(this.word) + 1;
this.streak = this.wordErrors === 0 ? this.streak + 1 : 0;
this.bestStreak = Math.max(this.bestStreak, this.streak);
if (isBeat) this.paceWpm = Math.min(240, this.paceWpm * 1.035);
} else {
this.streak = 0;
this.misses++;
}
this.wordIndex++;
this.buffer = [];
this.wordErrors = 0;
this.typedEnd = null;
this.waitingForBeat = false;
this.allowOptionalSeparator = completed && !isBeat && this.advance === 'letter';
this.wordStartedAt = now;
if (this.mode === '50' && this.completedWords >= 50) {
this._finish(now, 'words', false);
} else if (this.mode === 'tempo' && this.misses >= 3) {
this._finish(now, 'misses', false);
} else if (isBeat) {
this._setBeat(now);
}
}
_finish(now, reason, closeCurrent = true) {
if (this.status === 'finished') return;
if (this.startedAt !== null && closeCurrent) this._recordWord(now, false);
this.status = 'finished';
this.endedAt = now;
this.finishedReason = reason;
}
tick(nowMs) {
const now = this._now(nowMs);
if (this.status === 'running') {
if (this.mode === '30' || this.mode === '60') {
const deadline = this.startedAt + Number(this.mode) * 1000;
if (now >= deadline) this._finish(deadline, 'time');
} else if (this.mode === 'tempo') {
// Catch up from a suspended tab. No human gains extra time by hiding it.
while (this.status === 'running' && now >= this.beatDeadline) {
const deadline = this.beatDeadline;
this._advance(deadline, this.waitingForBeat, true);
}
}
}
return this._snapshot(now);
}
press(key, nowMs) {
const now = this._now(nowMs);
this.tick(now);
if (this.status === 'finished' || typeof key !== 'string') return this._snapshot(now);
if (key === 'Backspace') {
if (this.status === 'running' && !this.waitingForBeat && this.buffer.length) {
this.buffer.pop();
this.typedEnd = null;
this._event('backspace', now);
}
return this._snapshot(now);
}
if (Array.from(key).length !== 1 || /[\r\n\t\u0000-\u001f\u007f]/u.test(key)) return this._snapshot(now);
if (this.status === 'ready') {
if (/\s/u.test(key)) return this._snapshot(now);
this._start(now);
}
if (this.waitingForBeat) return this._snapshot(now);
// Automatic advancement already earned one normalized separator. Let a
// typist press that space out of habit without corrupting the next word.
// Only the very next printable key gets this allowance; extra or mid-word
// spaces still need correction, and an ignored separator earns no output.
if (this.allowOptionalSeparator) {
this.allowOptionalSeparator = false;
if (key === ' ') return this._snapshot(now);
}
if (key === ' ' && this.buffer.join('') === this.word) {
if (this.mode === 'tempo') {
this.waitingForBeat = true;
} else {
this._advance(now, true);
}
return this._snapshot(now);
}
// The small bound prevents an accidentally held key growing a buffer forever.
// Ignored overflow presses are not logged or included in accuracy.
if (this.buffer.length >= 4096) return this._snapshot(now);
const target = Array.from(this.word);
const correct = key === target[this.buffer.length];
this.buffer.push(key);
if (correct) this.hits++;
else {
this.errors++;
this.wordErrors++;
this.streak = 0;
}
this._event(correct ? 'hit' : 'miss', now);
if (this.buffer.join('') === this.word) {
this.typedEnd = now;
if (this.mode === 'tempo') this.waitingForBeat = true;
else if (this.advance === 'letter') this._advance(now, true);
}
return this._snapshot(now);
}
finish(nowMs) {
const now = this._now(nowMs);
this.tick(now);
if (this.status !== 'finished') this._finish(now, 'manual');
return this._snapshot(now);
}
_snapshot(now) {
const stop = this.endedAt ?? now;
const elapsedMs = this.startedAt === null ? 0 : Math.max(0, stop - this.startedAt);
const flags = this._flags();
const errorIndex = flags.indexOf(false);
const correctPrefix = this._prefix();
const remainingMs = this.mode === '30' || this.mode === '60'
? Math.max(0, Number(this.mode) * 1000 - elapsedMs) : null;
return {
status: this.status, mode: this.mode, advance: this.advance,
wordIndex: this.wordIndex, word: this.word,
nextWord: this.words[(this.wordIndex + 1) % this.words.length],
buffer: this.buffer.join(''), correctPrefix, errorIndex,
startedAt: this.startedAt, elapsedMs, remainingMs,
wpm: speed(this.completedChars + correctPrefix, elapsedMs),
accuracy: this.hits + this.errors ? rounded(this.hits * 100 / (this.hits + this.errors)) : 100,
bestStreak: this.bestStreak, streak: this.streak, misses: this.misses,
paceWpm: rounded(this.paceWpm),
beatRemainingMs: this.beatDeadline === null ? null : Math.max(0, this.beatDeadline - now),
beatDurationMs: this.beatDurationMs,
waitingForBeat: this.waitingForBeat,
completedWords: this.completedWords, finishedReason: this.finishedReason,
correctChars: this.completedChars + correctPrefix,
keystrokes: this.hits + this.errors, errors: this.errors,
replayTruncated: this.replayTruncated
};
}
snapshot(nowMs) { return this.tick(nowMs); }
result(nowMs) {
const state = this.tick(nowMs);
const wordTimings = this.wordTimings.map(timing => ({ ...timing }));
// Useful for previews or manual save callers, without mutating a running game.
if (this.status === 'running') {
const timing = {
index: this.wordIndex, start: this._relative(this.wordStartedAt),
end: Math.round(state.elapsedMs), completed: false, errors: this.wordErrors
};
if (this.typedEnd !== null) timing.typedEnd = this._relative(this.typedEnd);
wordTimings.push(timing);
}
const run = {
version: 1, mode: this.mode, advance: this.advance,
words: [...this.words], durationMs: Math.round(state.elapsedMs),
finishedReason: this.finishedReason,
events: this.events.map(event => ({ ...event })), wordTimings,
replayTruncated: this.replayTruncated
};
run.stats = summarizeRun(run);
if (this.replayTruncated) {
// The full run still has correct local results. The server refuses partial
// replay uploads instead of pretending the retained tail is the full run.
Object.assign(run.stats, {
wpm: state.wpm, accuracy: state.accuracy, bestStreak: state.bestStreak,
correctChars: state.correctChars, keystrokes: state.keystrokes,
errors: state.errors, completedWords: state.completedWords
});
}
return run;
}
}