domma-cms 1.8.31 → 1.8.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -0
- package/plugins/_lib/admin/games/kit.js +446 -0
- package/plugins/_lib/games/http.js +92 -0
- package/plugins/_lib/games/names.js +42 -0
- package/plugins/_lib/games/plays.js +176 -0
- package/plugins/_lib/games/ranking.js +68 -0
- package/plugins/_lib/games/timing.js +27 -0
- package/plugins/_lib/public/games/format.js +38 -0
- package/plugins/_lib/public/games/games.css +1 -0
- package/plugins/_lib/public/games/games.js +204 -0
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -0
- package/plugins/crossword/admin/templates/crossword.html +1 -0
- package/plugins/crossword/admin/views/crossword.js +449 -0
- package/plugins/crossword/collections/crossword-plays.json +20 -0
- package/plugins/crossword/collections/crossword-puzzles.json +17 -0
- package/plugins/crossword/config.js +6 -0
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -0
- package/plugins/crossword/lib/crossword.js +170 -0
- package/plugins/crossword/lib/store.js +56 -0
- package/plugins/crossword/plugin.js +274 -0
- package/plugins/crossword/plugin.json +79 -0
- package/plugins/crossword/public/crossword.css +36 -0
- package/plugins/crossword/public/crossword.js +519 -0
- package/plugins/crossword/public/lib/layout.js +199 -0
- package/plugins/free-tier.lock.json +43 -1
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -0
- package/plugins/quiz/admin/templates/quiz.html +1 -0
- package/plugins/quiz/admin/views/quiz.js +530 -0
- package/plugins/quiz/collections/quiz-plays.json +20 -0
- package/plugins/quiz/collections/quiz-quizzes.json +17 -0
- package/plugins/quiz/config.js +6 -0
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -0
- package/plugins/quiz/lib/engine.js +115 -0
- package/plugins/quiz/lib/quiz.js +225 -0
- package/plugins/quiz/lib/store.js +56 -0
- package/plugins/quiz/plugin.js +275 -0
- package/plugins/quiz/plugin.json +79 -0
- package/plugins/quiz/public/quiz.css +36 -0
- package/plugins/quiz/public/quiz.js +372 -0
|
@@ -0,0 +1,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(''));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
|
|
3
|
-
"source": "dcms-marketplace@
|
|
3
|
+
"source": "dcms-marketplace@aac25e9",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.10.1",
|
|
@@ -125,6 +125,27 @@
|
|
|
125
125
|
"tests/pure.test.js": "8ba0c8f76b4c9efcc422f39ff29509612544d81f760025a4b9a5594af14e7bcf"
|
|
126
126
|
}
|
|
127
127
|
},
|
|
128
|
+
"crossword": {
|
|
129
|
+
"version": "1.1.0",
|
|
130
|
+
"files": {
|
|
131
|
+
"CLAUDE.md": "598736eec2dfa833f7bb84ca9376071d1143d98a5c01106d0ff453749af18e43",
|
|
132
|
+
"admin/css/index.css": "d8d32268504d5aefa9c5fbf48a0c67b978cdd01d3be0bc747205a001972e05db",
|
|
133
|
+
"admin/templates/crossword.html": "336b4a3b7efff1ab8004c0516d55f11ae2efdc98cb3b18133ea00ca07311e2aa",
|
|
134
|
+
"admin/views/crossword.js": "42f3aabd09483eddf34d25dae243a853c562a13348eafe4fc1977361c1b03f4e",
|
|
135
|
+
"collections/crossword-plays.json": "20577250f00ebe0f6503cc2aae83dba93d88381b76b0b1dbc76c7b856d4295ff",
|
|
136
|
+
"collections/crossword-puzzles.json": "1b70950c96e50ede4d6a0c8e59ca7767b3c684c1a7c68588dc6462e69dad596e",
|
|
137
|
+
"config.js": "7a15db33506a432f5c43a095366c2e82a3bd20ceded585fb4fb78b93ad6dff29",
|
|
138
|
+
"docs/guide.md": "1189300bbe20f032e9040fe027e84a6137ab560e2fe9b88219aba66ea336ec4d",
|
|
139
|
+
"inject/body.html": "9dad7927d5afbd5e53f1edaa4a420403cd2325ce908624f9643a087388c090ab",
|
|
140
|
+
"lib/crossword.js": "f8149db65fb3a7f82b2fbae6a9f8d3ceeeec95730e426cf6fc36ea058527df34",
|
|
141
|
+
"lib/store.js": "284b3b8abe9fdaa64a660d5ab0120856b893d166b28e8bda6cf0ef038baa74bc",
|
|
142
|
+
"plugin.js": "5c1885955d122200c7ff9629d7fb9fcb54c232edb4cec4658574ca90d73730e5",
|
|
143
|
+
"plugin.json": "c5f9e3d934423161af6ef5fbf52c0c3394e737245a4de21da72da6d75a790f44",
|
|
144
|
+
"public/crossword.css": "656f369ea6587b000ff56b8ab80010f4f76af10966537c180950074ec5169f61",
|
|
145
|
+
"public/crossword.js": "8418a2c52a3d351a95e271d3c9f564b7336243ccde75106a9998995575802173",
|
|
146
|
+
"public/lib/layout.js": "0026afd8b42277838c55e1c031fa07cfea1c640c47aaf385e461cc998c4619c0"
|
|
147
|
+
}
|
|
148
|
+
},
|
|
128
149
|
"feedback": {
|
|
129
150
|
"version": "1.2.0",
|
|
130
151
|
"files": {
|
|
@@ -164,6 +185,27 @@
|
|
|
164
185
|
"plugin.json": "e5ac7f3496d9a535fa4899d1de87a514535084d13acecd6a5897744b6174807a"
|
|
165
186
|
}
|
|
166
187
|
},
|
|
188
|
+
"quiz": {
|
|
189
|
+
"version": "1.1.0",
|
|
190
|
+
"files": {
|
|
191
|
+
"CLAUDE.md": "9d037c11b0de115bf17a6c53387116be037c0d4dcd1b6bf3dafa373539143e16",
|
|
192
|
+
"admin/css/index.css": "6598225cfb8b58c486eb390ac41c7f359c78f48e0c97bc8b354d3832f618afa8",
|
|
193
|
+
"admin/templates/quiz.html": "f0b65c406b0b6325a815906ca8da7d884d5d24f2504e05631e46c34a5ca0edb9",
|
|
194
|
+
"admin/views/quiz.js": "c58ba86ec989df8f29e8acff2266fb3463e301c5516bf9281193de539b7c1f44",
|
|
195
|
+
"collections/quiz-plays.json": "331d665a90b6dddf069245a2faf8dd454a36d387702343a7e36d1fe6dd5015f8",
|
|
196
|
+
"collections/quiz-quizzes.json": "ce9bacf6c05fb5c22f9a5c5105f624c7a9c6d78c2bd343072d9cb7587e920b1d",
|
|
197
|
+
"config.js": "24db3727a804678ef12290ae258c7cf52afbc7a425bb452d87b7b2467ff7c559",
|
|
198
|
+
"docs/guide.md": "a7f8f449b94ecc726dc82fc7a0ae7c335431fa757f9493859bdf16e633689c3f",
|
|
199
|
+
"inject/body.html": "07727ead4a04fdeb2759a19e8cc358ba2692b807fb741a6daafaf02463fe8233",
|
|
200
|
+
"lib/engine.js": "a554c7abfa357288bbd73f47c9bcbf8eab7d8222ff63b1fd119a3ca5f9fb79f4",
|
|
201
|
+
"lib/quiz.js": "d697bbd1af41575a9ecee49781695f2dfb4a585bf0230cc39ac5d174dc9c2762",
|
|
202
|
+
"lib/store.js": "a740d879185e20c036ac17ab5cbca74d1f71097f4af3bc121fcddc826d8686c6",
|
|
203
|
+
"plugin.js": "0ec6f96fc4922c7ef41b36bd757c02926ee99d50e78fe85f7f30b9fbd9605b25",
|
|
204
|
+
"plugin.json": "788960b6a31c7c1c2d525fb7cae9ab9b7c9310def5d778c275f22302ecaadf78",
|
|
205
|
+
"public/quiz.css": "e8869fb30e22763357464e9aa7a353306b6631b8a7418ba4e483331e66aada67",
|
|
206
|
+
"public/quiz.js": "dedc96469e440c979b20d7d95655fbb25062b00b27be569b2a55ec5a8ec0cbb5"
|
|
207
|
+
}
|
|
208
|
+
},
|
|
167
209
|
"security": {
|
|
168
210
|
"version": "1.2.1",
|
|
169
211
|
"files": {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# quiz - AI Assistant Guide
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Timed quizzes on the public site with name-only players and leaderboards. 1.1.0 (2026-10-21), `tier: free`,
|
|
6
|
+
disabled on arrival. Sibling of `crossword`; both sit in the sidebar's Tools > Games folder (`"group": "Games"`)
|
|
7
|
+
and share `plugins/_lib/games` (server) + `_lib/public/games` (browser) + `_lib/admin/games` (admin kit).
|
|
8
|
+
|
|
9
|
+
## Files
|
|
10
|
+
|
|
11
|
+
- `lib/quiz.js` - PURE rules: `cleanQuiz` (the only gate on what the admin saves), `isOpen`, `planPlay` (question
|
|
12
|
+
order, option order, `draw`, fixed at start), `publicQuestion` (never `correct`), `mark`, `foldAnswer`.
|
|
13
|
+
- `lib/engine.js` - PURE clock: `catchUp`, `serve`, `answer`, `end`, `view`. Each mutates the play and says if it
|
|
14
|
+
changed; the route saves through `plays.change()` (locked read-modify-write).
|
|
15
|
+
- `lib/store.js` - `quiz-quizzes` collection + `plays = new PlayStore('quiz-plays', {order: 'score'})`.
|
|
16
|
+
- `plugin.js` - routes (header comment lists them), shortcodes, the `top-scores` notification source.
|
|
17
|
+
- `public/quiz.js` - the player (domma-reactive bindings), injected on every page by `inject/body.html`. Exports
|
|
18
|
+
`mount(el, {test})`, which the admin's Test screen uses.
|
|
19
|
+
- `admin/views/quiz.js` - list, editor, test and plays screens, all domma-reactive through the admin kit
|
|
20
|
+
(`mountScreen`, `wireRows`, `sortableRows`, `testScreen`, `playsScreen` in `_lib/admin/games/kit.js`). Templates
|
|
21
|
+
are strings in the JS (a templateUrl goes through `_.render`, which eats data-each `{{ }}`); the questions are an
|
|
22
|
+
`observableArray` of question models with their answers nested as `{{#each options key=id}}`.
|
|
23
|
+
- Tests: `tests/plugins/games-pure.test.js`, `tests/plugins/games-api.test.js` in the CMS repo.
|
|
24
|
+
|
|
25
|
+
## Contracts
|
|
26
|
+
|
|
27
|
+
- **The server keeps time and marks.** A question's clock starts at `serve()` - the GET of `/public/play/:id` -
|
|
28
|
+
and only then can it be answered (`answer()` refuses an unserved one with `code: 'not-current'`). So the player
|
|
29
|
+
must GET the next question after each answer, not use the one in the answer response. `GRACE_MS` (3s) covers
|
|
30
|
+
latency; late answers are marked wrong, never refused.
|
|
31
|
+
- **The play token** (`X-Play-Token`) is returned once by start and compared with `timingSafeEqual`.
|
|
32
|
+
`PlayStore.adminView` strips it and the state from everything the admin sees.
|
|
33
|
+
- **Leaderboards** rank finished, unhidden plays, best per `nameKey`, score then time (`_lib/games/ranking.js`).
|
|
34
|
+
- **Shortcode prefix trap:** the engine's plugin-shortcode regex is a PREFIX match, so `[quiz` also matches
|
|
35
|
+
`[quiz-leaderboard`. `quiz-leaderboard` is registered with `{priority: 5}` so it runs first. Keep it that way.
|
|
36
|
+
- **Domma bindings:** a `data-on-submit` handler must return `false` synchronously - an async one returns a
|
|
37
|
+
Promise and the form submits natively (page reload).
|
|
38
|
+
- Collections' API verbs are all off: nothing here is readable through `/api/v1`.
|
|
39
|
+
- **Test plays** (1.1.0): `GET/POST /quizzes/:id/test` (manage) give the start screen and a play marked `test: true`,
|
|
40
|
+
for the SAVED quiz whatever its status. It is played through the ordinary `/public/play` routes. `PlayStore` leaves
|
|
41
|
+
tests out of `forGame` (so boards, places, goes-per-name and the admin list), `todayBadge` skips them,
|
|
42
|
+
`afterFinish` never notifies for one, and the next test of the game clears them (`clearTests`). `/result` answers
|
|
43
|
+
`test: true` and `wouldPlace` instead of a place. Tests: `games-api.test.js` "test plays".
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* Quiz admin - the question editor. Shared pieces are in /plugins/_lib/admin/games/games.css. */
|
|
2
|
+
.qza-questions { display: flex; flex-direction: column; gap: .75rem; }
|
|
3
|
+
.qza-question { border: 1px solid var(--gma-line); border-left: 4px solid var(--dm-primary); border-radius: var(--dm-radius-md, 8px);
|
|
4
|
+
padding: .85rem 1rem; display: flex; flex-direction: column; gap: .6rem; background: var(--dm-background, transparent); }
|
|
5
|
+
.qza-question .form-select { width: auto; }
|
|
6
|
+
.qza-answers { display: flex; flex-direction: column; gap: .4rem; }
|
|
7
|
+
.qza-answer { display: flex; align-items: center; gap: .5rem; padding: .25rem .4rem; border-radius: 6px; }
|
|
8
|
+
.qza-answer.is-right { background: color-mix(in srgb, var(--dm-success) 12%, transparent); }
|
|
9
|
+
.qza-answer .form-input { flex: 1; min-width: 0; }
|
|
10
|
+
.qza-mark { display: inline-flex; align-items: center; cursor: pointer; padding: .2rem; }
|
|
11
|
+
.qza-mark input { width: 1.1rem; height: 1.1rem; accent-color: var(--dm-success); }
|
|
12
|
+
.qza-fixed { flex: 1; font-weight: 500; }
|
|
13
|
+
.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>
|