domma-cms 1.8.30 → 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 (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/docs/DommaFeatures.md +5 -4
  3. package/package.json +3 -1
  4. package/plugins/_lib/admin/games/games.css +1 -0
  5. package/plugins/_lib/admin/games/kit.js +241 -0
  6. package/plugins/_lib/games/http.js +82 -0
  7. package/plugins/_lib/games/names.js +42 -0
  8. package/plugins/_lib/games/plays.js +155 -0
  9. package/plugins/_lib/games/ranking.js +68 -0
  10. package/plugins/_lib/games/timing.js +27 -0
  11. package/plugins/_lib/public/games/format.js +38 -0
  12. package/plugins/_lib/public/games/games.css +1 -0
  13. package/plugins/_lib/public/games/games.js +204 -0
  14. package/plugins/crossword/admin/css/index.css +1 -0
  15. package/plugins/crossword/admin/templates/crossword.html +1 -0
  16. package/plugins/crossword/admin/views/crossword.js +317 -0
  17. package/plugins/crossword/collections/crossword-plays.json +20 -0
  18. package/plugins/crossword/collections/crossword-puzzles.json +17 -0
  19. package/plugins/crossword/config.js +6 -0
  20. package/plugins/crossword/inject/body.html +1 -0
  21. package/plugins/crossword/lib/crossword.js +170 -0
  22. package/plugins/crossword/lib/store.js +56 -0
  23. package/plugins/crossword/plugin.js +241 -0
  24. package/plugins/crossword/plugin.json +78 -0
  25. package/plugins/crossword/public/crossword.css +1 -0
  26. package/plugins/crossword/public/crossword.js +57 -0
  27. package/plugins/crossword/public/lib/layout.js +199 -0
  28. package/plugins/quiz/admin/css/index.css +1 -0
  29. package/plugins/quiz/admin/templates/quiz.html +1 -0
  30. package/plugins/quiz/admin/views/quiz.js +352 -0
  31. package/plugins/quiz/collections/quiz-plays.json +20 -0
  32. package/plugins/quiz/collections/quiz-quizzes.json +17 -0
  33. package/plugins/quiz/config.js +6 -0
  34. package/plugins/quiz/inject/body.html +1 -0
  35. package/plugins/quiz/lib/engine.js +115 -0
  36. package/plugins/quiz/lib/quiz.js +225 -0
  37. package/plugins/quiz/lib/store.js +56 -0
  38. package/plugins/quiz/plugin.js +241 -0
  39. package/plugins/quiz/plugin.json +78 -0
  40. package/plugins/quiz/public/quiz.css +1 -0
  41. package/plugins/quiz/public/quiz.js +80 -0
@@ -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();
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Crossword - laying words out in a grid. PURE, and isomorphic: the admin
3
+ * builder runs it in the browser for a live preview, the server runs it again
4
+ * to check what it is given. It holds no puzzle, so it is safe to serve.
5
+ *
6
+ * cleanAnswer("New York") -> {letters: 'NEWYORK', enumeration: '3,4'}
7
+ * generate(words, {seed}) -> {width, height, entries, unplaced}
8
+ * solutionGrid(layout) -> rows of letters ('' = a black square), or throws if entries clash
9
+ * numberEntries(entries) -> entries numbered in reading order
10
+ *
11
+ * The generator is greedy: longest words first, each placed where it crosses
12
+ * the most letters already down without touching any other word side by side,
13
+ * keeping the grid as square as it can. It tries a number of shuffles from the
14
+ * seed and keeps the best, so the same seed always gives the same grid.
15
+ *
16
+ * @module crossword/public/lib/layout
17
+ */
18
+
19
+ export const MAX_WORDS = 60;
20
+ export const MAX_LETTERS = 21;
21
+ export const MIN_LETTERS = 2;
22
+
23
+ /** Letters only, upper case, accents folded; the word lengths for the clue, e.g. "(3,4)". */
24
+ export function cleanAnswer(raw) {
25
+ const words = String(raw ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toUpperCase()
26
+ .split(/[\s-]+/).map(w => w.replace(/[^A-Z]/g, '')).filter(Boolean);
27
+ const letters = words.join('');
28
+ const hyphenated = /-/.test(String(raw ?? ''));
29
+ return {letters, enumeration: words.map(w => w.length).join(hyphenated ? '-' : ',')};
30
+ }
31
+
32
+ /** A small seeded random (mulberry32), so a seed always lays out the same grid. */
33
+ export function seeded(seed) {
34
+ let a = (Number(seed) >>> 0) || 1;
35
+ return () => {
36
+ a = (a + 0x6D2B79F5) >>> 0;
37
+ let t = a;
38
+ t = Math.imul(t ^ (t >>> 15), t | 1);
39
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
40
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
41
+ };
42
+ }
43
+
44
+ const key = (r, c) => `${r},${c}`;
45
+ const DIRS = {across: [0, 1], down: [1, 0]};
46
+
47
+ /** One attempt: words in this order, placed greedily on an open board. */
48
+ function attempt(words) {
49
+ const cells = new Map(); // "r,c" -> {ch, across: bool, down: bool}
50
+ const placed = [];
51
+ const unplaced = [];
52
+ let box = {r0: 0, r1: -1, c0: 0, c1: -1};
53
+
54
+ const at = (r, c) => cells.get(key(r, c));
55
+
56
+ function fits(w, r, c, dir) {
57
+ const [dr, dc] = DIRS[dir];
58
+ // The squares just before and after the word must be empty.
59
+ if (at(r - dr, c - dc) || at(r + dr * w.length, c + dc * w.length)) return -1;
60
+ let crossings = 0;
61
+ for (let i = 0; i < w.length; i++) {
62
+ const rr = r + dr * i;
63
+ const cc = c + dc * i;
64
+ const cell = at(rr, cc);
65
+ if (cell) {
66
+ if (cell.ch !== w[i] || cell[dir]) return -1;
67
+ crossings++;
68
+ continue;
69
+ }
70
+ // An empty square must not sit beside another word's letter (side by side, parallel).
71
+ if (at(rr + dc, cc + dr) || at(rr - dc, cc - dr)) return -1;
72
+ }
73
+ return crossings;
74
+ }
75
+
76
+ function put(w, r, c, dir, item) {
77
+ const [dr, dc] = DIRS[dir];
78
+ for (let i = 0; i < w.length; i++) {
79
+ const k = key(r + dr * i, c + dc * i);
80
+ const cell = cells.get(k) || {ch: w[i], across: false, down: false};
81
+ cell[dir] = true;
82
+ cells.set(k, cell);
83
+ }
84
+ const r1 = r + dr * (w.length - 1);
85
+ const c1 = c + dc * (w.length - 1);
86
+ box = placed.length
87
+ ? {r0: Math.min(box.r0, r), r1: Math.max(box.r1, r1), c0: Math.min(box.c0, c), c1: Math.max(box.c1, c1)}
88
+ : {r0: r, r1, c0: c, c1};
89
+ placed.push({...item, row: r, col: c, dir});
90
+ }
91
+
92
+ let queue = [...words];
93
+ let progress = true;
94
+ // Words that could not cross anything yet get another chance once more are down.
95
+ while (queue.length && progress) {
96
+ progress = false;
97
+ const left = [];
98
+ for (const item of queue) {
99
+ const w = item.letters;
100
+ if (!placed.length) { put(w, 0, 0, 'across', item); progress = true; continue; }
101
+ let best = null;
102
+ for (const [k, cell] of cells) {
103
+ const [cr, cc] = k.split(',').map(Number);
104
+ for (let i = 0; i < w.length; i++) {
105
+ if (w[i] !== cell.ch) continue;
106
+ for (const dir of ['across', 'down']) {
107
+ if (cell[dir]) continue;
108
+ const [dr, dc] = DIRS[dir];
109
+ const r = cr - dr * i;
110
+ const c = cc - dc * i;
111
+ const crossings = fits(w, r, c, dir);
112
+ if (crossings < 1) continue;
113
+ const r1 = r + dr * (w.length - 1);
114
+ const c1 = c + dc * (w.length - 1);
115
+ const h = Math.max(box.r1, r1) - Math.min(box.r0, r) + 1;
116
+ const wd = Math.max(box.c1, c1) - Math.min(box.c0, c) + 1;
117
+ // More crossings, then a smaller, squarer grid.
118
+ const score = crossings * 100 - (h * wd) / 4 - Math.abs(h - wd) * 2;
119
+ if (!best || score > best.score) best = {score, r, c, dir};
120
+ }
121
+ }
122
+ }
123
+ if (best) { put(w, best.r, best.c, best.dir, item); progress = true; }
124
+ else left.push(item);
125
+ }
126
+ queue = left;
127
+ }
128
+ unplaced.push(...queue);
129
+ return {placed, unplaced, box, crossings: [...cells.values()].filter(c => c.across && c.down).length};
130
+ }
131
+
132
+ /** Entries numbered in reading order: a square that starts any entry gets the next number. */
133
+ export function numberEntries(entries) {
134
+ const starts = [...new Set(entries.map(e => key(e.row, e.col)))]
135
+ .map(k => k.split(',').map(Number)).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
136
+ const num = new Map(starts.map(([r, c], i) => [key(r, c), i + 1]));
137
+ return entries.map(e => ({...e, number: num.get(key(e.row, e.col))}))
138
+ .sort((a, b) => (a.dir === b.dir ? a.number - b.number : a.dir === 'across' ? -1 : 1));
139
+ }
140
+
141
+ /**
142
+ * A layout from a list of {answer, clue}.
143
+ *
144
+ * @param {{answer: string, clue: string}[]} words
145
+ * @param {{seed?: number, tries?: number}} [opts]
146
+ * @returns {{width: number, height: number, entries: object[], unplaced: object[], seed: number}}
147
+ */
148
+ export function generate(words, {seed = 1, tries = 40} = {}) {
149
+ const items = [];
150
+ const seen = new Set();
151
+ for (const w of (words || []).slice(0, MAX_WORDS)) {
152
+ const {letters, enumeration} = cleanAnswer(w?.answer);
153
+ if (letters.length < MIN_LETTERS || letters.length > MAX_LETTERS || seen.has(letters)) continue;
154
+ seen.add(letters);
155
+ items.push({answer: String(w.answer).trim(), letters, enumeration, clue: String(w?.clue ?? '').trim()});
156
+ }
157
+ if (!items.length) return {width: 0, height: 0, entries: [], unplaced: [], seed};
158
+
159
+ const rand = seeded(seed);
160
+ let best = null;
161
+ for (let t = 0; t < Math.max(1, tries); t++) {
162
+ // The first try is longest-first; the rest shuffle words of similar length.
163
+ const order = items.map(it => ({it, k: it.letters.length + (t ? rand() * 4 : 0)})).sort((a, b) => b.k - a.k).map(x => x.it);
164
+ const res = attempt(order);
165
+ const h = res.box.r1 - res.box.r0 + 1;
166
+ const w = res.box.c1 - res.box.c0 + 1;
167
+ const score = res.placed.length * 1000 + res.crossings * 10 - h * w / 2 - Math.abs(h - w) * 3;
168
+ if (!best || score > best.score) best = {...res, score, h, w};
169
+ }
170
+ const entries = numberEntries(best.placed.map(p => ({
171
+ answer: p.answer, letters: p.letters, enumeration: p.enumeration, clue: p.clue,
172
+ row: p.row - best.box.r0, col: p.col - best.box.c0, dir: p.dir
173
+ })));
174
+ return {width: best.w, height: best.h, entries, unplaced: best.unplaced.map(u => ({answer: u.answer, clue: u.clue})), seed};
175
+ }
176
+
177
+ /**
178
+ * The solution as rows of letters ('' = black). Throws when the entries do
179
+ * not fit the size or disagree about a square - a layout from anywhere but
180
+ * generate() is checked this way before it is saved.
181
+ */
182
+ export function solutionGrid({width, height, entries}) {
183
+ const grid = Array.from({length: height}, () => Array(width).fill(''));
184
+ for (const e of entries) {
185
+ const [dr, dc] = DIRS[e.dir] || [];
186
+ if (dr === undefined) throw new Error('An entry has no direction.');
187
+ for (let i = 0; i < e.letters.length; i++) {
188
+ const r = e.row + dr * i;
189
+ const c = e.col + dc * i;
190
+ if (r < 0 || c < 0 || r >= height || c >= width) throw new Error(`"${e.answer}" runs off the grid.`);
191
+ if (grid[r][c] && grid[r][c] !== e.letters[i]) throw new Error(`"${e.answer}" clashes with another answer.`);
192
+ grid[r][c] = e.letters[i];
193
+ }
194
+ }
195
+ return grid;
196
+ }
197
+
198
+ /** The grid's shape for the player: '#' black, '.' a square to fill - no letters. */
199
+ export const shapeOf = (grid) => grid.map(row => row.map(ch => (ch ? '.' : '#')).join(''));
@@ -0,0 +1 @@
1
+ .qza-questions{display:flex;flex-direction:column;gap:.75rem}.qza-question{border:1px solid var(--gma-line);border-left:4px solid var(--dm-primary);border-radius:var(--dm-radius-md, 8px);padding:.85rem 1rem;display:flex;flex-direction:column;gap:.6rem;background:var(--dm-background, transparent)}.qza-question .form-select{width:auto}.qza-answers{display:flex;flex-direction:column;gap:.4rem}.qza-answer{display:flex;align-items:center;gap:.5rem;padding:.25rem .4rem;border-radius:6px}.qza-answer.is-right{background:color-mix(in srgb,var(--dm-success) 12%,transparent)}.qza-answer .form-input{flex:1;min-width:0}.qza-mark{display:inline-flex;align-items:center;cursor:pointer;padding:.2rem}.qza-mark input{width:1.1rem;height:1.1rem;accent-color:var(--dm-success)}.qza-fixed{flex:1;font-weight:500}.qza-more summary{cursor:pointer;color:var(--dm-text-muted);font-size:var(--dm-text-sm);margin-bottom:.5rem}
@@ -0,0 +1 @@
1
+ <div class="gma qza" data-quiz-admin></div>