domma-cms 1.8.31 → 1.8.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +1 -1
  3. package/plugins/_lib/admin/games/games.css +1 -0
  4. package/plugins/_lib/admin/games/kit.js +241 -0
  5. package/plugins/_lib/games/http.js +82 -0
  6. package/plugins/_lib/games/names.js +42 -0
  7. package/plugins/_lib/games/plays.js +155 -0
  8. package/plugins/_lib/games/ranking.js +68 -0
  9. package/plugins/_lib/games/timing.js +27 -0
  10. package/plugins/_lib/public/games/format.js +38 -0
  11. package/plugins/_lib/public/games/games.css +1 -0
  12. package/plugins/_lib/public/games/games.js +204 -0
  13. package/plugins/crossword/admin/css/index.css +1 -0
  14. package/plugins/crossword/admin/templates/crossword.html +1 -0
  15. package/plugins/crossword/admin/views/crossword.js +317 -0
  16. package/plugins/crossword/collections/crossword-plays.json +20 -0
  17. package/plugins/crossword/collections/crossword-puzzles.json +17 -0
  18. package/plugins/crossword/config.js +6 -0
  19. package/plugins/crossword/inject/body.html +1 -0
  20. package/plugins/crossword/lib/crossword.js +170 -0
  21. package/plugins/crossword/lib/store.js +56 -0
  22. package/plugins/crossword/plugin.js +241 -0
  23. package/plugins/crossword/plugin.json +78 -0
  24. package/plugins/crossword/public/crossword.css +1 -0
  25. package/plugins/crossword/public/crossword.js +57 -0
  26. package/plugins/crossword/public/lib/layout.js +199 -0
  27. package/plugins/quiz/admin/css/index.css +1 -0
  28. package/plugins/quiz/admin/templates/quiz.html +1 -0
  29. package/plugins/quiz/admin/views/quiz.js +352 -0
  30. package/plugins/quiz/collections/quiz-plays.json +20 -0
  31. package/plugins/quiz/collections/quiz-quizzes.json +17 -0
  32. package/plugins/quiz/config.js +6 -0
  33. package/plugins/quiz/inject/body.html +1 -0
  34. package/plugins/quiz/lib/engine.js +115 -0
  35. package/plugins/quiz/lib/quiz.js +225 -0
  36. package/plugins/quiz/lib/store.js +56 -0
  37. package/plugins/quiz/plugin.js +241 -0
  38. package/plugins/quiz/plugin.json +78 -0
  39. package/plugins/quiz/public/quiz.css +1 -0
  40. package/plugins/quiz/public/quiz.js +80 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Quiz - one play moving forward, by the server's clock. PURE: each function
3
+ * takes the quiz, the play and `now`, changes the play in place and says
4
+ * whether it changed (the route then saves it).
5
+ *
6
+ * catchUp(quiz, play, now) time that has run out while nobody asked: a question
7
+ * past its limit is marked as not answered; past the
8
+ * quiz's limit, the play ends
9
+ * serve(quiz, play, now) the current question is now in front of the player
10
+ * answer(quiz, play, qid, given, now) one answer, marked; the play moves on (or ends)
11
+ * view(quiz, play, now) what the player may know of their play now
12
+ *
13
+ * @module quiz/lib/engine
14
+ */
15
+
16
+ import {GRACE_MS, leftOf, overdue} from '../../_lib/games/timing.js';
17
+ import {mark, publicQuestion, questionLimitMs, rightText} from './quiz.js';
18
+
19
+ const questionOf = (quiz, play) => {
20
+ const slot = play.state.order[play.state.index];
21
+ return slot ? quiz.questions.find(q => q.id === slot.q) || null : null;
22
+ };
23
+
24
+ const scoreOf = (play) => play.state.answers.reduce((s, a) => s + (a.points || 0), 0);
25
+
26
+ /** Ends the play. Out of time: the time counted is the limit itself, never more. */
27
+ export function end(quiz, play, now, {timeUp = false} = {}) {
28
+ const limit = (quiz.timeLimit || 0) * 1000;
29
+ play.status = 'finished';
30
+ play.finishedAt = new Date(now).toISOString();
31
+ play.score = scoreOf(play);
32
+ const spent = Math.max(0, now - Date.parse(play.startedAt));
33
+ play.timeMs = limit ? Math.min(spent, limit) : spent;
34
+ play.state.timeUp = timeUp;
35
+ return true;
36
+ }
37
+
38
+ function skip(quiz, play, now, why) {
39
+ const q = questionOf(quiz, play);
40
+ play.state.answers.push({q: q?.id || '', given: null, correct: false, points: 0, late: true, why, at: new Date(now).toISOString()});
41
+ play.state.index += 1;
42
+ if (play.state.index >= play.state.order.length) end(quiz, play, now);
43
+ }
44
+
45
+ /** Bring the play up to date with the clock. */
46
+ export function catchUp(quiz, play, now = Date.now()) {
47
+ if (play.status !== 'playing') return false;
48
+ let changed = false;
49
+ if (overdue(play.startedAt, (quiz.timeLimit || 0) * 1000, now)) return end(quiz, play, now, {timeUp: true});
50
+ // Only a question that was SHOWN can run out: one never fetched has not started its clock.
51
+ while (play.status === 'playing') {
52
+ const q = questionOf(quiz, play);
53
+ if (!q) { end(quiz, play, now); return true; }
54
+ const served = play.state.servedAt[play.state.index];
55
+ if (!served || !overdue(served, questionLimitMs(quiz, q), now)) break;
56
+ skip(quiz, play, now, 'time');
57
+ changed = true;
58
+ }
59
+ return changed;
60
+ }
61
+
62
+ /** The current question is on the player's screen from now - the first time only, so a reload does not reset its clock. */
63
+ export function serve(quiz, play, now = Date.now()) {
64
+ if (play.status !== 'playing' || play.state.servedAt[play.state.index]) return false;
65
+ play.state.servedAt[play.state.index] = new Date(now).toISOString();
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * An answer to question `qid`. Refused (`error`) when it is not the question
71
+ * they are on - an old tab, a replayed request. Late is marked wrong.
72
+ *
73
+ * @returns {{error?: string, correct?: boolean, late?: boolean, points?: number, q?: object}}
74
+ */
75
+ export function answer(quiz, play, qid, given, now = Date.now()) {
76
+ if (play.status !== 'playing') return {error: 'This play has finished.'};
77
+ const q = questionOf(quiz, play);
78
+ if (!q || q.id !== String(qid)) return {error: 'That is not the question you are on. Carry on from where you are.', code: 'not-current'};
79
+ const served = play.state.servedAt[play.state.index];
80
+ if (!served) return {error: 'That question has not been shown yet.', code: 'not-current'};
81
+ const late = overdue(served, questionLimitMs(quiz, q), now) || overdue(play.startedAt, (quiz.timeLimit || 0) * 1000, now);
82
+ const result = late ? {correct: false, given: null} : mark(q, given);
83
+ const points = result.correct ? q.points : 0;
84
+ play.state.answers.push({q: q.id, given: result.given, correct: result.correct, points, late, at: new Date(now).toISOString()});
85
+ play.state.index += 1;
86
+ play.score = scoreOf(play);
87
+ if (play.state.index >= play.state.order.length) end(quiz, play, now);
88
+ else if (overdue(play.startedAt, (quiz.timeLimit || 0) * 1000, now)) end(quiz, play, now, {timeUp: true});
89
+ return {correct: result.correct, late, points, q};
90
+ }
91
+
92
+ /** Feedback straight after an answer, when the quiz shows answers as you go. */
93
+ export function feedbackFor(quiz, q, outcome) {
94
+ if (quiz.showAnswers !== 'after-each' || !q) return null;
95
+ return {correct: outcome.correct, late: outcome.late, rightAnswer: rightText(q), explanation: q.explanation || ''};
96
+ }
97
+
98
+ /** What the player may know of their play right now. */
99
+ export function view(quiz, play, now = Date.now()) {
100
+ const total = play.state.order.length;
101
+ const base = {
102
+ status: play.status, index: Math.min(play.state.index, total), total,
103
+ elapsedMs: Math.max(0, (play.status === 'playing' ? now : Date.parse(play.finishedAt)) - Date.parse(play.startedAt)),
104
+ score: play.score || 0, grace: GRACE_MS
105
+ };
106
+ if (play.status !== 'playing') return base;
107
+ const q = publicQuestion(quiz, play.state);
108
+ const served = play.state.servedAt[play.state.index];
109
+ return {
110
+ ...base,
111
+ question: q,
112
+ questionLeftMs: served ? leftOf(served, questionLimitMs(quiz, questionOf(quiz, play)), now) : null,
113
+ totalLeftMs: leftOf(play.startedAt, (quiz.timeLimit || 0) * 1000, now)
114
+ };
115
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Quiz - the rules. PURE: no I/O, so the same decisions run in the routes and
3
+ * the tests.
4
+ *
5
+ * cleanQuiz(input) -> {quiz, errors} what the admin saved, made safe
6
+ * isOpen(quiz, now) -> {open, reason} can people play it now?
7
+ * planPlay(quiz, rand) -> state this player's questions, in their order
8
+ * publicQuestion(...) -> what the player may see of a question (never the answer)
9
+ * mark(question, given) -> {correct, given} one answer marked
10
+ * answerText(question, ids) -> "Paris" an answer in words, for feedback and review
11
+ *
12
+ * @module quiz/lib/quiz
13
+ */
14
+
15
+ export const KINDS = ['single', 'multiple', 'truefalse', 'text'];
16
+ export const STATUSES = ['draft', 'open', 'closed'];
17
+ export const SHOW_ANSWERS = ['never', 'after-each', 'end'];
18
+
19
+ export const LIMITS = {questions: 200, options: 8, text: 1000, option: 200, accepted: 20, seconds: 3600, timeLimit: 4 * 3600, points: 100};
20
+
21
+ const str = (v, max) => String(v ?? '').replace(/\r\n?/g, '\n').trim().slice(0, max);
22
+ const int = (v, min, max, dflt = 0) => {
23
+ const n = Math.round(Number(v));
24
+ return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : dflt;
25
+ };
26
+ const iso = (v) => {
27
+ const t = Date.parse(String(v || ''));
28
+ return Number.isFinite(t) ? new Date(t).toISOString() : '';
29
+ };
30
+ const shortId = () => Math.random().toString(36).slice(2, 10);
31
+ const idOf = (v) => (/^[a-z0-9_-]{1,40}$/i.test(String(v || '')) ? String(v) : shortId());
32
+
33
+ /** "Capital Cities 2026" -> "capital-cities-2026". */
34
+ export function slugify(s) {
35
+ return String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
36
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
37
+ }
38
+
39
+ /** For comparing typed answers: case, accents, punctuation and spacing do not matter; a leading "the"/"a" neither. */
40
+ export function foldAnswer(s) {
41
+ return String(s ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
42
+ .replace(/&/g, ' and ').replace(/[^\p{L}\p{N}]+/gu, ' ').trim().replace(/^(the|a|an) /, '');
43
+ }
44
+
45
+ function cleanQuestion(q, n, errors) {
46
+ const kind = KINDS.includes(q?.kind) ? q.kind : 'single';
47
+ const out = {
48
+ id: idOf(q?.id), kind,
49
+ text: str(q?.text, LIMITS.text),
50
+ image: str(q?.image, 500),
51
+ points: int(q?.points ?? 1, 0, LIMITS.points, 1),
52
+ seconds: int(q?.seconds, 0, LIMITS.seconds),
53
+ explanation: str(q?.explanation, LIMITS.text),
54
+ options: [],
55
+ accepted: []
56
+ };
57
+ const where = `Question ${n}`;
58
+ if (!out.text) errors[`q${n}`] = `${where} has no question.`;
59
+ if (out.image && !/^(\/(?!\/)|https:\/\/)/.test(out.image)) errors[`q${n}`] = `${where}: the picture must be on this site (/media/...) or an https:// address.`;
60
+
61
+ if (kind === 'text') {
62
+ const raw = Array.isArray(q?.accepted) ? q.accepted : String(q?.accepted || '').split('\n');
63
+ out.accepted = [...new Set(raw.map(a => str(a, LIMITS.option)).filter(Boolean))].slice(0, LIMITS.accepted);
64
+ if (!out.accepted.length) errors[`q${n}`] ??= `${where} needs at least one accepted answer.`;
65
+ return out;
66
+ }
67
+ if (kind === 'truefalse') {
68
+ const isTrue = Array.isArray(q?.options) ? q.options.find(o => o?.id === 'true')?.correct !== false : true;
69
+ out.options = [{id: 'true', text: 'True', correct: isTrue}, {id: 'false', text: 'False', correct: !isTrue}];
70
+ return out;
71
+ }
72
+ const seen = new Set();
73
+ for (const o of (Array.isArray(q?.options) ? q.options : []).slice(0, LIMITS.options)) {
74
+ const text = str(o?.text, LIMITS.option);
75
+ if (!text) continue;
76
+ let id = idOf(o?.id);
77
+ while (seen.has(id)) id = shortId();
78
+ seen.add(id);
79
+ out.options.push({id, text, correct: o?.correct === true});
80
+ }
81
+ const right = out.options.filter(o => o.correct).length;
82
+ if (out.options.length < 2) errors[`q${n}`] ??= `${where} needs at least two answers to choose from.`;
83
+ else if (!right) errors[`q${n}`] ??= `${where}: tick the right answer.`;
84
+ else if (kind === 'single' && right > 1) errors[`q${n}`] ??= `${where} allows one answer but has ${right} ticked. Make it "more than one answer" or untick the others.`;
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * The quiz as the admin saved it, cleaned. `errors` keyed by field (`title`, `q3`...).
90
+ *
91
+ * @returns {{quiz: object, errors: Object<string, string>}}
92
+ */
93
+ export function cleanQuiz(input = {}) {
94
+ const errors = {};
95
+ const quiz = {
96
+ title: str(input.title, 120),
97
+ slug: slugify(input.slug || input.title),
98
+ intro: str(input.intro, 2000),
99
+ status: STATUSES.includes(input.status) ? input.status : 'draft',
100
+ opensAt: iso(input.opensAt),
101
+ closesAt: iso(input.closesAt),
102
+ timeLimit: int(input.timeLimit, 0, LIMITS.timeLimit),
103
+ questionSeconds: int(input.questionSeconds, 0, LIMITS.seconds),
104
+ shuffleQuestions: input.shuffleQuestions === true,
105
+ shuffleOptions: input.shuffleOptions === true,
106
+ draw: int(input.draw, 0, LIMITS.questions),
107
+ showAnswers: SHOW_ANSWERS.includes(input.showAnswers) ? input.showAnswers : 'end',
108
+ leaderboard: input.leaderboard !== false,
109
+ leaderboardSize: int(input.leaderboardSize ?? 10, 3, 100, 10),
110
+ attemptsPerName: int(input.attemptsPerName, 0, 100),
111
+ questions: []
112
+ };
113
+ if (!quiz.title) errors.title = 'Give the quiz a title.';
114
+ if (!quiz.slug) errors.slug = 'Give the quiz a short name for its address (letters and numbers).';
115
+ if (quiz.opensAt && quiz.closesAt && quiz.closesAt <= quiz.opensAt) errors.closesAt = 'It has to close after it opens.';
116
+ const raw = Array.isArray(input.questions) ? input.questions.slice(0, LIMITS.questions) : [];
117
+ const ids = new Set();
118
+ raw.forEach((q, i) => {
119
+ const clean = cleanQuestion(q, i + 1, errors);
120
+ while (ids.has(clean.id)) clean.id = shortId();
121
+ ids.add(clean.id);
122
+ quiz.questions.push(clean);
123
+ });
124
+ if (quiz.status === 'open' && !quiz.questions.length) errors.questions = 'Add at least one question before opening the quiz.';
125
+ if (quiz.draw && quiz.draw > quiz.questions.length) quiz.draw = 0;
126
+ return {quiz, errors};
127
+ }
128
+
129
+ /** Can people play it right now? `reason` is said to them when not. */
130
+ export function isOpen(quiz, now = Date.now()) {
131
+ if (!quiz || quiz.status === 'draft') return {open: false, reason: 'This quiz is not open yet.', state: 'draft'};
132
+ if (quiz.status === 'closed') return {open: false, reason: 'This quiz has closed.', state: 'closed'};
133
+ if (quiz.opensAt && Date.parse(quiz.opensAt) > now) return {open: false, reason: 'This quiz opens soon.', state: 'scheduled', opensAt: quiz.opensAt};
134
+ if (quiz.closesAt && Date.parse(quiz.closesAt) <= now) return {open: false, reason: 'This quiz has closed.', state: 'ended'};
135
+ if (!quiz.questions?.length) return {open: false, reason: 'This quiz has no questions yet.', state: 'empty'};
136
+ return {open: true, reason: '', state: 'open'};
137
+ }
138
+
139
+ /** Fisher-Yates with the caller's random (tests pass a fixed one). */
140
+ export function shuffle(list, rand = Math.random) {
141
+ const a = [...list];
142
+ for (let i = a.length - 1; i > 0; i--) {
143
+ const j = Math.floor(rand() * (i + 1));
144
+ [a[i], a[j]] = [a[j], a[i]];
145
+ }
146
+ return a;
147
+ }
148
+
149
+ /**
150
+ * This player's quiz: which questions (all, or `draw` of them), in what order,
151
+ * and each one's answers in what order. Fixed at the start so a reload shows
152
+ * the same thing.
153
+ */
154
+ export function planPlay(quiz, rand = Math.random) {
155
+ let qs = quiz.shuffleQuestions || quiz.draw ? shuffle(quiz.questions, rand) : [...quiz.questions];
156
+ if (quiz.draw) qs = qs.slice(0, quiz.draw);
157
+ return {
158
+ order: qs.map(q => ({q: q.id, opts: (quiz.shuffleOptions && q.kind !== 'truefalse' ? shuffle(q.options, rand) : q.options).map(o => o.id)})),
159
+ index: 0,
160
+ servedAt: [],
161
+ answers: []
162
+ };
163
+ }
164
+
165
+ export const maxScoreOf = (quiz, state) => state.order.reduce((sum, o) => sum + (quiz.questions.find(q => q.id === o.q)?.points ?? 0), 0);
166
+
167
+ /** A question's time limit in ms (its own, else the quiz's default), 0 = none. */
168
+ export const questionLimitMs = (quiz, q) => ((q?.seconds || quiz.questionSeconds || 0) * 1000);
169
+
170
+ /** What the player sees: the question and its choices - never which is right. */
171
+ export function publicQuestion(quiz, state) {
172
+ const slot = state.order[state.index];
173
+ const q = slot && quiz.questions.find(x => x.id === slot.q);
174
+ if (!q) return null;
175
+ const byId = new Map(q.options.map(o => [o.id, o]));
176
+ return {
177
+ id: q.id, kind: q.kind, text: q.text, image: q.image, points: q.points,
178
+ options: q.kind === 'text' ? [] : slot.opts.map(id => byId.get(id)).filter(Boolean).map(o => ({id: o.id, text: o.text})),
179
+ seconds: questionLimitMs(quiz, q) / 1000
180
+ };
181
+ }
182
+
183
+ /**
184
+ * One answer marked. `given`: an option id (single, truefalse), a list of ids
185
+ * (multiple), or what they typed (text). Anything else is simply wrong.
186
+ *
187
+ * @returns {{correct: boolean, given: string|string[]}}
188
+ */
189
+ export function mark(q, given) {
190
+ if (!q) return {correct: false, given: ''};
191
+ if (q.kind === 'text') {
192
+ const typed = String(given ?? '').slice(0, LIMITS.option);
193
+ const f = foldAnswer(typed);
194
+ return {correct: Boolean(f) && q.accepted.some(a => foldAnswer(a) === f), given: typed};
195
+ }
196
+ const ids = new Set(q.options.map(o => o.id));
197
+ if (q.kind === 'multiple') {
198
+ const picked = [...new Set((Array.isArray(given) ? given : [given]).map(String).filter(id => ids.has(id)))];
199
+ const right = q.options.filter(o => o.correct).map(o => o.id);
200
+ return {correct: picked.length === right.length && right.every(id => picked.includes(id)), given: picked};
201
+ }
202
+ const one = ids.has(String(given)) ? String(given) : '';
203
+ return {correct: Boolean(one) && q.options.find(o => o.id === one)?.correct === true, given: one};
204
+ }
205
+
206
+ /** An answer (option ids, or typed text) in words. */
207
+ export function answerText(q, given) {
208
+ if (!q) return '';
209
+ if (q.kind === 'text') return String(given ?? '');
210
+ const list = Array.isArray(given) ? given : [given];
211
+ return list.map(id => q.options.find(o => o.id === id)?.text).filter(Boolean).join(', ');
212
+ }
213
+
214
+ /** The right answer in words. */
215
+ export const rightText = (q) => (q?.kind === 'text' ? q.accepted[0] || '' : q?.options.filter(o => o.correct).map(o => o.text).join(', ') || '');
216
+
217
+ /** The quiz as a list row in the admin. */
218
+ export function summary(quiz, plays = []) {
219
+ const finished = plays.filter(p => p.status === 'finished');
220
+ return {
221
+ id: quiz.id, title: quiz.title, slug: quiz.slug, status: quiz.status, opensAt: quiz.opensAt, closesAt: quiz.closesAt,
222
+ questions: quiz.questions?.length || 0, plays: plays.length, finished: finished.length,
223
+ best: finished.reduce((m, p) => Math.max(m, p.score || 0), 0), updatedAt: quiz.updatedAt || ''
224
+ };
225
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Quiz - where quizzes and plays are kept: the collections `quiz-quizzes` and
3
+ * `quiz-plays` (made at every boot when missing; API access all off - a quiz
4
+ * holds its answers, a play the player's name).
5
+ *
6
+ * @module quiz/lib/store
7
+ */
8
+
9
+ import fs from 'node:fs/promises';
10
+ import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
11
+ import {PlayStore} from '../../_lib/games/plays.js';
12
+
13
+ export const QUIZZES = 'quiz-quizzes';
14
+ export const PLAYS = 'quiz-plays';
15
+
16
+ export const plays = new PlayStore(PLAYS, {order: 'score'});
17
+
18
+ export async function ensureCollections(log = console) {
19
+ for (const slug of [QUIZZES, PLAYS]) {
20
+ if (await getCollection(slug)) continue;
21
+ const schema = JSON.parse(await fs.readFile(new URL(`../collections/${slug}.json`, import.meta.url), 'utf8'));
22
+ await createCollection(schema);
23
+ log.info?.(`[quiz] Created collection: ${slug}`);
24
+ }
25
+ }
26
+
27
+ const flat = (e) => (e ? {id: e.id, ...e.data, createdAt: e.meta?.createdAt, updatedAt: e.meta?.updatedAt} : null);
28
+
29
+ let cache = null;
30
+ export async function allQuizzes() {
31
+ cache ??= listEntries(QUIZZES, {limit: 100000}).then(r => (r.entries || []).map(flat)).catch(() => []);
32
+ return cache;
33
+ }
34
+ const forget = () => { cache = null; };
35
+
36
+ export const getQuiz = async (id) => flat(await getEntry(QUIZZES, String(id)).catch(() => null));
37
+ export const quizBySlug = async (slug) => (await allQuizzes()).find(q => q.slug === String(slug)) || null;
38
+
39
+ /** A short name no other quiz has. */
40
+ export async function uniqueSlug(slug, selfId = '') {
41
+ const taken = new Set((await allQuizzes()).filter(q => q.id !== selfId).map(q => q.slug));
42
+ if (!taken.has(slug)) return slug;
43
+ for (let n = 2; ; n++) if (!taken.has(`${slug}-${n}`)) return `${slug}-${n}`;
44
+ }
45
+
46
+ export async function saveQuiz(id, data) {
47
+ const saved = id ? await updateEntry(QUIZZES, id, data) : await createEntry(QUIZZES, data);
48
+ forget();
49
+ return flat(saved);
50
+ }
51
+
52
+ export async function removeQuiz(id) {
53
+ await deleteEntry(QUIZZES, String(id));
54
+ forget();
55
+ return plays.reset(String(id));
56
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Quiz - the API (mounted at /api/plugins/quiz).
3
+ *
4
+ * Admin (the `quiz` permission; read to look, manage to change):
5
+ * GET /count sidebar badge: plays finished today
6
+ * GET /quizzes every quiz as a list row
7
+ * GET /quizzes/:id · POST /quizzes · PUT /quizzes/:id · DELETE /quizzes/:id
8
+ * POST /quizzes/:id/copy a copy, as a draft
9
+ * GET /quizzes/:id/plays · DELETE /quizzes/:id/plays · PUT /plays/:id · DELETE /plays/:id
10
+ *
11
+ * Players (no sign-in), under /public - the server keeps the time and marks every answer:
12
+ * GET /public/quiz/:slug title, intro, how many questions, the limits
13
+ * GET /public/quiz/:slug/leaderboard ?period=today|week|month|all&limit=
14
+ * POST /public/quiz/:slug/start {name} -> {playId, token} (the token is shown once)
15
+ * GET /public/play/:id where the play is now: the current question (no answer), time left
16
+ * POST /public/play/:id/answer {q, answer} -> marked; feedback when the quiz shows answers as you go
17
+ * GET /public/play/:id/result score, time, place, and the answers when the quiz shows them
18
+ * Each /public/play call carries the token in `X-Play-Token`.
19
+ *
20
+ * Shortcodes: [quiz id="<short name>" /] and [quiz-leaderboard id="<short name>" period="week" limit="10" /].
21
+ */
22
+
23
+ import {cleanPlayerName} from '../_lib/games/names.js';
24
+ import {boardQuery, fail, isBot, limited, mountPlayAdmin, todayBadge, tokenOf} from '../_lib/games/http.js';
25
+ import {answerText, cleanQuiz, isOpen, maxScoreOf, planPlay, rightText, summary} from './lib/quiz.js';
26
+ import {answer, catchUp, feedbackFor, serve, view} from './lib/engine.js';
27
+ import {allQuizzes, ensureCollections, getQuiz, plays, quizBySlug, removeQuiz, saveQuiz, uniqueSlug} from './lib/store.js';
28
+
29
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
30
+ const slugAttr = (v) => String(v || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 60);
31
+
32
+ function registerShortcodes(hooks) {
33
+ if (typeof hooks.registerShortcode !== 'function') return;
34
+ // Self-closing. The page's script (public/quiz.js, on every page via inject/body.html) mounts on the placeholder.
35
+ hooks.registerShortcode('quiz', (attrStr, _body, ctx) => {
36
+ const a = ctx.parseShortcodeAttrs(attrStr || '');
37
+ const slug = slugAttr(a.id || a.slug);
38
+ if (!slug) return '<p class="gm-muted">[quiz] needs id="the quiz\'s short name".</p>';
39
+ return `<div class="gm-app qz-app" data-quiz='${esc(JSON.stringify({slug}))}'><p class="gm-muted">Loading the quiz…</p></div>`;
40
+ });
41
+ hooks.registerShortcode('quiz-leaderboard', (attrStr, _body, ctx) => {
42
+ const a = ctx.parseShortcodeAttrs(attrStr || '');
43
+ const id = slugAttr(a.id || a.slug);
44
+ if (!id) return '<p class="gm-muted">[quiz-leaderboard] needs id="the quiz\'s short name".</p>';
45
+ const cfg = {id, period: String(a.period || 'all'), limit: Math.max(1, Math.min(100, Number(a.limit) || 10)), title: String(a.title || 'Leaderboard').slice(0, 80)};
46
+ return `<div class="qz-board" data-quiz-board='${esc(JSON.stringify(cfg))}'></div>`;
47
+ // First: the engine matches [quiz…/] by prefix, so [quiz] would otherwise take [quiz-leaderboard …/] too.
48
+ }, {priority: 5});
49
+ }
50
+
51
+ /** What anyone may know about a quiz before playing it. */
52
+ function publicInfo(quiz) {
53
+ const state = isOpen(quiz);
54
+ const count = quiz.draw || quiz.questions.length;
55
+ return {
56
+ slug: quiz.slug, title: quiz.title, intro: quiz.intro, open: state.open, reason: state.reason, opensAt: state.opensAt || '',
57
+ questions: count, timeLimit: quiz.timeLimit, questionSeconds: quiz.questionSeconds,
58
+ perQuestionTimes: quiz.questions.some(q => q.seconds > 0),
59
+ showAnswers: quiz.showAnswers, leaderboard: quiz.leaderboard, leaderboardSize: quiz.leaderboardSize,
60
+ attemptsPerName: quiz.attemptsPerName
61
+ };
62
+ }
63
+
64
+ /** The review at the end: every question, their answer and the right one - only when the quiz shows answers. */
65
+ function review(quiz, play) {
66
+ if (quiz.showAnswers === 'never') return null;
67
+ return play.state.order.map((slot, i) => {
68
+ const q = quiz.questions.find(x => x.id === slot.q);
69
+ const a = play.state.answers[i];
70
+ if (!q) return null;
71
+ return {
72
+ text: q.text, image: q.image, points: q.points,
73
+ yours: a ? (a.given == null ? '' : answerText(q, a.given)) : '',
74
+ right: rightText(q), correct: a?.correct === true, late: a?.late === true, explanation: q.explanation || ''
75
+ };
76
+ }).filter(Boolean);
77
+ }
78
+
79
+ export default async function quizPlugin(fastify, options) {
80
+ const {authenticate, requirePermission} = options.auth;
81
+ const hooks = options.hooks || {};
82
+ const can = (action) => ({preHandler: [authenticate, requirePermission('quiz', action)]});
83
+
84
+ await ensureCollections(fastify.log);
85
+ registerShortcodes(hooks);
86
+
87
+ if (typeof hooks.registerNotificationSource === 'function') {
88
+ try {
89
+ hooks.registerNotificationSource({id: 'top-scores', label: 'Quiz top scores', severity: 'info', defaultOn: true,
90
+ description: 'Someone has gone top of a quiz\'s all-time leaderboard.'});
91
+ } catch { /* older engine */ }
92
+ }
93
+
94
+ // ---- Admin ------------------------------------------------------------------
95
+
96
+ fastify.get('/count', can('read'), async () => todayBadge(plays, 'quiz play'));
97
+
98
+ fastify.get('/quizzes', can('read'), async () => {
99
+ const everyPlay = await plays.all();
100
+ return {quizzes: (await allQuizzes()).map(q => summary(q, everyPlay.filter(p => p.gameId === q.id)))
101
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))};
102
+ });
103
+
104
+ fastify.get('/quizzes/:id', can('read'), async (request, reply) => {
105
+ const quiz = await getQuiz(request.params.id);
106
+ return quiz || fail(reply, 404, 'That quiz is not here any more.');
107
+ });
108
+
109
+ async function save(request, reply, id) {
110
+ const current = id ? await getQuiz(id) : null;
111
+ if (id && !current) return fail(reply, 404, 'That quiz is not here any more.');
112
+ const {quiz, errors} = cleanQuiz(request.body || {});
113
+ if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
114
+ quiz.slug = await uniqueSlug(quiz.slug, id || '');
115
+ const saved = await saveQuiz(id, quiz);
116
+ return id ? saved : reply.code(201).send(saved);
117
+ }
118
+
119
+ fastify.post('/quizzes', can('manage'), (request, reply) => save(request, reply, ''));
120
+ fastify.put('/quizzes/:id', can('manage'), (request, reply) => save(request, reply, request.params.id));
121
+
122
+ fastify.post('/quizzes/:id/copy', can('manage'), async (request, reply) => {
123
+ const quiz = await getQuiz(request.params.id);
124
+ if (!quiz) return fail(reply, 404, 'That quiz is not here any more.');
125
+ const {id, createdAt, updatedAt, ...rest} = quiz;
126
+ const copy = {...rest, title: `${quiz.title} (copy)`.slice(0, 120), status: 'draft', slug: await uniqueSlug(quiz.slug)};
127
+ return reply.code(201).send(await saveQuiz('', copy));
128
+ });
129
+
130
+ fastify.delete('/quizzes/:id', can('manage'), async (request, reply) => {
131
+ if (!await getQuiz(request.params.id)) return fail(reply, 404, 'That quiz is not here any more.');
132
+ return {ok: true, playsRemoved: await removeQuiz(request.params.id)};
133
+ });
134
+
135
+ mountPlayAdmin(fastify, {store: plays, base: '/quizzes', can, exists: async (id) => Boolean(await getQuiz(id))});
136
+
137
+ // ---- Players ----------------------------------------------------------------
138
+
139
+ /** A quiz the public may see: anything but a draft. */
140
+ const shownQuiz = async (slug) => {
141
+ const quiz = await quizBySlug(slug);
142
+ return quiz && quiz.status !== 'draft' ? quiz : null;
143
+ };
144
+
145
+ fastify.get('/public/quiz/:slug', limited(120), async (request, reply) => {
146
+ const quiz = await shownQuiz(request.params.slug);
147
+ if (!quiz) return fail(reply, 404, 'There is no quiz here.');
148
+ return publicInfo(quiz);
149
+ });
150
+
151
+ fastify.get('/public/quiz/:slug/leaderboard', limited(120), async (request, reply) => {
152
+ const quiz = await shownQuiz(request.params.slug);
153
+ if (!quiz || !quiz.leaderboard) return fail(reply, 404, 'There is no leaderboard here.');
154
+ const {period, limit} = boardQuery(request.query, quiz.leaderboardSize);
155
+ return {title: quiz.title, period, rows: await plays.board(quiz.id, {period, limit})};
156
+ });
157
+
158
+ fastify.post('/public/quiz/:slug/start', limited(20, 10), async (request, reply) => {
159
+ const quiz = await shownQuiz(request.params.slug);
160
+ if (!quiz) return fail(reply, 404, 'There is no quiz here.');
161
+ const state = isOpen(quiz);
162
+ if (!state.open) return fail(reply, 409, state.reason, {code: 'closed'});
163
+ if (isBot(request.body)) return fail(reply, 400, 'Something went wrong. Please try again.');
164
+ const {name, error} = cleanPlayerName(request.body?.name);
165
+ if (error) return fail(reply, 400, error, {code: 'name'});
166
+ if (quiz.attemptsPerName && await plays.attemptsBy(quiz.id, name) >= quiz.attemptsPerName) {
167
+ return fail(reply, 409, quiz.attemptsPerName === 1 ? `${name} has already had a go at this quiz.` : `${name} has already had ${quiz.attemptsPerName} goes at this quiz.`, {code: 'attempts'});
168
+ }
169
+ const plan = planPlay(quiz);
170
+ const play = await plays.start({gameId: quiz.id, name, maxScore: maxScoreOf(quiz, plan), state: plan});
171
+ return reply.code(201).send({playId: play.id, token: play.token, name, total: plan.order.length, maxScore: play.maxScore});
172
+ });
173
+
174
+ /**
175
+ * The play behind a request, brought up to date with the clock (and saved
176
+ * when that changed it). Null after answering with the reason.
177
+ */
178
+ async function livePlay(request, reply, {serveNow = false} = {}) {
179
+ const opened = await plays.open(request.params.id, tokenOf(request));
180
+ if (!opened) { fail(reply, 404, 'That game is not here any more. Start again.', {code: 'gone'}); return null; }
181
+ const quiz = await getQuiz(opened.gameId);
182
+ if (!quiz) { fail(reply, 404, 'That quiz is not here any more.', {code: 'gone'}); return null; }
183
+ const now = Date.now();
184
+ const play = await plays.change(opened.id, (p) => {
185
+ const a = catchUp(quiz, p, now);
186
+ const b = serveNow && serve(quiz, p, now);
187
+ return a || b ? p : null;
188
+ });
189
+ if (play.status === 'finished' && opened.status === 'playing') await afterFinish(quiz, play);
190
+ return {quiz, play, now};
191
+ }
192
+
193
+ /** A new top of the all-time board tells the admins. */
194
+ async function afterFinish(quiz, play) {
195
+ try {
196
+ if (!quiz.leaderboard || await plays.placeOf(play, 'all') !== 1) return;
197
+ hooks.notify?.({
198
+ title: `New top score on "${quiz.title}"`,
199
+ body: `${play.name}: ${play.score} of ${play.maxScore} in ${Math.round(play.timeMs / 1000)} seconds`,
200
+ severity: 'info', link: `#/plugins/quiz/plays/${quiz.id}`, source: 'top-scores', dedupeKey: `quiz-top:${play.id}`
201
+ });
202
+ } catch { /* a notice never breaks a play */ }
203
+ }
204
+
205
+ fastify.get('/public/play/:id', limited(240), async (request, reply) => {
206
+ const live = await livePlay(request, reply, {serveNow: true});
207
+ if (!live) return reply;
208
+ return view(live.quiz, live.play, live.now);
209
+ });
210
+
211
+ fastify.post('/public/play/:id/answer', limited(240), async (request, reply) => {
212
+ const live = await livePlay(request, reply);
213
+ if (!live) return reply;
214
+ const {quiz} = live;
215
+ let outcome = null;
216
+ const play = await plays.change(live.play.id, (p) => {
217
+ outcome = answer(quiz, p, request.body?.q, request.body?.answer, Date.now());
218
+ return outcome.error ? null : p;
219
+ });
220
+ if (outcome?.error) return fail(reply, 409, outcome.error, {code: outcome.code || 'finished', play: view(quiz, play)});
221
+ if (play.status === 'finished') await afterFinish(quiz, play);
222
+ return {correct: outcome.correct, late: outcome.late, points: outcome.points, feedback: feedbackFor(quiz, outcome.q, outcome), play: view(quiz, play)};
223
+ });
224
+
225
+ fastify.get('/public/play/:id/result', limited(120), async (request, reply) => {
226
+ const live = await livePlay(request, reply);
227
+ if (!live) return reply;
228
+ const {quiz, play} = live;
229
+ if (play.status !== 'finished') return fail(reply, 409, 'This play has not finished yet.', {code: 'playing'});
230
+ return {
231
+ name: play.name, score: play.score, maxScore: play.maxScore, timeMs: play.timeMs, timeUp: play.state.timeUp === true,
232
+ correct: play.state.answers.filter(a => a.correct).length, total: play.state.order.length,
233
+ hidden: play.hidden === true,
234
+ place: quiz.leaderboard && !play.hidden ? await plays.placeOf(play, 'all') : 0,
235
+ placeToday: quiz.leaderboard && !play.hidden ? await plays.placeOf(play, 'today') : 0,
236
+ leaderboard: quiz.leaderboard,
237
+ review: review(quiz, play)
238
+ };
239
+ });
240
+ }
241
+