domma-cms 1.8.31 → 1.8.33
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.
- package/CHANGELOG.md +12 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -0
- package/plugins/_lib/admin/games/kit.js +446 -0
- package/plugins/_lib/games/http.js +92 -0
- package/plugins/_lib/games/names.js +42 -0
- package/plugins/_lib/games/plays.js +176 -0
- package/plugins/_lib/games/ranking.js +68 -0
- package/plugins/_lib/games/timing.js +27 -0
- package/plugins/_lib/public/games/format.js +38 -0
- package/plugins/_lib/public/games/games.css +1 -0
- package/plugins/_lib/public/games/games.js +204 -0
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -0
- package/plugins/crossword/admin/templates/crossword.html +1 -0
- package/plugins/crossword/admin/views/crossword.js +449 -0
- package/plugins/crossword/collections/crossword-plays.json +20 -0
- package/plugins/crossword/collections/crossword-puzzles.json +17 -0
- package/plugins/crossword/config.js +6 -0
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -0
- package/plugins/crossword/lib/crossword.js +170 -0
- package/plugins/crossword/lib/store.js +56 -0
- package/plugins/crossword/plugin.js +274 -0
- package/plugins/crossword/plugin.json +79 -0
- package/plugins/crossword/public/crossword.css +36 -0
- package/plugins/crossword/public/crossword.js +519 -0
- package/plugins/crossword/public/lib/layout.js +199 -0
- package/plugins/free-tier.lock.json +43 -1
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -0
- package/plugins/quiz/admin/templates/quiz.html +1 -0
- package/plugins/quiz/admin/views/quiz.js +530 -0
- package/plugins/quiz/collections/quiz-plays.json +20 -0
- package/plugins/quiz/collections/quiz-quizzes.json +17 -0
- package/plugins/quiz/config.js +6 -0
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -0
- package/plugins/quiz/lib/engine.js +115 -0
- package/plugins/quiz/lib/quiz.js +225 -0
- package/plugins/quiz/lib/store.js +56 -0
- package/plugins/quiz/plugin.js +275 -0
- package/plugins/quiz/plugin.json +79 -0
- package/plugins/quiz/public/quiz.css +36 -0
- package/plugins/quiz/public/quiz.js +372 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Games - the name a player puts on the leaderboard. PURE.
|
|
3
|
+
*
|
|
4
|
+
* Names are typed by strangers and shown to everyone, so they are cleaned
|
|
5
|
+
* hard: letters (any script), digits, spaces and . ' - _ only, 2 to 24
|
|
6
|
+
* characters, and a short list of words that never go on a public board.
|
|
7
|
+
* A name is compared by its key (lower case, accents and spacing folded), so
|
|
8
|
+
* "Sam", "sam " and "SÁM" are one player for attempts and best-per-name.
|
|
9
|
+
*
|
|
10
|
+
* @module _lib/games/names
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const NAME_MIN = 2;
|
|
14
|
+
export const NAME_MAX = 24;
|
|
15
|
+
|
|
16
|
+
/** Folded to a-z first, so leetspeak and accents do not slip past. Kept short on purpose. */
|
|
17
|
+
const BLOCKED = ['fuck', 'shit', 'cunt', 'wank', 'twat', 'bitch', 'bastard', 'nigg', 'fag', 'slut', 'whore', 'rape', 'nazi', 'hitler', 'penis', 'vagina', 'dick', 'cock', 'pussy', 'arse', 'asshole', 'admin', 'moderator'];
|
|
18
|
+
|
|
19
|
+
const LEET = {0: 'o', 1: 'i', 3: 'e', 4: 'a', 5: 's', 7: 't', '@': 'a', $: 's'};
|
|
20
|
+
|
|
21
|
+
/** Lower case, accents gone, spacing collapsed - the identity of a name. */
|
|
22
|
+
export function nameKey(name) {
|
|
23
|
+
return String(name ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function blocked(name) {
|
|
27
|
+
const folded = nameKey(name).replace(/[013457@$]/g, c => LEET[c] || c).replace(/[^a-z]/g, '');
|
|
28
|
+
return BLOCKED.some(w => folded.includes(w));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {*} raw - what the player typed
|
|
33
|
+
* @returns {{name: string, error: string}} name is '' whenever error is set
|
|
34
|
+
*/
|
|
35
|
+
export function cleanPlayerName(raw) {
|
|
36
|
+
const name = String(raw ?? '').normalize('NFC').replace(/\s+/g, ' ').trim();
|
|
37
|
+
if (name.length < NAME_MIN) return {name: '', error: `Please type a name of at least ${NAME_MIN} characters.`};
|
|
38
|
+
if (name.length > NAME_MAX) return {name: '', error: `Please keep your name to ${NAME_MAX} characters or fewer.`};
|
|
39
|
+
if (!/^[\p{L}\p{N}][\p{L}\p{N} .'_-]*$/u.test(name)) return {name: '', error: 'Letters, numbers, spaces and . \' - _ only, please.'};
|
|
40
|
+
if (blocked(name)) return {name: '', error: 'Please choose another name.'};
|
|
41
|
+
return {name, error: ''};
|
|
42
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Games - the plays of one game Tool, in a collection of its own
|
|
3
|
+
* (`quiz-plays`, `crossword-plays`).
|
|
4
|
+
*
|
|
5
|
+
* A play is one person's go at one game: who (the name they typed), when it
|
|
6
|
+
* started and finished BY THE SERVER'S CLOCK, the score, and the game's own
|
|
7
|
+
* state (`state` - which question they are on, penalties...). Its `token` is
|
|
8
|
+
* the player's key to it: handed over once, at the start, and asked for on
|
|
9
|
+
* every move, so nobody can answer for someone else's play. The token never
|
|
10
|
+
* leaves the server again - not to the board, not to the admin.
|
|
11
|
+
*
|
|
12
|
+
* Writes go one at a time (`withLock`), so two answers sent at once cannot
|
|
13
|
+
* both be marked.
|
|
14
|
+
*
|
|
15
|
+
* A TEST play (`test: true`) is an admin trying the game from its Tool. It
|
|
16
|
+
* plays exactly like any other, but it is nobody's go: it never counts for a
|
|
17
|
+
* board, a place, the goes-per-name limit, the badge or the admin's list of
|
|
18
|
+
* plays, and the next test of the same game clears it away.
|
|
19
|
+
*
|
|
20
|
+
* @module _lib/games/plays
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {randomBytes, timingSafeEqual} from 'node:crypto';
|
|
24
|
+
import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
|
|
25
|
+
import {nameKey} from './names.js';
|
|
26
|
+
import {placeOf, rankPlays} from './ranking.js';
|
|
27
|
+
import {ABANDON_MS} from './timing.js';
|
|
28
|
+
|
|
29
|
+
const flat = (e) => (e ? {id: e.id, ...e.data} : null);
|
|
30
|
+
|
|
31
|
+
export class PlayStore {
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} slug - the plays collection
|
|
34
|
+
* @param {{order?: 'score'|'time'}} [opts] - how the board is ordered
|
|
35
|
+
*/
|
|
36
|
+
constructor(slug, {order = 'score'} = {}) {
|
|
37
|
+
this.slug = slug;
|
|
38
|
+
this.order = order;
|
|
39
|
+
this.queue = Promise.resolve();
|
|
40
|
+
this.cache = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Run `fn` after every write before it has finished. */
|
|
44
|
+
withLock(fn) {
|
|
45
|
+
const run = this.queue.then(fn, fn);
|
|
46
|
+
this.queue = run.catch(() => {});
|
|
47
|
+
return run;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async all() {
|
|
51
|
+
if (!this.cache) {
|
|
52
|
+
this.cache = listEntries(this.slug, {limit: 100000})
|
|
53
|
+
.then(r => (r.entries || []).map(flat))
|
|
54
|
+
.catch(() => []);
|
|
55
|
+
}
|
|
56
|
+
return this.cache;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
forget() { this.cache = null; }
|
|
60
|
+
|
|
61
|
+
/** The real plays of a game - test plays left out. */
|
|
62
|
+
async forGame(gameId) {
|
|
63
|
+
return (await this.all()).filter(p => p.gameId === gameId && p.test !== true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every play of a game, tests included. */
|
|
67
|
+
async everyForGame(gameId) {
|
|
68
|
+
return (await this.all()).filter(p => p.gameId === gameId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async get(id) {
|
|
72
|
+
return flat(await getEntry(this.slug, String(id)).catch(() => null));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The play, if `token` is its key; else null. */
|
|
76
|
+
async open(id, token) {
|
|
77
|
+
const play = await this.get(id);
|
|
78
|
+
if (!play || !PlayStore.tokenOk(play.token, token)) return null;
|
|
79
|
+
return play;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static tokenOk(stored, given) {
|
|
83
|
+
const a = Buffer.from(String(stored || ''));
|
|
84
|
+
const b = Buffer.from(String(given || ''));
|
|
85
|
+
return a.length > 0 && a.length === b.length && timingSafeEqual(a, b);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How many goes this name has had at this game (any state but hidden ones the admin removed). */
|
|
89
|
+
async attemptsBy(gameId, name) {
|
|
90
|
+
const key = nameKey(name);
|
|
91
|
+
return (await this.forGame(gameId)).filter(p => p.nameKey === key).length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {{gameId: string, name: string, maxScore?: number, state?: object, userId?: string, test?: boolean}} play
|
|
96
|
+
* `test`: an admin trying the game - see the module comment
|
|
97
|
+
* @returns {Promise<object>} the play, token included - for the player, once
|
|
98
|
+
*/
|
|
99
|
+
async start({gameId, name, maxScore = 0, state = {}, userId = '', test = false}) {
|
|
100
|
+
return this.withLock(async () => {
|
|
101
|
+
const now = new Date().toISOString();
|
|
102
|
+
const saved = await createEntry(this.slug, {
|
|
103
|
+
gameId, name, nameKey: nameKey(name), userId,
|
|
104
|
+
status: 'playing', startedAt: now, finishedAt: '', timeMs: 0, score: 0, maxScore,
|
|
105
|
+
hidden: false, ...(test ? {test: true} : {}), token: randomBytes(24).toString('base64url'), state
|
|
106
|
+
}, {source: test ? 'admin' : 'public'});
|
|
107
|
+
this.forget();
|
|
108
|
+
return flat(saved);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Re-read the play inside the lock, let `fn` change it, write it back. `fn` returning null writes nothing. */
|
|
113
|
+
async change(id, fn) {
|
|
114
|
+
return this.withLock(async () => {
|
|
115
|
+
const current = await this.get(id);
|
|
116
|
+
if (!current) return null;
|
|
117
|
+
const next = await fn(structuredClone(current));
|
|
118
|
+
if (!next) return current;
|
|
119
|
+
const {id: _id, ...data} = next;
|
|
120
|
+
const saved = flat(await updateEntry(this.slug, id, data));
|
|
121
|
+
this.forget();
|
|
122
|
+
return saved;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Finished now: the time is the server's, plus any penalty the game added. */
|
|
127
|
+
static finish(play, {score = play.score, penaltyMs = 0, status = 'finished', now = Date.now()} = {}) {
|
|
128
|
+
play.status = status;
|
|
129
|
+
play.finishedAt = new Date(now).toISOString();
|
|
130
|
+
play.score = score;
|
|
131
|
+
play.timeMs = Math.max(0, now - Date.parse(play.startedAt)) + Math.max(0, penaltyMs);
|
|
132
|
+
return play;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async setHidden(id, hidden) {
|
|
136
|
+
return this.change(id, (p) => { p.hidden = hidden === true; return p; });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async remove(id) {
|
|
140
|
+
await this.withLock(() => deleteEntry(this.slug, String(id)));
|
|
141
|
+
this.forget();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Every play of a game gone: a fresh board. Answers how many real plays went. */
|
|
145
|
+
async reset(gameId) {
|
|
146
|
+
const mine = await this.everyForGame(gameId);
|
|
147
|
+
await this.withLock(async () => { for (const p of mine) await deleteEntry(this.slug, p.id).catch(() => {}); });
|
|
148
|
+
this.forget();
|
|
149
|
+
return mine.filter(p => p.test !== true).length;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A game's test plays gone (before the next test starts). */
|
|
153
|
+
async clearTests(gameId) {
|
|
154
|
+
const tests = (await this.everyForGame(gameId)).filter(p => p.test === true);
|
|
155
|
+
if (!tests.length) return 0;
|
|
156
|
+
await this.withLock(async () => { for (const p of tests) await deleteEntry(this.slug, p.id).catch(() => {}); });
|
|
157
|
+
this.forget();
|
|
158
|
+
return tests.length;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async board(gameId, {period = 'all', limit = 10} = {}) {
|
|
162
|
+
return rankPlays(await this.forGame(gameId), {order: this.order, period, limit});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async placeOf(play, period = 'all') {
|
|
166
|
+
return placeOf(play, await this.forGame(play.gameId), {order: this.order, period});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A play as the admin sees it: everything but the key and the game's inner state. */
|
|
170
|
+
static adminView(p) {
|
|
171
|
+
if (!p) return p;
|
|
172
|
+
const {token, state, ...rest} = p;
|
|
173
|
+
const open = rest.status === 'playing' && Date.now() - Date.parse(rest.startedAt) > ABANDON_MS;
|
|
174
|
+
return {...rest, status: open ? 'abandoned' : rest.status};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Games - who is top. PURE: plays in, leaderboard rows out.
|
|
3
|
+
*
|
|
4
|
+
* Only finished, visible plays count. A player (by name key) appears once,
|
|
5
|
+
* with their best play, so one person replaying cannot fill the board. Order:
|
|
6
|
+
*
|
|
7
|
+
* 'score' higher score first, then the faster time, then whoever got there first (Quiz)
|
|
8
|
+
* 'time' the faster time first, then whoever got there first (Crossword - every finisher solved it)
|
|
9
|
+
*
|
|
10
|
+
* Periods are calendar ones in the server's own day: today, this week (from
|
|
11
|
+
* Monday), this month, all time.
|
|
12
|
+
*
|
|
13
|
+
* @module _lib/games/ranking
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {cleanPeriod} from '../public/games/format.js';
|
|
17
|
+
|
|
18
|
+
/** The instant a period starts, or 0 for all time. */
|
|
19
|
+
export function periodStart(period, now = Date.now()) {
|
|
20
|
+
const d = new Date(now);
|
|
21
|
+
d.setHours(0, 0, 0, 0);
|
|
22
|
+
switch (cleanPeriod(period)) {
|
|
23
|
+
case 'today': return d.getTime();
|
|
24
|
+
case 'week': d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return d.getTime();
|
|
25
|
+
case 'month': d.setDate(1); return d.getTime();
|
|
26
|
+
default: return 0;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** a before b? - the board's order. */
|
|
31
|
+
export function comparePlays(a, b, order = 'score') {
|
|
32
|
+
if (order === 'score' && (b.score ?? 0) !== (a.score ?? 0)) return (b.score ?? 0) - (a.score ?? 0);
|
|
33
|
+
if ((a.timeMs ?? Infinity) !== (b.timeMs ?? Infinity)) return (a.timeMs ?? Infinity) - (b.timeMs ?? Infinity);
|
|
34
|
+
return String(a.finishedAt).localeCompare(String(b.finishedAt));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object[]} plays - {id, name, nameKey, status, hidden, score, timeMs, finishedAt}
|
|
39
|
+
* @param {{order?: 'score'|'time', period?: string, now?: number, limit?: number, bestPerName?: boolean}} [opts]
|
|
40
|
+
* @returns {{id: string, rank: number, name: string, score: number, timeMs: number, finishedAt: string}[]}
|
|
41
|
+
*/
|
|
42
|
+
export function rankPlays(plays, {order = 'score', period = 'all', now = Date.now(), limit = 10, bestPerName = true} = {}) {
|
|
43
|
+
const from = periodStart(period, now);
|
|
44
|
+
const counted = (plays || []).filter(p => p && p.status === 'finished' && p.hidden !== true
|
|
45
|
+
&& Date.parse(p.finishedAt) >= from).sort((a, b) => comparePlays(a, b, order));
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const rows = [];
|
|
48
|
+
for (const p of counted) {
|
|
49
|
+
const key = p.nameKey || String(p.name).toLowerCase();
|
|
50
|
+
if (bestPerName && seen.has(key)) continue;
|
|
51
|
+
seen.add(key);
|
|
52
|
+
rows.push({id: p.id, rank: 0, name: p.name, score: p.score ?? 0, timeMs: p.timeMs ?? 0, finishedAt: p.finishedAt});
|
|
53
|
+
}
|
|
54
|
+
// Equal score and time share a rank ("=2nd"); the next is placed after them.
|
|
55
|
+
rows.forEach((r, i) => {
|
|
56
|
+
const prev = rows[i - 1];
|
|
57
|
+
r.rank = prev && (order === 'time' || prev.score === r.score) && prev.timeMs === r.timeMs ? prev.rank : i + 1;
|
|
58
|
+
});
|
|
59
|
+
return Math.max(0, Number(limit) || 0) ? rows.slice(0, Math.max(1, Math.min(100, Number(limit)))) : rows;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Where one play stands on its board for a period, counting it even when it is not the player's best. */
|
|
63
|
+
export function placeOf(play, plays, {order = 'score', period = 'all', now = Date.now()} = {}) {
|
|
64
|
+
if (!play || play.status !== 'finished') return 0;
|
|
65
|
+
const others = (plays || []).filter(p => p.id !== play.id);
|
|
66
|
+
const board = rankPlays([...others, play], {order, period, now, limit: 0, bestPerName: false});
|
|
67
|
+
return board.find(r => r.id === play.id)?.rank || 0;
|
|
68
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Games - how the server keeps time. PURE.
|
|
3
|
+
*
|
|
4
|
+
* Every clock that decides anything is the server's: when a play started, when
|
|
5
|
+
* a question was put in front of the player, when the answer arrived. The
|
|
6
|
+
* browser's countdown is only a picture of these. A little grace covers the
|
|
7
|
+
* network, so an answer sent in the last second still counts.
|
|
8
|
+
*
|
|
9
|
+
* @module _lib/games/timing
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A late answer is still on time within this much, for the network. */
|
|
13
|
+
export const GRACE_MS = 3000;
|
|
14
|
+
|
|
15
|
+
/** A play still open this long after it started counts as abandoned. */
|
|
16
|
+
export const ABANDON_MS = 24 * 3600 * 1000;
|
|
17
|
+
|
|
18
|
+
/** Milliseconds left before `startIso + limitMs`, never below 0; null when there is no limit. */
|
|
19
|
+
export function leftOf(startIso, limitMs, now = Date.now()) {
|
|
20
|
+
if (!(limitMs > 0)) return null;
|
|
21
|
+
return Math.max(0, Date.parse(startIso) + limitMs - now);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Past `startIso + limitMs` by more than the grace? */
|
|
25
|
+
export function overdue(startIso, limitMs, now = Date.now()) {
|
|
26
|
+
return limitMs > 0 && now > Date.parse(startIso) + limitMs + GRACE_MS;
|
|
27
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Games - words and numbers shared by the server and the browser (Quiz,
|
|
3
|
+
* Crossword). Served from /plugins/_lib/public/games/format.js and imported by
|
|
4
|
+
* path on the server, so it must stay free of I/O and of DOM.
|
|
5
|
+
*
|
|
6
|
+
* @module _lib/public/games/format
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** "0:42", "3:05", "1:02:09" from milliseconds. Tenths only under a minute, when asked. */
|
|
10
|
+
export function fmtDuration(ms, {tenths = false} = {}) {
|
|
11
|
+
const n = Math.max(0, Number(ms) || 0);
|
|
12
|
+
if (tenths && n < 60000) return `${(Math.floor(n / 100) / 10).toFixed(1)}s`;
|
|
13
|
+
const total = Math.floor(n / 1000);
|
|
14
|
+
const h = Math.floor(total / 3600);
|
|
15
|
+
const m = Math.floor((total % 3600) / 60);
|
|
16
|
+
const s = String(total % 60).padStart(2, '0');
|
|
17
|
+
return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** "1st", "2nd", "3rd", "11th". */
|
|
21
|
+
export function ordinal(n) {
|
|
22
|
+
const v = Math.abs(Math.round(Number(n) || 0));
|
|
23
|
+
const tail = v % 100 >= 11 && v % 100 <= 13 ? 'th' : ({1: 'st', 2: 'nd', 3: 'rd'}[v % 10] || 'th');
|
|
24
|
+
return `${v}${tail}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The leaderboard periods, in the order the switcher shows them. */
|
|
28
|
+
export const PERIODS = [
|
|
29
|
+
{key: 'today', label: 'Today'},
|
|
30
|
+
{key: 'week', label: 'This week'},
|
|
31
|
+
{key: 'month', label: 'This month'},
|
|
32
|
+
{key: 'all', label: 'All time'}
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const PERIOD_KEYS = PERIODS.map(p => p.key);
|
|
36
|
+
|
|
37
|
+
/** A period key, or 'all'. */
|
|
38
|
+
export const cleanPeriod = (p) => (PERIOD_KEYS.includes(String(p)) ? String(p) : 'all');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.gm-app{--gm-gap: 1rem;--gm-radius: var(--dm-radius, 10px);--gm-line: var(--dm-border, rgba(127, 127, 127, .3));--gm-soft: var(--dm-surface, rgba(127, 127, 127, .08));--gm-accent: var(--dm-primary, #3b82f6);--gm-good: var(--dm-success, #16a34a);--gm-bad: var(--dm-danger, #dc2626);--gm-warn: var(--dm-warning, #d97706);max-width:760px;margin:1.5rem auto;color:var(--dm-text, inherit)}.gm-app [hidden],.gm-board [hidden]{display:none!important}.gm-card{border:1px solid var(--gm-line);border-radius:var(--gm-radius);background:var(--dm-surface-raised, var(--dm-background, transparent));padding:1.25rem 1.5rem;margin-bottom:var(--gm-gap)}.gm-title{margin:0 0 .5rem;font-size:1.6rem;line-height:1.2}.gm-intro{margin:0 0 1rem;white-space:pre-line}.gm-muted{color:var(--dm-text-muted, #6b7280);margin:.25rem 0}.gm-facts{display:flex;flex-wrap:wrap;gap:.5rem 1.25rem;margin:0 0 1rem;padding:0;list-style:none;color:var(--dm-text-muted, #6b7280)}.gm-facts li:before{content:"\2022 "}.gm-facts li:first-child:before{content:""}.gm-start{display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end}.gm-field{display:flex;flex-direction:column;gap:.3rem;flex:1 1 220px}.gm-field label{font-weight:600}.gm-input{font:inherit;padding:.6rem .75rem;border:1px solid var(--gm-line);border-radius:8px;background:var(--dm-input-bg, var(--dm-background, #fff));color:inherit;width:100%;box-sizing:border-box}.gm-input:focus{outline:2px solid var(--gm-accent);outline-offset:1px}.gm-hp{position:absolute!important;left:-9999px!important;width:1px;height:1px;overflow:hidden}.gm-error{color:var(--gm-bad);margin:.5rem 0 0;font-weight:600}.gm-note{margin:.5rem 0 0}.gm-bar{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:1rem}.gm-progress{font-weight:600}.gm-clock{font-variant-numeric:tabular-nums;font-weight:700;padding:.2rem .65rem;border-radius:999px;background:var(--gm-soft);min-width:4.5rem;text-align:center}.gm-clock.is-low{background:var(--gm-bad);color:#fff}.gm-track{height:6px;border-radius:999px;background:var(--gm-soft);overflow:hidden;margin-bottom:1rem}.gm-track>span{display:block;height:100%;background:var(--gm-accent);transition:width .2s linear}.gm-actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:1rem}.gm-result-score{font-size:2.4rem;font-weight:800;margin:.25rem 0}.gm-result-place{font-size:1.15rem;margin:0 0 .75rem}.gm-board{border:1px solid var(--gm-line, rgba(127, 127, 127, .3));border-radius:var(--gm-radius, 10px);padding:1rem 1.25rem;margin:0 0 1rem}.gm-board-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.5rem}.gm-board-title{margin:0;font-size:1.2rem}.gm-periods{display:flex;flex-wrap:wrap;gap:.25rem}.gm-period{font:inherit;font-size:.85rem;padding:.2rem .6rem;border-radius:999px;cursor:pointer;border:1px solid var(--gm-line, rgba(127, 127, 127, .3));background:transparent;color:inherit}.gm-period.is-on{background:var(--dm-primary, #3b82f6);border-color:var(--dm-primary, #3b82f6);color:#fff}.gm-board-list{list-style:none;margin:0;padding:0}.gm-board-row{display:grid;grid-template-columns:3.2rem 1fr auto auto;gap:.75rem;align-items:center;padding:.45rem .5rem;border-bottom:1px solid var(--gm-line, rgba(127, 127, 127, .2))}.gm-board-row:last-child{border-bottom:0}.gm-board-row:nth-child(1) .gm-rank{color:#c9a227}.gm-board-row:nth-child(2) .gm-rank{color:#9aa3ad}.gm-board-row:nth-child(3) .gm-rank{color:#b0703a}.gm-board-row.is-mine{background:color-mix(in srgb,var(--dm-primary, #3b82f6) 14%,transparent);border-radius:6px;font-weight:700}.gm-rank{font-weight:800}.gm-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gm-score,.gm-time{font-variant-numeric:tabular-nums;text-align:right}.gm-score{font-weight:700}@media(max-width:520px){.gm-card{padding:1rem}.gm-board-row{grid-template-columns:2.6rem 1fr auto auto;gap:.5rem}}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Games - what the Quiz and Crossword pages share in the browser: talking to
|
|
3
|
+
* the server, the leaderboard, the countdown, the player's remembered name,
|
|
4
|
+
* the stylesheet and a celebration for a top place.
|
|
5
|
+
*
|
|
6
|
+
* Served from /plugins/_lib/public/games/games.js. Domma is on every public
|
|
7
|
+
* page, so the screens are domma-reactive (window.M): observables, one
|
|
8
|
+
* M.applyBindings per screen, and text from the server reaches the page only
|
|
9
|
+
* through bindings (textContent), never as HTML.
|
|
10
|
+
*
|
|
11
|
+
* @module _lib/public/games/games
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {fmtDuration, ordinal, PERIODS} from './format.js';
|
|
15
|
+
|
|
16
|
+
export {fmtDuration, ordinal, PERIODS};
|
|
17
|
+
|
|
18
|
+
const M = window.M;
|
|
19
|
+
|
|
20
|
+
/** JSON in and out (`token`: the play's key); a failure is an Error carrying the server's words, `status` and `code`. */
|
|
21
|
+
export async function request(url, {method = 'GET', body, token} = {}) {
|
|
22
|
+
const headers = {Accept: 'application/json'};
|
|
23
|
+
if (body) headers['Content-Type'] = 'application/json';
|
|
24
|
+
// A play's key travels in a header, so it stays out of addresses and server logs.
|
|
25
|
+
if (token) headers['X-Play-Token'] = token;
|
|
26
|
+
const res = await fetch(url, {
|
|
27
|
+
method,
|
|
28
|
+
headers,
|
|
29
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
30
|
+
credentials: 'same-origin'
|
|
31
|
+
});
|
|
32
|
+
let data = null;
|
|
33
|
+
try { data = await res.json(); } catch { /* not JSON */ }
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const err = new Error(data?.error || (res.status === 429 ? 'Too many tries just now. Please wait a minute.' : 'Something went wrong. Please try again.'));
|
|
36
|
+
err.status = res.status;
|
|
37
|
+
err.code = data?.code || '';
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Remembered conveniences (the name last played under, a play in progress). Storage may be blocked. */
|
|
44
|
+
export const memory = {
|
|
45
|
+
get(k) { try { return window.S ? S.get(k) : null; } catch { return null; } },
|
|
46
|
+
set(k, v) { try { window.S?.set(k, v); } catch { /* a convenience */ } },
|
|
47
|
+
remove(k) { try { window.S?.remove(k); } catch { /* a convenience */ } }
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let stylesAdded = false;
|
|
51
|
+
/** The shared stylesheet, once per page. */
|
|
52
|
+
export function addStyles(extra = []) {
|
|
53
|
+
const want = stylesAdded ? extra : ['/plugins/_lib/public/games/games.css', ...extra];
|
|
54
|
+
stylesAdded = true;
|
|
55
|
+
for (const href of want) {
|
|
56
|
+
if (document.querySelector(`link[href="${href}"]`)) continue;
|
|
57
|
+
const link = document.createElement('link');
|
|
58
|
+
link.rel = 'stylesheet';
|
|
59
|
+
link.href = href;
|
|
60
|
+
document.head.appendChild(link);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The JSON config a shortcode wrote on its placeholder, or null. */
|
|
65
|
+
export function configOf(el, attr) {
|
|
66
|
+
try { return JSON.parse(el.getAttribute(attr) || 'null'); } catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A short-lived unique prefix for ids inside one mount. */
|
|
70
|
+
let mounts = 0;
|
|
71
|
+
export const uid = (prefix) => `${prefix}${++mounts}${Math.random().toString(36).slice(2, 6)}`;
|
|
72
|
+
|
|
73
|
+
/** Ticker tape for a top place - skipped when the visitor asked for less motion. */
|
|
74
|
+
export function celebrate() {
|
|
75
|
+
try {
|
|
76
|
+
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
|
|
77
|
+
const fx = window.Domma?.effects;
|
|
78
|
+
if (typeof fx?.tickerTape === 'function') fx.tickerTape(null, {burst: true, burstCount: 160});
|
|
79
|
+
} catch { /* a celebration never breaks the page */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A countdown to a deadline the SERVER set: the browser only shows it. `left`
|
|
84
|
+
* is an observable of milliseconds; `onEnd` runs once when it reaches 0.
|
|
85
|
+
*/
|
|
86
|
+
export class Countdown {
|
|
87
|
+
constructor(onEnd = () => {}) {
|
|
88
|
+
this.left = M.observable(0);
|
|
89
|
+
this.onEnd = onEnd;
|
|
90
|
+
this.timer = null;
|
|
91
|
+
this.deadline = 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `ms` from now; 0 or less stops it (no limit). */
|
|
95
|
+
start(ms) {
|
|
96
|
+
this.stop();
|
|
97
|
+
if (!(ms > 0)) { this.left.value = 0; return; }
|
|
98
|
+
this.deadline = performance.now() + ms;
|
|
99
|
+
const tick = () => {
|
|
100
|
+
const left = Math.max(0, this.deadline - performance.now());
|
|
101
|
+
this.left.value = left;
|
|
102
|
+
if (left <= 0) { this.stop(); this.onEnd(); }
|
|
103
|
+
};
|
|
104
|
+
tick();
|
|
105
|
+
this.timer = setInterval(tick, 200);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
stop() {
|
|
109
|
+
if (this.timer) clearInterval(this.timer);
|
|
110
|
+
this.timer = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A running clock from a start the server gave (milliseconds already gone). */
|
|
115
|
+
export class Stopwatch {
|
|
116
|
+
constructor() {
|
|
117
|
+
this.shown = M.observable('0:00');
|
|
118
|
+
this.timer = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
start(elapsedMs = 0) {
|
|
122
|
+
this.stop();
|
|
123
|
+
const origin = performance.now() - Math.max(0, elapsedMs);
|
|
124
|
+
const tick = () => { this.shown.value = fmtDuration(performance.now() - origin); };
|
|
125
|
+
tick();
|
|
126
|
+
this.timer = setInterval(tick, 500);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
stop() {
|
|
130
|
+
if (this.timer) clearInterval(this.timer);
|
|
131
|
+
this.timer = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const BOARD = `
|
|
136
|
+
<div class="gm-board-head">
|
|
137
|
+
<h3 class="gm-board-title" data-bind-text="title.value"></h3>
|
|
138
|
+
<div class="gm-periods" role="group" aria-label="Period" data-each="periods key=key">
|
|
139
|
+
<button type="button" class="gm-period" data-bind-class="$root.period.value === key && 'is-on'"
|
|
140
|
+
data-bind-aria-pressed="$root.period.value === key ? 'true' : 'false'" data-on-click="$root.pick($data)" data-bind-text="label"></button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
<p class="gm-muted" hidden data-bind-hidden="!loading.value">Loading…</p>
|
|
144
|
+
<p class="gm-muted" hidden data-bind-hidden="loading.value || rows.value.length">No scores yet. Be the first!</p>
|
|
145
|
+
<ol class="gm-board-list" hidden data-bind-hidden="!rows.value.length" data-each="rows.value key=id">
|
|
146
|
+
<li class="gm-board-row" data-bind-class="mine && 'is-mine'">
|
|
147
|
+
<span class="gm-rank" data-bind-text="place"></span>
|
|
148
|
+
<span class="gm-name" data-bind-text="name"></span>
|
|
149
|
+
<span class="gm-score" data-if="showScore" data-bind-text="scoreText"></span>
|
|
150
|
+
<span class="gm-time" data-bind-text="timeText"></span>
|
|
151
|
+
</li>
|
|
152
|
+
</ol>`;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A live leaderboard on `el`, fetched from `url` (?period=&limit=).
|
|
156
|
+
*
|
|
157
|
+
* @param {HTMLElement} el
|
|
158
|
+
* @param {{url: string, period?: string, limit?: number, showScore?: boolean, title?: string}} opts
|
|
159
|
+
* @returns {{refresh: (mineId?: string) => Promise<void>, dispose: () => void}}
|
|
160
|
+
*/
|
|
161
|
+
export function mountLeaderboard(el, {url, period = 'all', limit = 10, showScore = true, title = 'Leaderboard'}) {
|
|
162
|
+
if (!M) return {refresh: async () => {}, dispose() {}};
|
|
163
|
+
addStyles();
|
|
164
|
+
el.classList.add('gm-board');
|
|
165
|
+
el.innerHTML = BOARD;
|
|
166
|
+
const state = {
|
|
167
|
+
title: M.observable(title),
|
|
168
|
+
period: M.observable(PERIODS.some(p => p.key === period) ? period : 'all'),
|
|
169
|
+
periods: PERIODS,
|
|
170
|
+
rows: M.observable([]),
|
|
171
|
+
loading: M.observable(true),
|
|
172
|
+
pick: (p) => { state.period.value = p.key; refresh(); }
|
|
173
|
+
};
|
|
174
|
+
let mine = '';
|
|
175
|
+
async function refresh(mineId) {
|
|
176
|
+
if (mineId) mine = mineId;
|
|
177
|
+
state.loading.value = !state.rows.peek().length;
|
|
178
|
+
try {
|
|
179
|
+
const res = await request(`${url}?period=${encodeURIComponent(state.period.peek())}&limit=${encodeURIComponent(limit)}`);
|
|
180
|
+
state.rows.value = (res.rows || []).map(r => ({
|
|
181
|
+
id: r.id, name: r.name, mine: r.id === mine, showScore,
|
|
182
|
+
place: ordinal(r.rank), scoreText: `${r.score}`, timeText: fmtDuration(r.timeMs)
|
|
183
|
+
}));
|
|
184
|
+
} catch {
|
|
185
|
+
state.rows.value = [];
|
|
186
|
+
} finally {
|
|
187
|
+
state.loading.value = false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const handle = M.applyBindings(state, el);
|
|
191
|
+
refresh();
|
|
192
|
+
return {refresh, dispose: () => handle.dispose()};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Every `[data-<kind>-board]` placeholder on the page (the leaderboard shortcodes). */
|
|
196
|
+
export function mountBoards(attr, urlFor, {showScore = true} = {}) {
|
|
197
|
+
for (const el of document.querySelectorAll(`[${attr}]`)) {
|
|
198
|
+
if (el.dataset.gmMounted) continue;
|
|
199
|
+
el.dataset.gmMounted = '1';
|
|
200
|
+
const cfg = configOf(el, attr);
|
|
201
|
+
if (!cfg?.id) continue;
|
|
202
|
+
mountLeaderboard(el, {url: urlFor(cfg.id), period: cfg.period, limit: cfg.limit, showScore, title: cfg.title || 'Leaderboard'});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# crossword - AI Assistant Guide
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Crosswords on the public site, built from a word + clue list, timed by the server, with a fastest-solver
|
|
6
|
+
leaderboard. 1.1.0 (2026-10-21), `tier: free`, disabled on arrival. Sibling of `quiz`; shares `plugins/_lib/games`.
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
- `public/lib/layout.js` - PURE, isomorphic, SERVED: `generate(words, {seed})` (greedy, deterministic from the
|
|
11
|
+
seed, clues never affect the layout), `cleanAnswer` (letters + enumeration), `solutionGrid`, `shapeOf`. The admin
|
|
12
|
+
builder imports it over HTTP for the live preview; the server runs the same function on save, so a layout is
|
|
13
|
+
never taken from the browser. It holds no puzzle, which is why it may live under `public/`.
|
|
14
|
+
- `lib/crossword.js` - PURE: `cleanPuzzle`, `isOpen`, `publicPuzzle` (shape + clues, no letters), `readCells`,
|
|
15
|
+
`compare`, `catchUp`, `finish`, `view`.
|
|
16
|
+
- `lib/store.js` - `crossword-puzzles` + `plays = new PlayStore('crossword-plays', {order: 'time'})`.
|
|
17
|
+
- `plugin.js` - routes (header comment), shortcodes, the `fastest` notification source.
|
|
18
|
+
- `public/crossword.js` - the player: the screens are domma-reactive; the grid is a `Board` class over real
|
|
19
|
+
`<input>`s (keyboard rules in its header - all focus and keystrokes, which bindings do not reach), letters
|
|
20
|
+
remembered per play in storage. Exports `mount(el, {test})`, which the admin's Test screen uses.
|
|
21
|
+
- `admin/views/crossword.js` - list, editor with live grid, test and plays screens, all domma-reactive through the
|
|
22
|
+
admin kit (`_lib/admin/games/kit.js`). The preview grid is a data-each over cells.
|
|
23
|
+
|
|
24
|
+
## Contracts
|
|
25
|
+
|
|
26
|
+
- **No letters before solving.** `/public/crossword/:slug` gives only the size; the clues come with the play (so
|
|
27
|
+
nobody studies them off the clock); `solution` is in `/result` only when `status === 'finished'`. The tests
|
|
28
|
+
assert no answer string appears in the play payload - keep that test.
|
|
29
|
+
- **Time = server elapsed + `state.penaltyMs`.** Check adds `checkPenalty` seconds (403 when `checks` is off); a
|
|
30
|
+
FULL but wrong submit adds `wrongPenalty`; a grid with empties only reports the count.
|
|
31
|
+
- Cells travel as `height` strings of `width` chars, `A-Z` or space; anything else is 400.
|
|
32
|
+
- The grid uses fixed ink/paper colours (`--cw-ink`, `--cw-paper`) whatever the theme - a themed grid inverted
|
|
33
|
+
under dark themes.
|
|
34
|
+
- Same shortcode prefix trap as quiz: `crossword-leaderboard` is registered with `{priority: 5}`.
|
|
35
|
+
- **Test plays** (1.1.0): `GET/POST /puzzles/:id/test` (manage), as in quiz's CLAUDE.md - the saved crossword
|
|
36
|
+
whatever its status, never on a board, not in the plays list, no notification, cleared by the next test.
|
|
37
|
+
`/result` answers `test: true` and `wouldPlace`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* Crossword admin - the word list and the live grid. Shared pieces are in /plugins/_lib/admin/games/games.css. */
|
|
2
|
+
.cwa-words { display: flex; flex-direction: column; gap: .4rem; }
|
|
3
|
+
.cwa-word { display: grid; grid-template-columns: 1.6rem minmax(7rem, 12rem) 3.5rem minmax(0, 1fr) auto; gap: .4rem; align-items: center; }
|
|
4
|
+
.cwa-n { color: var(--dm-text-muted); font-size: var(--dm-text-sm); text-align: right; }
|
|
5
|
+
.cwa-answer { text-transform: uppercase; font-family: var(--dm-font-mono, monospace); letter-spacing: .04em; }
|
|
6
|
+
.cwa-enum { color: var(--dm-text-muted); font-size: var(--dm-text-xs); white-space: nowrap; }
|
|
7
|
+
@media (max-width: 700px) { .cwa-word { grid-template-columns: 1.4rem 1fr auto; } .cwa-enum { display: none; } .cwa-clue { grid-column: 2 / 4; } }
|
|
8
|
+
|
|
9
|
+
.cwa-side { position: sticky; top: .5rem; }
|
|
10
|
+
.cwa-preview { overflow: auto; max-height: 70vh; }
|
|
11
|
+
/* Fixed ink and paper, as on the public grid: a themed grid came out inverted under dark themes. */
|
|
12
|
+
.cwa-grid { --cwa-ink: #111827; --cwa-paper: #ffffff; display: grid; gap: 0; width: max-content; border: 2px solid var(--cwa-ink); background: var(--cwa-ink); }
|
|
13
|
+
.cwa-cell { position: relative; width: 1.7rem; height: 1.7rem; display: grid; place-items: center; font-weight: 700; font-size: .85rem;
|
|
14
|
+
background: var(--cwa-paper); color: var(--cwa-ink); border: 1px solid color-mix(in srgb, var(--cwa-ink) 35%, transparent); box-sizing: border-box; }
|
|
15
|
+
.cwa-cell.is-block { background: var(--cwa-ink); border-color: var(--cwa-ink); }
|
|
16
|
+
.cwa-num { position: absolute; top: 1px; left: 2px; font-size: .5rem; font-weight: 600; line-height: 1; color: #4b5563; }
|
|
17
|
+
.cwa-unplaced { margin: 0; padding: .5rem .75rem; border-radius: 6px; font-size: var(--dm-text-sm);
|
|
18
|
+
background: color-mix(in srgb, var(--dm-warning) 15%, transparent); color: color-mix(in srgb, var(--dm-warning) 55%, var(--dm-text)); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div class="gma cwa" data-crossword-admin></div>
|