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,170 @@
1
+ /**
2
+ * Crossword - the rules. PURE: no I/O, so the routes and the tests share them.
3
+ *
4
+ * cleanPuzzle(input) -> {puzzle, errors, unplaced} what the admin saved, laid out by the server
5
+ * isOpen(puzzle, now) -> {open, reason}
6
+ * publicPuzzle(puzzle) -> the grid's shape and the clues - never a letter of the answers
7
+ * readCells(raw, puzzle) -> the player's letters as rows, or null when malformed
8
+ * compare(cells, solution) -> {wrong: [[r, c]], empty: n}
9
+ * catchUp / finish -> the play, by the server's clock
10
+ *
11
+ * @module crossword/lib/crossword
12
+ */
13
+
14
+ import {GRACE_MS, leftOf, overdue} from '../../_lib/games/timing.js';
15
+ import {MAX_WORDS, generate, shapeOf, solutionGrid} from '../public/lib/layout.js';
16
+
17
+ export const STATUSES = ['draft', 'open', 'closed'];
18
+ export const LIMITS = {clue: 300, answer: 40, timeLimit: 6 * 3600, penalty: 600};
19
+
20
+ const str = (v, max) => String(v ?? '').replace(/\r\n?/g, '\n').trim().slice(0, max);
21
+ const int = (v, min, max, dflt = 0) => {
22
+ const n = Math.round(Number(v));
23
+ return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : dflt;
24
+ };
25
+ const iso = (v) => {
26
+ const t = Date.parse(String(v || ''));
27
+ return Number.isFinite(t) ? new Date(t).toISOString() : '';
28
+ };
29
+
30
+ export function slugify(s) {
31
+ return String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
32
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
33
+ }
34
+
35
+ /**
36
+ * The puzzle as the admin saved it. The grid is laid out HERE from the words
37
+ * and the seed (the same function the builder previews with), so a layout is
38
+ * never taken from the browser.
39
+ *
40
+ * @returns {{puzzle: object, errors: Object<string, string>, unplaced: object[]}}
41
+ */
42
+ export function cleanPuzzle(input = {}) {
43
+ const errors = {};
44
+ const words = (Array.isArray(input.words) ? input.words : []).slice(0, MAX_WORDS)
45
+ .map(w => ({answer: str(w?.answer, LIMITS.answer), clue: str(w?.clue, LIMITS.clue)}))
46
+ .filter(w => w.answer || w.clue);
47
+ const puzzle = {
48
+ title: str(input.title, 120),
49
+ slug: slugify(input.slug || input.title),
50
+ intro: str(input.intro, 2000),
51
+ status: STATUSES.includes(input.status) ? input.status : 'draft',
52
+ opensAt: iso(input.opensAt),
53
+ closesAt: iso(input.closesAt),
54
+ timeLimit: int(input.timeLimit, 0, LIMITS.timeLimit),
55
+ checks: input.checks !== false,
56
+ checkPenalty: int(input.checkPenalty ?? 30, 0, LIMITS.penalty, 30),
57
+ wrongPenalty: int(input.wrongPenalty ?? 10, 0, LIMITS.penalty, 10),
58
+ leaderboard: input.leaderboard !== false,
59
+ leaderboardSize: int(input.leaderboardSize ?? 10, 3, 100, 10),
60
+ attemptsPerName: int(input.attemptsPerName, 0, 100),
61
+ seed: int(input.seed ?? 1, 1, 2 ** 31 - 1, 1),
62
+ words,
63
+ layout: {width: 0, height: 0, entries: []}
64
+ };
65
+ if (!puzzle.title) errors.title = 'Give the crossword a title.';
66
+ if (!puzzle.slug) errors.slug = 'Give the crossword a short name for its address (letters and numbers).';
67
+ if (puzzle.opensAt && puzzle.closesAt && puzzle.closesAt <= puzzle.opensAt) errors.closesAt = 'It has to close after it opens.';
68
+ words.forEach((w, i) => {
69
+ if (!w.answer) errors[`w${i + 1}`] ??= `Word ${i + 1} has a clue but no answer.`;
70
+ else if (!w.clue) errors[`w${i + 1}`] ??= `"${w.answer}" needs a clue.`;
71
+ });
72
+ const laid = generate(words.filter(w => w.answer && w.clue), {seed: puzzle.seed});
73
+ puzzle.layout = {width: laid.width, height: laid.height, entries: laid.entries};
74
+ if (puzzle.status === 'open' && laid.entries.length < 2) errors.words = 'Add at least two answers that cross before opening the crossword.';
75
+ return {puzzle, errors, unplaced: laid.unplaced};
76
+ }
77
+
78
+ export function isOpen(puzzle, now = Date.now()) {
79
+ if (!puzzle || puzzle.status === 'draft') return {open: false, reason: 'This crossword is not open yet.', state: 'draft'};
80
+ if (puzzle.status === 'closed') return {open: false, reason: 'This crossword has closed.', state: 'closed'};
81
+ if (puzzle.opensAt && Date.parse(puzzle.opensAt) > now) return {open: false, reason: 'This crossword opens soon.', state: 'scheduled', opensAt: puzzle.opensAt};
82
+ if (puzzle.closesAt && Date.parse(puzzle.closesAt) <= now) return {open: false, reason: 'This crossword has closed.', state: 'ended'};
83
+ if ((puzzle.layout?.entries?.length || 0) < 1) return {open: false, reason: 'This crossword is not ready yet.', state: 'empty'};
84
+ return {open: true, reason: '', state: 'open'};
85
+ }
86
+
87
+ /** What anyone may see: the shape, the numbers and the clues - no letters. */
88
+ export function publicPuzzle(puzzle) {
89
+ const {width, height, entries} = puzzle.layout;
90
+ const grid = solutionGrid(puzzle.layout);
91
+ const clue = (e) => ({number: e.number, dir: e.dir, clue: e.clue, enumeration: e.enumeration, row: e.row, col: e.col, length: e.letters.length});
92
+ return {
93
+ width, height, shape: shapeOf(grid),
94
+ across: entries.filter(e => e.dir === 'across').map(clue),
95
+ down: entries.filter(e => e.dir === 'down').map(clue)
96
+ };
97
+ }
98
+
99
+ /**
100
+ * The player's grid: `height` strings of `width` characters, a letter or a
101
+ * space. Anything else (a black square they typed into, a wrong size) is null.
102
+ */
103
+ export function readCells(raw, puzzle) {
104
+ const {width, height} = puzzle.layout;
105
+ if (!Array.isArray(raw) || raw.length !== height) return null;
106
+ const rows = [];
107
+ for (const r of raw) {
108
+ const row = String(r ?? '').toUpperCase();
109
+ if (row.length !== width || !/^[A-Z ]*$/.test(row)) return null;
110
+ rows.push(row);
111
+ }
112
+ return rows;
113
+ }
114
+
115
+ /** Which squares are wrong (a letter that is not the answer's) and how many are still empty. */
116
+ export function compare(cells, solution) {
117
+ const wrong = [];
118
+ let empty = 0;
119
+ solution.forEach((row, r) => row.forEach((ch, c) => {
120
+ if (!ch) return;
121
+ const given = cells[r][c];
122
+ if (given === ' ') empty++;
123
+ else if (given !== ch) wrong.push([r, c]);
124
+ }));
125
+ return {wrong, empty};
126
+ }
127
+
128
+ const limitMs = (puzzle) => (puzzle.timeLimit || 0) * 1000;
129
+
130
+ /** Out of time while nobody asked: the play ends, off the board. */
131
+ export function catchUp(puzzle, play, now = Date.now()) {
132
+ if (play.status !== 'playing' || !overdue(play.startedAt, limitMs(puzzle), now)) return false;
133
+ play.status = 'timeout';
134
+ play.finishedAt = new Date(now).toISOString();
135
+ play.timeMs = limitMs(puzzle);
136
+ return true;
137
+ }
138
+
139
+ /** Solved: the time is the server's, plus every penalty. */
140
+ export function finish(play, now = Date.now()) {
141
+ play.status = 'finished';
142
+ play.finishedAt = new Date(now).toISOString();
143
+ play.score = 1;
144
+ play.timeMs = Math.max(0, now - Date.parse(play.startedAt)) + (play.state.penaltyMs || 0);
145
+ return play;
146
+ }
147
+
148
+ /** What the player may know of their play right now. */
149
+ export function view(puzzle, play, now = Date.now()) {
150
+ const end = play.status === 'playing' ? now : Date.parse(play.finishedAt);
151
+ return {
152
+ status: play.status,
153
+ elapsedMs: Math.max(0, end - Date.parse(play.startedAt)),
154
+ penaltyMs: play.state.penaltyMs || 0,
155
+ checks: play.state.checks || 0,
156
+ totalLeftMs: play.status === 'playing' ? leftOf(play.startedAt, limitMs(puzzle), now) : null,
157
+ grace: GRACE_MS
158
+ };
159
+ }
160
+
161
+ /** The puzzle as a list row in the admin. */
162
+ export function summary(puzzle, plays = []) {
163
+ const finished = plays.filter(p => p.status === 'finished');
164
+ return {
165
+ id: puzzle.id, title: puzzle.title, slug: puzzle.slug, status: puzzle.status,
166
+ words: puzzle.layout?.entries?.length || 0, size: `${puzzle.layout?.width || 0}×${puzzle.layout?.height || 0}`,
167
+ plays: plays.length, finished: finished.length,
168
+ fastest: finished.length ? Math.min(...finished.map(p => p.timeMs || 0)) : 0, updatedAt: puzzle.updatedAt || ''
169
+ };
170
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Crossword - where puzzles and plays are kept: the collections `crossword-puzzles` and
3
+ * `crossword-plays` (made at every boot when missing; API access all off - a puzzle
4
+ * holds its answers, a play the player's name).
5
+ *
6
+ * @module crossword/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 PUZZLES = 'crossword-puzzles';
14
+ export const PLAYS = 'crossword-plays';
15
+
16
+ export const plays = new PlayStore(PLAYS, {order: 'time'});
17
+
18
+ export async function ensureCollections(log = console) {
19
+ for (const slug of [PUZZLES, 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?.(`[crossword] 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 allPuzzles() {
31
+ cache ??= listEntries(PUZZLES, {limit: 100000}).then(r => (r.entries || []).map(flat)).catch(() => []);
32
+ return cache;
33
+ }
34
+ const forget = () => { cache = null; };
35
+
36
+ export const getPuzzle = async (id) => flat(await getEntry(PUZZLES, String(id)).catch(() => null));
37
+ export const puzzleBySlug = async (slug) => (await allPuzzles()).find(q => q.slug === String(slug)) || null;
38
+
39
+ /** A short name no other crossword has. */
40
+ export async function uniqueSlug(slug, selfId = '') {
41
+ const taken = new Set((await allPuzzles()).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 savePuzzle(id, data) {
47
+ const saved = id ? await updateEntry(PUZZLES, id, data) : await createEntry(PUZZLES, data);
48
+ forget();
49
+ return flat(saved);
50
+ }
51
+
52
+ export async function removePuzzle(id) {
53
+ await deleteEntry(PUZZLES, String(id));
54
+ forget();
55
+ return plays.reset(String(id));
56
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Crossword - the API (mounted at /api/plugins/crossword).
3
+ *
4
+ * Admin (the `crossword` permission; read to look, manage to change):
5
+ * GET /count sidebar badge: crosswords finished today
6
+ * GET /puzzles every crossword as a list row
7
+ * GET /puzzles/:id · POST /puzzles · PUT /puzzles/:id · DELETE /puzzles/:id
8
+ * POST /puzzles/:id/copy a copy, as a draft
9
+ * GET /puzzles/:id/plays · DELETE /puzzles/:id/plays · PUT /plays/:id · DELETE /plays/:id
10
+ * The grid is laid out on the server from the words and the seed (the builder previews with
11
+ * the same function, public/lib/layout.js); a save answers `unplaced` for words that did not fit.
12
+ *
13
+ * Players (no sign-in), under /public - the answers never leave the server:
14
+ * GET /public/crossword/:slug title, intro, the grid's size (the clues come with a play)
15
+ * GET /public/crossword/:slug/leaderboard ?period=&limit=
16
+ * POST /public/crossword/:slug/start {name} -> {playId, token}
17
+ * GET /public/play/:id the clock (elapsed, penalties, time left) and the grid's shape, numbers and clues
18
+ * POST /public/play/:id/check {cells} -> the squares that are wrong (costs the check penalty)
19
+ * POST /public/play/:id/submit {cells} -> solved (finished, timed) or not yet (wrong ones cost a penalty)
20
+ * GET /public/play/:id/result time, place; the solution
21
+ * `cells` is the grid as rows of letters, a space for an empty square. The token goes in `X-Play-Token`.
22
+ *
23
+ * Shortcodes: [crossword id="<short name>" /] and [crossword-leaderboard id="<short name>" period="week" /].
24
+ */
25
+
26
+ import {cleanPlayerName} from '../_lib/games/names.js';
27
+ import {boardQuery, fail, isBot, limited, mountPlayAdmin, todayBadge, tokenOf} from '../_lib/games/http.js';
28
+ import {solutionGrid} from './public/lib/layout.js';
29
+ import {catchUp, cleanPuzzle, compare, finish, isOpen, publicPuzzle, readCells, summary, view} from './lib/crossword.js';
30
+ import {allPuzzles, ensureCollections, getPuzzle, plays, puzzleBySlug, removePuzzle, savePuzzle, uniqueSlug} from './lib/store.js';
31
+
32
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
33
+ const slugAttr = (v) => String(v || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 60);
34
+ const secs = (ms) => Math.round(ms / 1000);
35
+
36
+ function registerShortcodes(hooks) {
37
+ if (typeof hooks.registerShortcode !== 'function') return;
38
+ hooks.registerShortcode('crossword', (attrStr, _body, ctx) => {
39
+ const a = ctx.parseShortcodeAttrs(attrStr || '');
40
+ const slug = slugAttr(a.id || a.slug);
41
+ if (!slug) return '<p class="gm-muted">[crossword] needs id="the crossword\'s short name".</p>';
42
+ return `<div class="gm-app cw-app" data-crossword='${esc(JSON.stringify({slug}))}'><p class="gm-muted">Loading the crossword…</p></div>`;
43
+ });
44
+ // First: the engine matches [crossword…/] by prefix, so [crossword] would otherwise take this one too.
45
+ hooks.registerShortcode('crossword-leaderboard', (attrStr, _body, ctx) => {
46
+ const a = ctx.parseShortcodeAttrs(attrStr || '');
47
+ const id = slugAttr(a.id || a.slug);
48
+ if (!id) return '<p class="gm-muted">[crossword-leaderboard] needs id="the crossword\'s short name".</p>';
49
+ const cfg = {id, period: String(a.period || 'all'), limit: Math.max(1, Math.min(100, Number(a.limit) || 10)), title: String(a.title || 'Fastest solvers').slice(0, 80)};
50
+ return `<div class="cw-board" data-crossword-board='${esc(JSON.stringify(cfg))}'></div>`;
51
+ }, {priority: 5});
52
+ }
53
+
54
+ function publicInfo(puzzle) {
55
+ const state = isOpen(puzzle);
56
+ return {
57
+ slug: puzzle.slug, title: puzzle.title, intro: puzzle.intro, open: state.open, reason: state.reason, opensAt: state.opensAt || '',
58
+ timeLimit: puzzle.timeLimit, checks: puzzle.checks, checkPenalty: puzzle.checkPenalty, wrongPenalty: puzzle.wrongPenalty,
59
+ leaderboard: puzzle.leaderboard, leaderboardSize: puzzle.leaderboardSize, attemptsPerName: puzzle.attemptsPerName,
60
+ // Only the size before a start: the clues come with the play, so nobody studies them off the clock.
61
+ width: puzzle.layout.width, height: puzzle.layout.height, answers: puzzle.layout.entries.length
62
+ };
63
+ }
64
+
65
+ export default async function crosswordPlugin(fastify, options) {
66
+ const {authenticate, requirePermission} = options.auth;
67
+ const hooks = options.hooks || {};
68
+ const can = (action) => ({preHandler: [authenticate, requirePermission('crossword', action)]});
69
+
70
+ await ensureCollections(fastify.log);
71
+ registerShortcodes(hooks);
72
+
73
+ if (typeof hooks.registerNotificationSource === 'function') {
74
+ try {
75
+ hooks.registerNotificationSource({id: 'fastest', label: 'Crossword fastest solves', severity: 'info', defaultOn: true,
76
+ description: 'Someone has solved a crossword faster than anyone before.'});
77
+ } catch { /* older engine */ }
78
+ }
79
+
80
+ // ---- Admin ------------------------------------------------------------------
81
+
82
+ fastify.get('/count', can('read'), async () => todayBadge(plays, 'crossword'));
83
+
84
+ fastify.get('/puzzles', can('read'), async () => {
85
+ const everyPlay = await plays.all();
86
+ return {puzzles: (await allPuzzles()).map(p => summary(p, everyPlay.filter(x => x.gameId === p.id)))
87
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))};
88
+ });
89
+
90
+ fastify.get('/puzzles/:id', can('read'), async (request, reply) => {
91
+ const puzzle = await getPuzzle(request.params.id);
92
+ return puzzle || fail(reply, 404, 'That crossword is not here any more.');
93
+ });
94
+
95
+ async function save(request, reply, id) {
96
+ if (id && !await getPuzzle(id)) return fail(reply, 404, 'That crossword is not here any more.');
97
+ const {puzzle, errors, unplaced} = cleanPuzzle(request.body || {});
98
+ if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
99
+ puzzle.slug = await uniqueSlug(puzzle.slug, id || '');
100
+ const saved = await savePuzzle(id, puzzle);
101
+ return reply.code(id ? 200 : 201).send({...saved, unplaced});
102
+ }
103
+
104
+ fastify.post('/puzzles', can('manage'), (request, reply) => save(request, reply, ''));
105
+ fastify.put('/puzzles/:id', can('manage'), (request, reply) => save(request, reply, request.params.id));
106
+
107
+ fastify.post('/puzzles/:id/copy', can('manage'), async (request, reply) => {
108
+ const puzzle = await getPuzzle(request.params.id);
109
+ if (!puzzle) return fail(reply, 404, 'That crossword is not here any more.');
110
+ const {id, createdAt, updatedAt, ...rest} = puzzle;
111
+ const copy = {...rest, title: `${puzzle.title} (copy)`.slice(0, 120), status: 'draft', slug: await uniqueSlug(puzzle.slug)};
112
+ return reply.code(201).send(await savePuzzle('', copy));
113
+ });
114
+
115
+ fastify.delete('/puzzles/:id', can('manage'), async (request, reply) => {
116
+ if (!await getPuzzle(request.params.id)) return fail(reply, 404, 'That crossword is not here any more.');
117
+ return {ok: true, playsRemoved: await removePuzzle(request.params.id)};
118
+ });
119
+
120
+ mountPlayAdmin(fastify, {store: plays, base: '/puzzles', can, exists: async (id) => Boolean(await getPuzzle(id))});
121
+
122
+ // ---- Players ----------------------------------------------------------------
123
+
124
+ const shownPuzzle = async (slug) => {
125
+ const puzzle = await puzzleBySlug(slug);
126
+ return puzzle && puzzle.status !== 'draft' ? puzzle : null;
127
+ };
128
+
129
+ fastify.get('/public/crossword/:slug', limited(120), async (request, reply) => {
130
+ const puzzle = await shownPuzzle(request.params.slug);
131
+ if (!puzzle) return fail(reply, 404, 'There is no crossword here.');
132
+ return publicInfo(puzzle);
133
+ });
134
+
135
+ fastify.get('/public/crossword/:slug/leaderboard', limited(120), async (request, reply) => {
136
+ const puzzle = await shownPuzzle(request.params.slug);
137
+ if (!puzzle || !puzzle.leaderboard) return fail(reply, 404, 'There is no leaderboard here.');
138
+ const {period, limit} = boardQuery(request.query, puzzle.leaderboardSize);
139
+ return {title: puzzle.title, period, rows: await plays.board(puzzle.id, {period, limit})};
140
+ });
141
+
142
+ fastify.post('/public/crossword/:slug/start', limited(20, 10), async (request, reply) => {
143
+ const puzzle = await shownPuzzle(request.params.slug);
144
+ if (!puzzle) return fail(reply, 404, 'There is no crossword here.');
145
+ const state = isOpen(puzzle);
146
+ if (!state.open) return fail(reply, 409, state.reason, {code: 'closed'});
147
+ if (isBot(request.body)) return fail(reply, 400, 'Something went wrong. Please try again.');
148
+ const {name, error} = cleanPlayerName(request.body?.name);
149
+ if (error) return fail(reply, 400, error, {code: 'name'});
150
+ if (puzzle.attemptsPerName && await plays.attemptsBy(puzzle.id, name) >= puzzle.attemptsPerName) {
151
+ return fail(reply, 409, puzzle.attemptsPerName === 1 ? `${name} has already had a go at this crossword.` : `${name} has already had ${puzzle.attemptsPerName} goes at this crossword.`, {code: 'attempts'});
152
+ }
153
+ const play = await plays.start({gameId: puzzle.id, name, maxScore: 1, state: {checks: 0, submits: 0, penaltyMs: 0}});
154
+ return reply.code(201).send({playId: play.id, token: play.token, name});
155
+ });
156
+
157
+ /** The play behind a request, up to date with the clock. Null after answering with the reason. */
158
+ async function livePlay(request, reply) {
159
+ const opened = await plays.open(request.params.id, tokenOf(request));
160
+ if (!opened) { fail(reply, 404, 'That game is not here any more. Start again.', {code: 'gone'}); return null; }
161
+ const puzzle = await getPuzzle(opened.gameId);
162
+ if (!puzzle) { fail(reply, 404, 'That crossword is not here any more.', {code: 'gone'}); return null; }
163
+ const play = await plays.change(opened.id, (p) => (catchUp(puzzle, p) ? p : null));
164
+ return {puzzle, play};
165
+ }
166
+
167
+ async function afterFinish(puzzle, play) {
168
+ try {
169
+ if (!puzzle.leaderboard || await plays.placeOf(play, 'all') !== 1) return;
170
+ hooks.notify?.({
171
+ title: `Fastest solve of "${puzzle.title}"`,
172
+ body: `${play.name} solved it in ${secs(play.timeMs)} seconds`,
173
+ severity: 'info', link: `#/plugins/crossword/plays/${puzzle.id}`, source: 'fastest', dedupeKey: `crossword-fastest:${play.id}`
174
+ });
175
+ } catch { /* a notice never breaks a play */ }
176
+ }
177
+
178
+ fastify.get('/public/play/:id', limited(240), async (request, reply) => {
179
+ const live = await livePlay(request, reply);
180
+ if (!live) return reply;
181
+ return {...view(live.puzzle, live.play), puzzle: publicPuzzle(live.puzzle)};
182
+ });
183
+
184
+ fastify.post('/public/play/:id/check', limited(60), async (request, reply) => {
185
+ const live = await livePlay(request, reply);
186
+ if (!live) return reply;
187
+ const {puzzle} = live;
188
+ if (!puzzle.checks) return fail(reply, 403, 'Checking is switched off for this crossword.', {code: 'no-checks'});
189
+ if (live.play.status !== 'playing') return fail(reply, 409, 'This game has finished.', {code: 'finished', play: view(puzzle, live.play)});
190
+ const cells = readCells(request.body?.cells, puzzle);
191
+ if (!cells) return fail(reply, 400, 'That grid does not fit this crossword.');
192
+ const {wrong, empty} = compare(cells, solutionGrid(puzzle.layout));
193
+ const play = await plays.change(live.play.id, (p) => {
194
+ p.state.checks = (p.state.checks || 0) + 1;
195
+ p.state.penaltyMs = (p.state.penaltyMs || 0) + puzzle.checkPenalty * 1000;
196
+ return p;
197
+ });
198
+ return {wrong, empty, play: view(puzzle, play)};
199
+ });
200
+
201
+ fastify.post('/public/play/:id/submit', limited(30), async (request, reply) => {
202
+ const live = await livePlay(request, reply);
203
+ if (!live) return reply;
204
+ const {puzzle} = live;
205
+ if (live.play.status !== 'playing') return fail(reply, 409, live.play.status === 'timeout' ? 'Time ran out.' : 'This game has finished.', {code: 'finished', play: view(puzzle, live.play)});
206
+ const cells = readCells(request.body?.cells, puzzle);
207
+ if (!cells) return fail(reply, 400, 'That grid does not fit this crossword.');
208
+ const {wrong, empty} = compare(cells, solutionGrid(puzzle.layout));
209
+ const solved = !wrong.length && !empty;
210
+ const play = await plays.change(live.play.id, (p) => {
211
+ if (p.status !== 'playing') return null;
212
+ p.state.submits = (p.state.submits || 0) + 1;
213
+ // Guessing costs time: a full grid with mistakes adds the penalty. Empty squares are only pointed out.
214
+ if (!solved && !empty) p.state.penaltyMs = (p.state.penaltyMs || 0) + puzzle.wrongPenalty * 1000;
215
+ return solved ? finish(p) : p;
216
+ });
217
+ if (solved) await afterFinish(puzzle, play);
218
+ return {
219
+ solved, empty, wrongCount: solved || empty ? 0 : wrong.length,
220
+ message: solved ? 'Solved!' : empty ? `${empty} square${empty === 1 ? ' is' : 's are'} still empty.`
221
+ : `Not quite: something is wrong.${puzzle.wrongPenalty ? ` That cost ${puzzle.wrongPenalty} seconds.` : ''}`,
222
+ play: view(puzzle, play)
223
+ };
224
+ });
225
+
226
+ fastify.get('/public/play/:id/result', limited(120), async (request, reply) => {
227
+ const live = await livePlay(request, reply);
228
+ if (!live) return reply;
229
+ const {puzzle, play} = live;
230
+ if (play.status === 'playing') return fail(reply, 409, 'This game has not finished yet.', {code: 'playing'});
231
+ const solved = play.status === 'finished';
232
+ return {
233
+ name: play.name, solved, timeMs: play.timeMs, penaltyMs: play.state.penaltyMs || 0, checks: play.state.checks || 0,
234
+ place: solved && puzzle.leaderboard && !play.hidden ? await plays.placeOf(play, 'all') : 0,
235
+ placeToday: solved && puzzle.leaderboard && !play.hidden ? await plays.placeOf(play, 'today') : 0,
236
+ leaderboard: puzzle.leaderboard,
237
+ // The answers only to someone who solved it: a timed-out play under another name must not be a way to read them.
238
+ solution: solved ? solutionGrid(puzzle.layout).map(row => row.map(ch => ch || ' ').join('')) : null
239
+ };
240
+ });
241
+ }
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "crossword",
3
+ "displayName": "Crossword",
4
+ "version": "1.0.0",
5
+ "tier": "free",
6
+ "description": "Type answers and clues in the admin and the grid lays itself out. Put the crossword on any page: players type a name, solve against the clock, and the fastest go top of the leaderboard. The answers stay on the server until someone has solved it.",
7
+ "author": "Domma CMS",
8
+ "date": "2026-10-20",
9
+ "icon": "grid",
10
+ "permissions": [
11
+ {
12
+ "key": "crossword",
13
+ "label": "Crossword",
14
+ "group": "Plugins",
15
+ "icon": "grid",
16
+ "description": "See crosswords and who solved them (read), or make, change and remove crosswords and plays (manage).",
17
+ "actions": [
18
+ {
19
+ "key": "read",
20
+ "label": "See",
21
+ "description": "Open Crossword and see the crosswords, plays and leaderboards"
22
+ },
23
+ {
24
+ "key": "manage",
25
+ "label": "Manage",
26
+ "description": "Make, change and delete crosswords; hide or remove plays; reset a leaderboard"
27
+ }
28
+ ],
29
+ "grant": [
30
+ "admin"
31
+ ]
32
+ }
33
+ ],
34
+ "inject": {
35
+ "bodyEnd": "inject/body.html"
36
+ },
37
+ "admin": {
38
+ "css": [
39
+ "../_lib/admin/games/games.css",
40
+ "admin/css/index.css"
41
+ ],
42
+ "sidebar": [
43
+ {
44
+ "id": "crossword",
45
+ "text": "Crossword",
46
+ "icon": "grid",
47
+ "url": "#/plugins/crossword",
48
+ "section": "#/plugins/crossword",
49
+ "permission": "crossword",
50
+ "group": "Games",
51
+ "countUrl": "/api/plugins/crossword/count"
52
+ }
53
+ ],
54
+ "routes": [
55
+ {
56
+ "path": "/plugins/crossword",
57
+ "view": "plugin-crossword",
58
+ "title": "Crossword - Domma CMS"
59
+ },
60
+ {
61
+ "path": "/plugins/crossword/:a",
62
+ "view": "plugin-crossword",
63
+ "title": "Crossword - Domma CMS"
64
+ },
65
+ {
66
+ "path": "/plugins/crossword/:a/:b",
67
+ "view": "plugin-crossword",
68
+ "title": "Crossword - Domma CMS"
69
+ }
70
+ ],
71
+ "views": {
72
+ "plugin-crossword": {
73
+ "entry": "crossword/admin/views/crossword.js?v=dec171f7",
74
+ "exportName": "crosswordView"
75
+ }
76
+ }
77
+ }
78
+ }
@@ -0,0 +1 @@
1
+ .cw-app{max-width:1040px}.cw-live{position:absolute;left:-9999px}.cw-clocks{display:inline-flex;align-items:center;gap:.5rem}.cw-current{margin:0 0 .75rem;padding:.5rem .75rem;border-radius:8px;font-weight:600;background:color-mix(in srgb,var(--gm-accent) 14%,transparent)}.cw-layout{display:grid;grid-template-columns:auto minmax(14rem,1fr);gap:1.25rem;align-items:start}@media(max-width:760px){.cw-layout{grid-template-columns:1fr}}.cw-grid-wrap{overflow-x:auto;max-width:100%}.cw-grid{--cw-size: clamp(1.6rem, calc((100vw - 4rem) / var(--cw-cols, 15)), 2.4rem);--cw-ink: #111827;--cw-paper: #ffffff;display:inline-flex;flex-direction:column;border:2px solid var(--cw-ink);background:var(--cw-ink)}.cw-row{display:flex}.cw-cell{position:relative;width:var(--cw-size);height:var(--cw-size);box-sizing:border-box;border:1px solid #9ca3af;background:var(--cw-paper)}.cw-cell.is-block{background:var(--cw-ink);border-color:var(--cw-ink)}.cw-cell.is-word{background:color-mix(in srgb,var(--gm-accent) 22%,var(--cw-paper))}.cw-cell.is-cursor{background:color-mix(in srgb,var(--gm-accent) 55%,var(--cw-paper))}.cw-cell.is-wrong .cw-input{color:#dc2626;text-decoration:line-through}.cw-num{position:absolute;top:1px;left:2px;font-size:calc(var(--cw-size) * .28);line-height:1;pointer-events:none;color:#4b5563}.cw-input{width:100%;height:100%;border:0;padding:0;margin:0;background:transparent;text-align:center;text-transform:uppercase;font:700 calc(var(--cw-size) * .55)/1 var(--dm-font-sans, inherit);color:var(--cw-ink);caret-color:transparent;cursor:pointer}.cw-input:focus{outline:none}.cw-input[readonly]{cursor:default}.cw-clues{display:grid;grid-template-columns:1fr;gap:1rem;max-height:34rem;overflow:auto}@media(min-width:1000px){.cw-clues{grid-template-columns:1fr 1fr}}.cw-clues h3{margin:0 0 .35rem;font-size:1rem;text-transform:uppercase;letter-spacing:.05em}.cw-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.1rem}.cw-clue{display:block;width:100%;text-align:left;font:inherit;color:inherit;background:none;border:0;padding:.3rem .45rem;border-radius:6px;cursor:pointer}.cw-clue:hover{background:var(--gm-soft)}.cw-list li.is-on .cw-clue{background:color-mix(in srgb,var(--gm-accent) 20%,transparent)}.cw-message.is-bad{color:var(--gm-bad);font-weight:600}.cw-message.is-good{color:var(--gm-good);font-weight:600}.cw-result{margin-bottom:1rem}
@@ -0,0 +1,57 @@
1
+ import{Stopwatch as q,Countdown as O,addStyles as N,celebrate as R,configOf as B,fmtDuration as g,memory as m,mountBoards as H,mountLeaderboard as K,ordinal as M,request as f,uid as W}from"/plugins/_lib/public/games/games.js";const h=window.M,b="/api/plugins/crossword/public",S="games.name",k=r=>`crossword.play.${r}`,$=r=>`crossword.cells.${r}`,F=`
2
+ <p class="cw-live" role="status" aria-live="polite" data-bind-text="live.value"></p>
3
+
4
+ <section class="gm-card" hidden data-bind-hidden="screen.value !== 'loading'"><p class="gm-muted">Loading the crossword\u2026</p></section>
5
+ <section class="gm-card" hidden data-bind-hidden="screen.value !== 'error'"><p class="gm-error" data-bind-text="problem.value"></p></section>
6
+
7
+ <section class="gm-card" hidden data-bind-hidden="screen.value !== 'intro'">
8
+ <h2 class="gm-title" data-bind-text="info.value.title"></h2>
9
+ <p class="gm-intro" hidden data-bind-hidden="!info.value.intro" data-bind-text="info.value.intro"></p>
10
+ <ul class="gm-facts" data-each="facts.value key=k"><li data-bind-text="k"></li></ul>
11
+ <p class="gm-note" hidden data-bind-hidden="info.value.open" data-bind-text="info.value.reason"></p>
12
+ <form class="gm-start" hidden data-bind-hidden="!info.value.open" data-on-submit="start" novalidate>
13
+ <div class="gm-field">
14
+ <label for="%U%-name">Your name</label>
15
+ <input id="%U%-name" class="gm-input" maxlength="24" autocomplete="nickname" data-model="name.value" aria-describedby="%U%-err">
16
+ </div>
17
+ <div class="gm-hp" aria-hidden="true"><label>Website <input tabindex="-1" autocomplete="off" data-model="website.value"></label></div>
18
+ <button type="submit" class="btn btn-primary" data-bind-disabled="busy.value">Start the clock</button>
19
+ </form>
20
+ <p class="gm-error" id="%U%-err" hidden data-bind-hidden="!problem.value" data-bind-text="problem.value"></p>
21
+ </section>
22
+
23
+ <section class="gm-card cw-play" hidden data-bind-hidden="screen.value !== 'play' && screen.value !== 'result'">
24
+ <div class="gm-bar" hidden data-bind-hidden="screen.value !== 'play'">
25
+ <strong data-bind-text="info.value.title"></strong>
26
+ <span class="cw-clocks">
27
+ <span class="gm-muted" hidden data-bind-hidden="!penaltyText.value" data-bind-text="penaltyText.value"></span>
28
+ <span class="gm-clock" role="timer" aria-label="Time so far" data-bind-text="clock.shown.value"></span>
29
+ <span class="gm-clock" hidden data-bind-hidden="!leftShown.value" data-bind-class="leftLow.value && 'is-low'"
30
+ data-bind-aria-label="'Time left ' + leftText.value" data-bind-text="leftText.value"></span>
31
+ </span>
32
+ </div>
33
+ <div class="cw-result" hidden data-bind-hidden="screen.value !== 'result'">
34
+ <h2 class="gm-title" id="%U%-res" tabindex="-1" data-bind-text="res.value.title"></h2>
35
+ <p class="gm-result-score" hidden data-bind-hidden="!res.value.solved" data-bind-text="res.value.timeText"></p>
36
+ <p class="gm-muted" data-bind-text="res.value.detail"></p>
37
+ <p class="gm-result-place" hidden data-bind-hidden="!res.value.placeText" data-bind-text="res.value.placeText"></p>
38
+ <div class="gm-actions"><button type="button" class="btn btn-primary" data-on-click="again">Play again</button></div>
39
+ </div>
40
+ <p class="cw-current" aria-hidden="true" hidden data-bind-hidden="screen.value !== 'play' || !currentClue.value" data-bind-text="currentClue.value"></p>
41
+ <div class="cw-layout">
42
+ <div class="cw-grid-wrap"><div class="cw-grid" role="grid" aria-label="Crossword grid"></div></div>
43
+ <div class="cw-clues">
44
+ <div><h3>Across</h3><ol class="cw-list" data-dir="across"></ol></div>
45
+ <div><h3>Down</h3><ol class="cw-list" data-dir="down"></ol></div>
46
+ </div>
47
+ </div>
48
+ <p class="gm-note cw-message" hidden data-bind-hidden="!message.value" data-bind-class="messageTone.value" data-bind-text="message.value"></p>
49
+ <div class="gm-actions" hidden data-bind-hidden="screen.value !== 'play'">
50
+ <button type="button" class="btn btn-primary" data-bind-disabled="busy.value" data-on-click="submit">I've finished</button>
51
+ <button type="button" class="btn btn-outline" hidden data-bind-hidden="!info.value.checks" data-bind-disabled="busy.value" data-on-click="check"
52
+ data-bind-text="checkLabel.value"></button>
53
+ <button type="button" class="btn btn-ghost" data-on-click="clearWord">Clear this word</button>
54
+ </div>
55
+ </section>
56
+
57
+ <div class="cw-board-slot"></div>`;function Y(r){const t=[`${r.answers} answers`,`${r.width} \xD7 ${r.height} grid`];return r.timeLimit&&t.push(`${g(r.timeLimit*1e3)} to solve it`),r.checks&&t.push(r.checkPenalty?`Checking costs ${r.checkPenalty} seconds`:"You can check your letters"),r.attemptsPerName===1&&t.push("One go each"),r.leaderboard&&t.push("Fastest solve wins"),t.map(l=>({k:l}))}class j{constructor(t,l,e,{onChange:a,onClue:n}){this.el=t,this.lists=l,this.p=e,this.onChange=a,this.onClue=n,this.letters=e.shape.map(i=>[...i].map(()=>"")),this.inputs=[],this.dir="across",this.pos=null,this.entries=[...e.across,...e.down],this.build()}open(t,l){return t>=0&&l>=0&&t<this.p.height&&l<this.p.width&&this.p.shape[t][l]==="."}entryAt(t,l,e){return this.entries.find(a=>a.dir===e&&(e==="across"?a.row===t&&l>=a.col&&l<a.col+a.length:a.col===l&&t>=a.row&&t<a.row+a.length))||null}cellsOf(t){return Array.from({length:t.length},(l,e)=>t.dir==="across"?[t.row,t.col+e]:[t.row+e,t.col])}build(){const{width:t,height:l,shape:e}=this.p,a=new Map(this.entries.map(n=>[`${n.row},${n.col}`,n.number]));this.el.innerHTML="",this.el.style.setProperty("--cw-cols",t);for(let n=0;n<l;n++){const i=document.createElement("div");i.className="cw-row",i.setAttribute("role","row"),this.inputs[n]=[];for(let o=0;o<t;o++){const d=document.createElement("div");if(d.className=e[n][o]==="."?"cw-cell":"cw-cell is-block",d.setAttribute("role","gridcell"),e[n][o]==="."){if(a.has(`${n},${o}`)){const p=document.createElement("span");p.className="cw-num",p.textContent=a.get(`${n},${o}`),d.append(p)}const u=document.createElement("input");u.className="cw-input",u.maxLength=2,u.autocomplete="off",u.setAttribute("autocapitalize","characters"),u.spellcheck=!1,u.setAttribute("aria-label",this.labelFor(n,o)),u.addEventListener("focus",()=>this.focusCell(n,o,{fromFocus:!0})),u.addEventListener("mousedown",p=>{this.pos&&this.pos[0]===n&&this.pos[1]===o&&(p.preventDefault(),this.turn())}),u.addEventListener("keydown",p=>this.key(p,n,o)),u.addEventListener("input",p=>this.typed(p,n,o)),d.append(u),this.inputs[n][o]=u}i.append(d)}this.el.append(i)}for(const n of this.lists){n.innerHTML="";for(const i of this.entries.filter(o=>o.dir===n.dataset.dir)){const o=document.createElement("li");o.dataset.key=`${i.dir}${i.number}`,o.value=i.number;const d=document.createElement("button");d.type="button",d.className="cw-clue";const u=document.createElement("strong");u.textContent=`${i.number} `,d.append(u,document.createTextNode(`${i.clue} (${i.enumeration})`)),d.addEventListener("click",()=>{this.dir=i.dir,this.focusCell(i.row,i.col)}),o.append(d),n.append(o)}}}labelFor(t,l){return`Square in ${["across","down"].map(a=>this.entryAt(t,l,a)).filter(Boolean).map(a=>`${a.number} ${a.dir}`).join(" and ")}`}focusCell(t,l,{fromFocus:e=!1}={}){this.open(t,l)&&(this.entryAt(t,l,this.dir)||(this.dir=this.dir==="across"?"down":"across"),this.pos=[t,l],e||this.inputs[t][l].focus(),this.paint())}turn(){const t=this.dir==="across"?"down":"across";this.pos&&this.entryAt(...this.pos,t)&&(this.dir=t,this.paint())}current(){return this.pos?this.entryAt(...this.pos,this.dir):null}paint(){const t=this.current(),l=new Set(t?this.cellsOf(t).map(([e,a])=>`${e},${a}`):[]);this.inputs.forEach((e,a)=>e.forEach((n,i)=>{if(!n)return;const o=n.parentElement;o.classList.toggle("is-word",l.has(`${a},${i}`)),o.classList.toggle("is-cursor",!!this.pos&&this.pos[0]===a&&this.pos[1]===i)}));for(const e of this.lists)for(const a of e.children){const n=t&&a.dataset.key===`${t.dir}${t.number}`;a.classList.toggle("is-on",!!n),n&&typeof a.scrollIntoView=="function"&&e.scrollHeight>e.clientHeight&&a.scrollIntoView({block:"nearest"})}this.onClue(t?`${t.number} ${t.dir}: ${t.clue} (${t.enumeration})`:"")}set(t,l,e){this.letters[t][l]=e,this.inputs[t][l].value=e,this.inputs[t][l].parentElement.classList.remove("is-wrong"),this.onChange()}step(t,l,e){const[a,n]=this.dir==="across"?[0,e]:[e,0];return this.open(t+a,l+n)?[t+a,l+n]:null}typed(t,l,e){const a=(t.target.value.toUpperCase().match(/[A-Z]/g)||[]).pop()||"";if(this.set(l,e,a),a){const n=this.step(l,e,1);n&&this.focusCell(...n)}}key(t,l,e){const a={ArrowUp:[-1,0,"down"],ArrowDown:[1,0,"down"],ArrowLeft:[0,-1,"across"],ArrowRight:[0,1,"across"]};if(a[t.key]){t.preventDefault();const[n,i,o]=a[t.key];if(this.dir!==o&&this.entryAt(l,e,o)){this.dir=o,this.paint();return}let[d,u]=[l+n,e+i];for(;d>=0&&u>=0&&d<this.p.height&&u<this.p.width&&!this.open(d,u);)d+=n,u+=i;this.open(d,u)&&this.focusCell(d,u)}else if(t.key==="Backspace")if(t.preventDefault(),this.letters[l][e])this.set(l,e,"");else{const n=this.step(l,e,-1);n&&(this.focusCell(...n),this.set(...n,""))}else if(t.key==="Delete")t.preventDefault(),this.set(l,e,"");else if(t.key===" ")t.preventDefault(),this.turn();else if(t.key==="Tab"){const n=this.current(),i=this.entries.indexOf(n),o=this.entries[(i+(t.shiftKey?-1:1)+this.entries.length)%this.entries.length];o&&(t.preventDefault(),this.dir=o.dir,this.focusCell(o.row,o.col))}else if(/^[a-z]$/i.test(t.key)&&!t.ctrlKey&&!t.metaKey&&!t.altKey){t.preventDefault(),this.set(l,e,t.key.toUpperCase());const n=this.step(l,e,1);n&&this.focusCell(...n)}}clearCurrent(){const t=this.current();if(t){for(const[l,e]of this.cellsOf(t))this.set(l,e,"");this.focusCell(t.row,t.col)}}rows(){return this.letters.map((t,l)=>t.map((e,a)=>this.open(l,a)&&e||" ").join(""))}load(t){!Array.isArray(t)||t.length!==this.p.height||t.forEach((l,e)=>[...String(l)].forEach((a,n)=>{this.open(e,n)&&/[A-Z]/.test(a)&&(this.letters[e][n]=a,this.inputs[e][n].value=a)}))}markWrong(t){for(const[l,e]of t)this.inputs[l]?.[e]?.parentElement.classList.add("is-wrong")}reveal(t,{lock:l=!0}={}){t.forEach((e,a)=>[...e].forEach((n,i)=>{const o=this.inputs[a]?.[i];o&&(o.value=n.trim(),l&&(o.readOnly=!0),o.parentElement.classList.remove("is-wrong","is-word","is-cursor"))})),this.pos=null;for(const e of this.lists)for(const a of e.children)a.classList.remove("is-on")}}function z(r){const t=B(r,"data-crossword");if(!t?.slug||!h)return;N(["/plugins/crossword/public/crossword.css?v=1.0.0"]);const l=W("cw");r.innerHTML=F.replaceAll("%U%",l);const e={screen:h.observable("loading"),live:h.observable(""),problem:h.observable(""),busy:h.observable(!1),info:h.observable({title:"",intro:"",open:!1,reason:"",checks:!1}),facts:h.observable([]),name:h.observable(String(m.get(S)||"")),website:h.observable(""),clock:new q,leftText:h.observable(""),leftShown:h.observable(!1),leftLow:h.observable(!1),penaltyText:h.observable(""),currentClue:h.observable(""),message:h.observable(""),messageTone:h.observable(""),checkLabel:h.observable("Check my letters"),res:h.observable({})};let a=m.get(k(t.slug))||null,n=null,i=null,o=0;const d=new O(()=>{e.screen.peek()==="play"&&setTimeout(v,3500)}),u=h.effect(()=>{const s=d.left.value;e.leftText.value=`${g(s)} left`,e.leftLow.value=s>0&&s<=6e4}),p=s=>{e.live.value="",setTimeout(()=>{e.live.value=s},20)},y=(s,c="")=>{e.message.value=s,e.messageTone.value=c?`is-${c}`:"",p(s)},w=()=>{m.remove(k(t.slug)),a=null},U=()=>{a&&i&&m.set($(a.playId),i.rows())};function C(s){o=s.penaltyMs||0,e.penaltyText.value=o?`+${Math.round(o/1e3)}s`:"",e.clock.start((s.elapsedMs||0)+o),e.leftShown.value=s.totalLeftMs!=null,s.totalLeftMs!=null?d.start(s.totalLeftMs):d.stop()}async function x(){const s=await f(`${b}/crossword/${encodeURIComponent(t.slug)}`);e.info.value=s,e.facts.value=Y(s),e.checkLabel.value=s.checkPenalty?`Check my letters (+${s.checkPenalty}s)`:"Check my letters",s.leaderboard&&!n&&(n=K(r.querySelector(".cw-board-slot"),{url:`${b}/crossword/${encodeURIComponent(t.slug)}/leaderboard`,limit:s.leaderboardSize,showScore:!1,title:"Fastest solvers"}))}async function v(){if(!a){e.screen.value="intro";return}try{const s=await f(`${b}/play/${encodeURIComponent(a.playId)}`,{token:a.token});if(s.status!=="playing")return T();i||(i=new j(r.querySelector(".cw-grid"),[...r.querySelectorAll(".cw-list")],s.puzzle,{onChange:()=>{U(),e.message.value=""},onClue:P=>{e.currentClue.value=P}}),i.load(m.get($(a.playId)))),C(s),e.busy.value=!1,e.screen.value="play";const c=s.puzzle.across[0]||s.puzzle.down[0];c&&!i.pos&&setTimeout(()=>{i.dir=c.dir,i.focusCell(c.row,c.col)},50)}catch(s){if(s.code==="gone"||s.status===404){w(),e.screen.value="intro";return}e.problem.value=s.message,e.screen.value="error"}}async function T(){e.clock.stop(),d.stop();try{const s=await f(`${b}/play/${encodeURIComponent(a.playId)}/result`,{token:a.token}),c=s.place?`You came ${M(s.place)} of all time${s.placeToday&&s.placeToday!==s.place?`, ${M(s.placeToday)} today`:""}.`:"";e.res.value={solved:s.solved,title:s.solved?`Solved, ${s.name}!`:`Time's up, ${s.name}.`,timeText:g(s.timeMs),detail:s.solved?s.penaltyMs?`Including ${Math.round(s.penaltyMs/1e3)} seconds of penalties${s.checks?` (${s.checks} check${s.checks===1?"":"s"})`:""}.`:"No penalties. Clean solve!":"The clock ran out before the grid was finished.",placeText:c},e.message.value="",e.currentClue.value="",s.solution&&i?i.reveal(s.solution):s.solution,e.screen.value="result",p(`${e.res.peek().title} ${s.solved?g(s.timeMs):""} ${c}`),setTimeout(()=>r.querySelector(`#${l}-res`)?.focus(),30),n?.refresh(a.playId),s.place===1&&R(),m.remove($(a.playId))}catch(s){if(s.status===404){w(),e.screen.value="intro";return}e.problem.value=s.message,e.screen.value="error"}}async function D(){if(!e.busy.peek()){e.problem.value="",e.busy.value=!0;try{const s=await f(`${b}/crossword/${encodeURIComponent(t.slug)}/start`,{method:"POST",body:{name:e.name.peek(),website:e.website.peek()}});m.set(S,s.name),a={playId:s.playId,token:s.token},m.set(k(t.slug),a),await v()}catch(s){e.problem.value=s.message,e.busy.value=!1,r.querySelector(`#${l}-name`)?.focus()}}}async function L(s){if(!(e.busy.peek()||!i)){e.busy.value=!0;try{const c=await f(`${b}/play/${encodeURIComponent(a.playId)}/${s}`,{method:"POST",token:a.token,body:{cells:i.rows()}});if(C(c.play),s==="check")i.markWrong(c.wrong),y(c.wrong.length?`${c.wrong.length} square${c.wrong.length===1?" is":"s are"} wrong (marked in red).`:c.empty?`Everything so far is right. ${c.empty} to go.`:`Everything is right. Press "I've finished"!`,c.wrong.length?"bad":"good");else{if(c.solved)return T();y(c.message,"bad")}}catch(c){if(c.code==="finished"||c.code==="gone")return v();y(c.message,"bad")}finally{e.busy.value=!1}}}const E={start(){return D(),!1},check(){L("check")},submit(){L("submit")},clearWord(){i?.clearCurrent()},async again(){w(),i=null,r.querySelector(".cw-grid").innerHTML="",r.querySelectorAll(".cw-list").forEach(s=>{s.innerHTML=""}),e.busy.value=!1,await x().catch(()=>{}),e.screen.value="intro"}};h.applyBindings({...e,...E},r,{methods:E}),x().then(v).catch(s=>{e.problem.value=s.status===404?"This crossword is not available.":s.message,e.screen.value="error"});const A=new MutationObserver(()=>{r.isConnected||(e.clock.stop(),d.stop(),u?.(),A.disconnect())});A.observe(document.body,{childList:!0,subtree:!0})}function I(){for(const r of document.querySelectorAll("[data-crossword]"))r.dataset.gmMounted||(r.dataset.gmMounted="1",z(r));H("data-crossword-board",r=>`${b}/crossword/${encodeURIComponent(r)}/leaderboard`,{showScore:!1})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",I):I();