-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCompetitionEngine.js
More file actions
130 lines (122 loc) · 5.56 KB
/
Copy pathCompetitionEngine.js
File metadata and controls
130 lines (122 loc) · 5.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
.pragma library
// This engine is deliberately independent of clocks, storage, and UI.
// A competitive client records events; every client and the server replays the
// same events through this code. Existing free-practice rules are unchanged.
var COMPETITION_VERSION = "competition-1"
var MAX_EVENTS = 24000
var MAX_DURATION_MS = 900000
function competitionRules(value) {
if (!value || value.version !== COMPETITION_VERSION || value.finish !== "passage"
|| value.correction !== "required" || typeof value.autoIndent !== "boolean")
throw new Error("Unsupported challenge rules")
return { version: COMPETITION_VERSION, finish: "passage", correction: "required", autoIndent: value.autoIndent }
}
function competitionState(passage, rules) {
competitionRules(rules)
if (typeof passage !== "string" || passage.length < 40 || passage.length > 4000
|| passage !== passage.normalize("NFC") || /[\u0000-\u0008\u000b-\u001f\u007f]/.test(passage)
|| passage.trim().length < 20 || /[\r\t]/.test(passage))
throw new Error("Invalid challenge passage")
return {
passage: Array.from(passage), rules: rules, typed: [], assisted: [],
correct: 0, wrong: 0, assistedCount: 0, presses: 0, errors: 0, lastAt: -1,
events: 0, finishedAt: null, progress: []
}
}
function competitionStep(state, event) {
if (state.finishedAt !== null) throw new Error("Attempt is already finished")
if (!event || !Number.isInteger(event.at) || event.at < 0
|| event.at < state.lastAt || event.at > MAX_DURATION_MS
|| state.events >= MAX_EVENTS)
throw new Error("Invalid attempt timing")
if (state.events === 0 && (event.type !== "input" || event.at !== 0))
throw new Error("Attempt must begin with input at time zero")
if (event.type !== "input" && event.type !== "backspace" && event.type !== "word")
throw new Error("Invalid input event")
if (event.type === "input" && (typeof event.text !== "string"
|| Array.from(event.text).length !== 1 || /[\u0000-\u0009\u000b-\u001f\u007f]/.test(event.text)))
throw new Error("Each input event must contain one character")
if (event.type === "input") {
state.presses++
var index = state.typed.length
if (index >= state.passage.length) {
state.errors++
} else {
state.typed.push(event.text)
state.assisted.push(false)
if (event.text === state.passage[index]) state.correct++
else { state.wrong++; state.errors++ }
if (event.text === "\n" && event.text === state.passage[index] && state.rules.autoIndent) {
while (state.passage[state.typed.length] === " ") {
state.typed.push(" ")
state.assisted.push(true)
state.assistedCount++
}
}
}
} else {
var removeTo = Math.max(0, state.typed.length - 1)
while (removeTo > 0 && state.assisted[removeTo]) removeTo--
if (event.type === "word") {
removeTo = state.typed.length
while (removeTo > 0 && /\s/.test(state.typed[removeTo - 1])) removeTo--
while (removeTo > 0 && !/\s/.test(state.typed[removeTo - 1])) removeTo--
}
while (state.typed.length > removeTo) {
var last = state.typed.length - 1
if (state.assisted[last]) state.assistedCount--
else {
if (state.typed[last] === state.passage[last]) state.correct--
else state.wrong--
}
state.typed.pop()
state.assisted.pop()
}
}
state.lastAt = event.at
state.events++
if (state.typed.length === state.passage.length && state.wrong === 0) state.finishedAt = event.at
// Public playback contains position and time only, never the entered text.
// At most one sample per 250ms, plus the finish. Corrections may move backward.
var position = state.correct + state.assistedCount
var previous = state.progress.length ? state.progress[state.progress.length - 1] : null
if (!previous || event.at - previous[0] >= 250 || state.finishedAt !== null)
state.progress.push([event.at, position])
return state
}
// Competition WPM: correct characters times 120000 over the recording duration, computed from the
// replayed events. A ranked result is never derived from practice WPM, and every racing client is
// scored here as well as on the service.
function competitionResult(state) {
if (state.finishedAt === null || state.finishedAt < 1000)
throw new Error("Complete the passage before submitting")
return {
durationMs: state.finishedAt,
wpm: Math.round(state.correct * 120000 / state.finishedAt) / 10,
rawWpm: Math.round(state.presses * 120000 / state.finishedAt) / 10,
accuracy: Math.round((state.presses - state.errors) * 1000 / state.presses) / 10,
errors: state.errors, characters: state.correct,
progress: state.progress.map(function(sample) { return sample.slice() })
}
}
function competitionReplay(passage, rules, events) {
if (!Array.isArray(events) || !events.length || events.length > MAX_EVENTS)
throw new Error("Invalid attempt recording")
var state = competitionState(passage, rules)
events.forEach(function(event) { competitionStep(state, event) })
return competitionResult(state)
}
function competitionPosition(samples, elapsed) {
if (!samples || !samples.length || elapsed < samples[0][0]) return 0
var low = 0
var high = samples.length - 1
while (low < high) {
var middle = Math.ceil((low + high) / 2)
if (samples[middle][0] <= elapsed) low = middle
else high = middle - 1
}
var left = samples[low]
var right = samples[Math.min(low + 1, samples.length - 1)]
if (left[0] === right[0]) return left[1]
return left[1] + (right[1] - left[1]) * Math.min(1, (elapsed - left[0]) / (right[0] - left[0]))
}