@onnie81/murdoku-spor 1.0.0

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 (54) hide show
  1. package/.dockerignore +7 -0
  2. package/Dockerfile +40 -0
  3. package/README.md +125 -0
  4. package/client/index.html +16 -0
  5. package/client/package.json +17 -0
  6. package/client/public/favicon.svg +14 -0
  7. package/client/public/manifest.webmanifest +12 -0
  8. package/client/src/App.jsx +62 -0
  9. package/client/src/api.js +40 -0
  10. package/client/src/art/logo.jsx +29 -0
  11. package/client/src/art/objects.jsx +158 -0
  12. package/client/src/art/portraits.jsx +197 -0
  13. package/client/src/components/Board.jsx +236 -0
  14. package/client/src/components/CluePanel.jsx +43 -0
  15. package/client/src/components/DailyView.jsx +114 -0
  16. package/client/src/components/FriendsView.jsx +112 -0
  17. package/client/src/components/HistoryView.jsx +80 -0
  18. package/client/src/components/HomeView.jsx +101 -0
  19. package/client/src/components/PlayView.jsx +323 -0
  20. package/client/src/components/SettingsView.jsx +134 -0
  21. package/client/src/components/Tray.jsx +48 -0
  22. package/client/src/difficulties.js +2 -0
  23. package/client/src/game.js +376 -0
  24. package/client/src/i18n/en.js +187 -0
  25. package/client/src/i18n/es.js +187 -0
  26. package/client/src/i18n/index.js +145 -0
  27. package/client/src/main.jsx +6 -0
  28. package/client/src/store.js +77 -0
  29. package/client/src/styles.css +374 -0
  30. package/client/vite.config.js +16 -0
  31. package/docker-compose.yml +21 -0
  32. package/murdoku.bundle +0 -0
  33. package/package.json +41 -0
  34. package/scripts/deploy.sh +49 -0
  35. package/scripts/npm-server-bootstrap.sh +53 -0
  36. package/scripts/npm-server-deploy.sh +61 -0
  37. package/scripts/server-bootstrap.sh +64 -0
  38. package/scripts/server-deploy.sh +40 -0
  39. package/server/api.js +354 -0
  40. package/server/auth.js +85 -0
  41. package/server/db.js +251 -0
  42. package/server/index.js +33 -0
  43. package/server/public/assets/index-COhad2G1.js +40 -0
  44. package/server/public/assets/index-h4AwMTqN.css +1 -0
  45. package/server/public/favicon.svg +14 -0
  46. package/server/public/index.html +17 -0
  47. package/server/public/manifest.webmanifest +12 -0
  48. package/server/puzzles.js +109 -0
  49. package/shared/bits.js +100 -0
  50. package/shared/clues.js +338 -0
  51. package/shared/generator.js +299 -0
  52. package/shared/model.js +127 -0
  53. package/shared/rng.js +51 -0
  54. package/shared/solver.js +262 -0
@@ -0,0 +1,197 @@
1
+ // Original flat "art-deco cozy mystery" suspect portraits. All hand-drawn SVG.
2
+ import React from 'react';
3
+
4
+ export const PERSON_COLORS = {
5
+ butler: '#6e56cf', chef: '#e5484d', heiress: '#e93d82', colonel: '#46a758',
6
+ professor: '#0090ff', gardener: '#f76b15', doctor: '#12a594', chauffeur: '#ad7f58',
7
+ diva: '#cf3897', journalist: '#ffc53d', magician: '#00a2c7', curator: '#bdee63'
8
+ };
9
+ export const DARK_TEXT_KEYS = new Set(['journalist', 'curator']);
10
+
11
+ const SKIN = {
12
+ butler: '#f2c9a0', chef: '#f2c9a0', heiress: '#f5d5b5', colonel: '#d9a066',
13
+ professor: '#a9714b', gardener: '#7c4f33', doctor: '#f2c9a0', chauffeur: '#d9a066',
14
+ diva: '#a9714b', journalist: '#f5d5b5', magician: '#d9a066', curator: '#f2c9a0'
15
+ };
16
+ const INK = '#241e33';
17
+ const COAT = '#2b2440';
18
+
19
+ function Base({ k, children }) {
20
+ const skin = SKIN[k];
21
+ return (
22
+ <>
23
+ {/* shoulders / coat */}
24
+ <path d="M16,92 Q50,64 84,92 L84,102 L16,102 Z" fill={COAT} />
25
+ {/* neck + head */}
26
+ <rect x="43" y="56" width="14" height="16" rx="5" fill={skin} />
27
+ <ellipse cx="50" cy="45" rx="16.5" ry="18.5" fill={skin} />
28
+ {/* eyes */}
29
+ <circle cx="43.5" cy="46" r="1.9" fill={INK} />
30
+ <circle cx="56.5" cy="46" r="1.9" fill={INK} />
31
+ {children}
32
+ </>
33
+ );
34
+ }
35
+
36
+ const DETAILS = {
37
+ butler: (
38
+ <>
39
+ <path d="M33.5,42 Q34,26 50,26 Q66,26 66.5,42 Q64,32 50,31.5 Q36,32 33.5,42 Z" fill={INK} />
40
+ <circle cx="56.5" cy="46" r="5.4" fill="none" stroke="#c9a227" strokeWidth="1.6" />
41
+ <path d="M61.5,49 Q64,56 61,60" fill="none" stroke="#c9a227" strokeWidth="1.1" />
42
+ <path d="M40,78 L50,72 L60,78 L50,84 Z" fill="#efe6d0" />
43
+ <path d="M43,78 L50,74.5 L57,78 L50,81.5 Z" fill={INK} />
44
+ </>
45
+ ),
46
+ chef: (
47
+ <>
48
+ <path d="M32,34 Q30,18 40,17 Q42,10 50,11 Q58,10 60,17 Q70,18 68,34 Z" fill="#f4efe2" />
49
+ <rect x="33" y="32" width="34" height="6" rx="2.5" fill="#e3dbc8" />
50
+ <path d="M38,76 Q50,68 62,76 L58,82 Q50,76 42,82 Z" fill="#e5484d" />
51
+ </>
52
+ ),
53
+ heiress: (
54
+ <>
55
+ <ellipse cx="50" cy="29" rx="27" ry="7.5" fill="#3b2f54" />
56
+ <path d="M34,29 Q34,15 50,15 Q66,15 66,29 Z" fill="#3b2f54" />
57
+ <rect x="34" y="24" width="32" height="4.5" rx="2" fill="#e93d82" />
58
+ <path d="M35,45 Q37,58 44,63 L38,52 Z" fill="#8d4a2f" />
59
+ <path d="M65,45 Q63,58 56,63 L62,52 Z" fill="#8d4a2f" />
60
+ {[42, 46.5, 50, 53.5, 58].map((x, i) => (
61
+ <circle key={i} cx={x} cy={i % 2 ? 73 : 74.6} r="1.9" fill="#f4efe2" />
62
+ ))}
63
+ </>
64
+ ),
65
+ colonel: (
66
+ <>
67
+ <path d="M32,34 Q32,20 50,20 Q68,20 68,34 L68,37 L32,37 Z" fill="#3d4a33" />
68
+ <path d="M30,37 L70,37 L66,42 L34,42 Z" fill="#2c3625" />
69
+ <circle cx="50" cy="28" r="3" fill="#c9a227" />
70
+ <path d="M38,54 Q44,50 50,54 Q56,50 62,54 Q58,60 50,57 Q42,60 38,54 Z" fill="#cbc4d4" />
71
+ <rect x="18" y="84" width="12" height="4" rx="2" fill="#c9a227" />
72
+ <rect x="70" y="84" width="12" height="4" rx="2" fill="#c9a227" />
73
+ </>
74
+ ),
75
+ professor: (
76
+ <>
77
+ <circle cx="50" cy="20" r="7.5" fill="#5d4037" />
78
+ <path d="M33.5,42 Q34,26 50,26 Q66,26 66.5,42 Q62,31 50,30.5 Q38,31 33.5,42 Z" fill="#5d4037" />
79
+ <circle cx="43.5" cy="46" r="5.2" fill="none" stroke={INK} strokeWidth="1.7" />
80
+ <circle cx="56.5" cy="46" r="5.2" fill="none" stroke={INK} strokeWidth="1.7" />
81
+ <line x1="48.7" y1="46" x2="51.3" y2="46" stroke={INK} strokeWidth="1.7" />
82
+ <path d="M42,76 L50,71 L58,76 L50,80 Z" fill="#0090ff" />
83
+ </>
84
+ ),
85
+ gardener: (
86
+ <>
87
+ <ellipse cx="50" cy="31" rx="26" ry="6.5" fill="#d9b566" />
88
+ <path d="M36,31 Q36,17 50,17 Q64,17 64,31 Z" fill="#d9b566" />
89
+ <path d="M36,29 L64,29 L64,32 L36,32 Z" fill="#8d6b30" />
90
+ <path d="M38,74 Q50,68 62,74 L60,80 Q50,73 40,80 Z" fill="#f76b15" />
91
+ <circle cx="50" cy="77" r="2.4" fill="#c14f0e" />
92
+ </>
93
+ ),
94
+ doctor: (
95
+ <>
96
+ <path d="M33,48 Q31,26 50,26 Q69,26 67,48 Q66,34 50,33 Q34,34 33,48 Z" fill="#8d4a2f" />
97
+ <path d="M33,48 Q35,54 40,52 L36,44 Z" fill="#8d4a2f" />
98
+ <path d="M67,48 Q65,54 60,52 L64,44 Z" fill="#8d4a2f" />
99
+ <circle cx="44" cy="46.5" r="4.6" fill="none" stroke="#c9a227" strokeWidth="1.5" />
100
+ <circle cx="56" cy="46.5" r="4.6" fill="none" stroke="#c9a227" strokeWidth="1.5" />
101
+ <path d="M48.5,46.5 L51.5,46.5" stroke="#c9a227" strokeWidth="1.5" />
102
+ <path d="M56,51 Q60,58 58,64" stroke="#c9a227" strokeWidth="1.1" fill="none" />
103
+ <circle cx="50" cy="78" r="3.2" fill="#12a594" />
104
+ </>
105
+ ),
106
+ chauffeur: (
107
+ <>
108
+ <path d="M32,33 Q32,20 50,20 Q68,20 68,33 L68,35 L32,35 Z" fill="#41597a" />
109
+ <path d="M46,35 L74,35 L70,40 L46,38 Z" fill="#31435c" />
110
+ <circle cx="42" cy="27" r="5" fill="none" stroke="#b8c4d4" strokeWidth="2.2" />
111
+ <circle cx="55" cy="27" r="5" fill="none" stroke="#b8c4d4" strokeWidth="2.2" />
112
+ <path d="M40,76 Q50,70 60,76 Q55,80 50,78 Q45,80 40,76 Z" fill="#cbc4d4" />
113
+ </>
114
+ ),
115
+ diva: (
116
+ <>
117
+ <path d="M50,22 Q46,8 34,6 Q44,4 52,10 Q50,4 58,2 Q54,8 56,14 Q60,8 68,8 Q58,12 56,22 Z" fill="#cf3897" />
118
+ <path d="M34,40 Q34,24 50,24 Q66,24 66,40 Q60,29 50,29 Q40,29 34,40 Z" fill={INK} />
119
+ <rect x="36" y="35" width="28" height="3.6" rx="1.8" fill="#c9a227" />
120
+ <circle cx="50" cy="36.8" r="2.6" fill="#e93d82" />
121
+ <path d="M33,46 L33,58 M67,46 L67,58" stroke="#c9a227" strokeWidth="2" />
122
+ <circle cx="33" cy="60" r="2.4" fill="#c9a227" />
123
+ <circle cx="67" cy="60" r="2.4" fill="#c9a227" />
124
+ </>
125
+ ),
126
+ journalist: (
127
+ <>
128
+ <path d="M31,36 Q31,19 50,19 Q69,19 69,36 L69,38 L31,38 Z" fill="#7a4b2a" />
129
+ <ellipse cx="50" cy="38" rx="24" ry="4.5" fill="#5d3820" />
130
+ <rect x="55" y="24" width="9" height="10" rx="1" fill="#efe6d0" transform="rotate(8 59.5 29)" />
131
+ <path d="M57,27 L62,27 M57,29.5 L62,29.5 M57,32 L60,32" stroke={INK} strokeWidth="0.9" transform="rotate(8 59.5 29)" />
132
+ <path d="M33,44 Q33,56 40,60 L36,47 Z" fill="#7a4b2a" />
133
+ <path d="M67,44 Q67,56 60,60 L64,47 Z" fill="#7a4b2a" />
134
+ </>
135
+ ),
136
+ magician: (
137
+ <>
138
+ <rect x="36" y="6" width="28" height="22" rx="2" fill={INK} />
139
+ <rect x="36" y="22" width="28" height="5" fill="#00a2c7" />
140
+ <ellipse cx="50" cy="29" rx="21" ry="4" fill={INK} />
141
+ <path d="M40,52 Q44,55 47,52 M53,52 Q56,55 60,52" stroke={INK} strokeWidth="1.6" fill="none" />
142
+ <path d="M41,77 L50,72 L59,77 L50,82 Z" fill="#00a2c7" />
143
+ </>
144
+ ),
145
+ curator: (
146
+ <>
147
+ <path d="M31,33 Q30,18 48,17 Q70,15 67,28 Q60,22 50,24 Q36,26 36,36 Z" fill="#4a3a63" />
148
+ <circle cx="66" cy="24" r="3" fill="#4a3a63" />
149
+ <circle cx="43.5" cy="46" r="5" fill="none" stroke={INK} strokeWidth="1.5" />
150
+ <circle cx="56.5" cy="46" r="5" fill="none" stroke={INK} strokeWidth="1.5" />
151
+ <path d="M48.5,46 L51.5,46 M38.5,46 L34,44" stroke={INK} strokeWidth="1.5" />
152
+ <path d="M46,58 Q50,63 54,58 Q52,65 50,65 Q48,65 46,58 Z" fill="#4a3a63" />
153
+ <path d="M40,77 L60,77 L58,81 L42,81 Z" fill="#bdee63" />
154
+ </>
155
+ )
156
+ };
157
+
158
+ export function Portrait({ k, size = 44, victim = false, className = '', title }) {
159
+ const color = PERSON_COLORS[k] || '#888';
160
+ return (
161
+ <svg
162
+ viewBox="0 0 100 100" width={size} height={size}
163
+ className={`portrait ${victim ? 'portrait-victim' : ''} ${className}`}
164
+ role="img" aria-label={title || k}
165
+ >
166
+ <defs>
167
+ <clipPath id={`pc-${k}`}><circle cx="50" cy="50" r="48" /></clipPath>
168
+ </defs>
169
+ <circle cx="50" cy="50" r="48" fill={color} opacity={victim ? 0.45 : 1} />
170
+ <circle cx="50" cy="50" r="48" fill="#1a1526" opacity="0.18" />
171
+ <g clipPath={`url(#pc-${k})`} filter={victim ? 'grayscale(0.9)' : undefined}>
172
+ <Base k={k}>{DETAILS[k]}</Base>
173
+ {victim && (
174
+ <g stroke="#241e33" strokeWidth="2" opacity="0.9">
175
+ <path d="M40.5,44 L46.5,48 M46.5,44 L40.5,48" />
176
+ <path d="M53.5,44 L59.5,48 M59.5,44 L53.5,48" />
177
+ </g>
178
+ )}
179
+ </g>
180
+ <circle cx="50" cy="50" r="47" fill="none" stroke="#12101c" strokeWidth="2.5" opacity="0.55" />
181
+ </svg>
182
+ );
183
+ }
184
+
185
+ export function NumberBadge({ n, k, size = 16 }) {
186
+ const dark = DARK_TEXT_KEYS.has(k);
187
+ return (
188
+ <span
189
+ className="num-badge"
190
+ style={{
191
+ width: size, height: size, fontSize: size * 0.62,
192
+ background: PERSON_COLORS[k] || '#888',
193
+ color: dark ? '#241e33' : '#fff'
194
+ }}
195
+ >{n}</span>
196
+ );
197
+ }
@@ -0,0 +1,236 @@
1
+ // The crime-scene board: rooms, walls, furniture, people, X marks, pencil
2
+ // marks, legality graying, clue spotlights. Pure SVG, pointer-driven.
3
+ import React, { forwardRef, useImperativeHandle, useRef, useMemo } from 'react';
4
+ import { Portrait, PERSON_COLORS, DARK_TEXT_KEYS } from '../art/portraits.jsx';
5
+ import { ObjectIcon } from '../art/objects.jsx';
6
+ import { roomName } from '../i18n/index.js';
7
+
8
+ const CS = 100; // cell size in svg units
9
+ const M = 6; // outer margin
10
+
11
+ export const ROOM_COLORS = [
12
+ '#4e4370', '#3e5644', '#5e4141', '#39586e',
13
+ '#5f5535', '#35594f', '#5d3d5d', '#4d5533'
14
+ ];
15
+
16
+ const NOTE_POS = [
17
+ [22, 22], [50, 22], [78, 22],
18
+ [22, 50], [50, 50], [78, 50],
19
+ [22, 78], [50, 78], [78, 78]
20
+ ];
21
+
22
+ export const Board = forwardRef(function Board(
23
+ { store, snap, marks = 'numbers', dragTarget = null, onCellPointerDown },
24
+ ref
25
+ ) {
26
+ const { puzzle, N, objCells } = store;
27
+ const svgRef = useRef(null);
28
+ const size = N * CS + M * 2;
29
+
30
+ useImperativeHandle(ref, () => ({
31
+ cellFromPoint(clientX, clientY) {
32
+ const el = svgRef.current;
33
+ if (!el) return null;
34
+ const r = el.getBoundingClientRect();
35
+ const u = ((clientX - r.left) / r.width) * size - M;
36
+ const v = ((clientY - r.top) / r.height) * size - M;
37
+ const c = Math.floor(u / CS), row = Math.floor(v / CS);
38
+ if (c < 0 || row < 0 || c >= N || row >= N) return null;
39
+ return row * N + c;
40
+ },
41
+ el: () => svgRef.current
42
+ }), [N, size]);
43
+
44
+ const roomOf = useMemo(() => {
45
+ const arr = new Array(N * N).fill(0);
46
+ for (const r of puzzle.data.rooms) for (const c of r.cells) arr[c] = r.id;
47
+ return arr;
48
+ }, [puzzle, N]);
49
+
50
+ const roomAnchor = useMemo(() => {
51
+ const m = new Map();
52
+ for (const r of puzzle.data.rooms) m.set(r.id, Math.min(...r.cells));
53
+ return m;
54
+ }, [puzzle]);
55
+
56
+ const cellOwner = useMemo(() => {
57
+ const m = new Map();
58
+ for (const [p, c] of Object.entries(snap.placed)) m.set(c, Number(p));
59
+ return m;
60
+ }, [snap.placed]);
61
+
62
+ // legality view: which cells gray out for the active person
63
+ const activePerson = dragTarget != null ? dragTarget : snap.selected;
64
+ const legalSet = useMemo(
65
+ () => (activePerson != null && snap.status === 'active' ? store.legalCells(activePerson) : null),
66
+ [activePerson, snap.placed, snap.xmarks, snap.status] // eslint-disable-line react-hooks/exhaustive-deps
67
+ );
68
+
69
+ const hl = snap.highlight;
70
+ const hlCells = useMemo(() => new Set(hl?.cells || []), [hl]);
71
+ const hlRooms = useMemo(() => new Set(hl?.rooms || []), [hl]);
72
+ const hlPersons = useMemo(() => new Set(hl?.persons || []), [hl]);
73
+ const xset = useMemo(() => new Set(snap.xmarks), [snap.xmarks]);
74
+ const wrongSet = useMemo(() => new Set(snap.wrongCells), [snap.wrongCells]);
75
+
76
+ const cells = [];
77
+ for (let cell = 0; cell < N * N; cell++) {
78
+ const r = Math.floor(cell / N), c = cell % N;
79
+ const x = M + c * CS, y = M + r * CS;
80
+ const room = roomOf[cell];
81
+ cells.push({ cell, r, c, x, y, room });
82
+ }
83
+
84
+ // walls between rooms
85
+ const walls = [];
86
+ for (const { cell, r, c, x, y, room } of cells) {
87
+ if (c < N - 1 && roomOf[cell + 1] !== room) walls.push(<line key={`v${cell}`} x1={x + CS} y1={y - 1} x2={x + CS} y2={y + CS + 1} />);
88
+ if (r < N - 1 && roomOf[cell + N] !== room) walls.push(<line key={`h${cell}`} x1={x - 1} y1={y + CS} x2={x + CS + 1} y2={y + CS} />);
89
+ }
90
+
91
+ return (
92
+ <svg
93
+ ref={svgRef}
94
+ className="board"
95
+ viewBox={`0 0 ${size} ${size}`}
96
+ role="grid"
97
+ aria-label="crime scene"
98
+ >
99
+ {/* room fills */}
100
+ {cells.map(({ cell, x, y, room }) => (
101
+ <rect key={cell} x={x} y={y} width={CS} height={CS}
102
+ fill={ROOM_COLORS[room % ROOM_COLORS.length]}
103
+ className={hlRooms.has(room) ? 'room-hl' : undefined} />
104
+ ))}
105
+ {/* subtle inner grid */}
106
+ {Array.from({ length: N - 1 }, (_, i) => (
107
+ <g key={i} stroke="#00000030" strokeWidth="1.5">
108
+ <line x1={M + (i + 1) * CS} y1={M} x2={M + (i + 1) * CS} y2={size - M} />
109
+ <line x1={M} y1={M + (i + 1) * CS} x2={size - M} y2={M + (i + 1) * CS} />
110
+ </g>
111
+ ))}
112
+ {/* clue spotlight on cells */}
113
+ {cells.filter(({ cell }) => hlCells.has(cell)).map(({ cell, x, y }) => (
114
+ <rect key={`hl${cell}`} x={x + 2} y={y + 2} width={CS - 4} height={CS - 4}
115
+ className="cell-spot" rx="8" />
116
+ ))}
117
+ {/* walls */}
118
+ <g stroke="#161022" strokeWidth="6" strokeLinecap="square">{walls}</g>
119
+ <rect x={M} y={M} width={N * CS} height={N * CS} fill="none" stroke="#161022" strokeWidth="10" rx="4" />
120
+
121
+ {/* room labels */}
122
+ {puzzle.data.rooms.map(room => {
123
+ const anchor = roomAnchor.get(room.id);
124
+ const r = Math.floor(anchor / N), c = anchor % N;
125
+ return (
126
+ <text key={room.id} x={M + c * CS + 8} y={M + r * CS + 20}
127
+ className="room-label">{roomName(puzzle, room.id, 'n').toUpperCase()}</text>
128
+ );
129
+ })}
130
+
131
+ {/* objects */}
132
+ {puzzle.data.objects.map(o => {
133
+ const r = Math.floor(o.cell / N), c = o.cell % N;
134
+ return (
135
+ <g key={o.id} className="obj" transform={`translate(${M + c * CS + 14},${M + r * CS + 16})`}>
136
+ <ObjectIcon type={o.type} size={72} />
137
+ </g>
138
+ );
139
+ })}
140
+
141
+ {/* X marks */}
142
+ {snap.xmarks.map(cell => {
143
+ const r = Math.floor(cell / N), c = cell % N;
144
+ const x = M + c * CS, y = M + r * CS;
145
+ return (
146
+ <g key={`x${cell}`} className="xmark" strokeLinecap="round">
147
+ <line x1={x + 30} y1={y + 30} x2={x + 70} y2={y + 70} />
148
+ <line x1={x + 70} y1={y + 30} x2={x + 30} y2={y + 70} />
149
+ </g>
150
+ );
151
+ })}
152
+
153
+ {/* pencil marks */}
154
+ {Object.entries(snap.notes).map(([cellStr, list]) => {
155
+ const cell = Number(cellStr);
156
+ const r = Math.floor(cell / N), c = cell % N;
157
+ const x = M + c * CS, y = M + r * CS;
158
+ return (
159
+ <g key={`n${cell}`}>
160
+ {list.slice(0, 9).map((p, i) => {
161
+ const [nx, ny] = NOTE_POS[i];
162
+ const k = puzzle.data.cast[p]?.key;
163
+ if (marks === 'faces') {
164
+ return (
165
+ <g key={p} transform={`translate(${x + nx - 13},${y + ny - 13})`} opacity="0.92">
166
+ <Portrait k={k} size={26} victim={p === puzzle.data.victim} />
167
+ </g>
168
+ );
169
+ }
170
+ return (
171
+ <g key={p}>
172
+ <circle cx={x + nx} cy={y + ny} r="12.5" fill={PERSON_COLORS[k]} opacity="0.92" />
173
+ <text x={x + nx} y={y + ny + 5.2} textAnchor="middle" className="note-num"
174
+ fill={DARK_TEXT_KEYS.has(k) ? '#241e33' : '#fff'}>{p + 1}</text>
175
+ </g>
176
+ );
177
+ })}
178
+ </g>
179
+ );
180
+ })}
181
+
182
+ {/* placed persons */}
183
+ {[...cellOwner.entries()].map(([cell, p]) => {
184
+ const r = Math.floor(cell / N), c = cell % N;
185
+ const x = M + c * CS, y = M + r * CS;
186
+ const k = puzzle.data.cast[p]?.key;
187
+ const isVictim = p === puzzle.data.victim;
188
+ const selected = snap.selected === p;
189
+ const wrong = wrongSet.has(cell);
190
+ const spotted = hlPersons.has(p);
191
+ return (
192
+ <g key={`p${p}`} className={`piece${selected ? ' piece-sel' : ''}${dragTarget === p ? ' piece-drag' : ''}`}>
193
+ {(selected || spotted) && (
194
+ <circle cx={x + 50} cy={y + 50} r="45" className={spotted && !selected ? 'ring-spot' : 'ring-sel'} />
195
+ )}
196
+ {wrong && <circle cx={x + 50} cy={y + 50} r="44" className="ring-wrong" />}
197
+ <g transform={`translate(${x + 11},${y + 11})`}>
198
+ <Portrait k={k} size={78} victim={isVictim} />
199
+ </g>
200
+ <circle cx={x + 82} cy={y + 20} r="13" fill={isVictim ? '#8d1d2c' : PERSON_COLORS[k]} stroke="#161022" strokeWidth="2" />
201
+ <text x={x + 82} y={y + 25.5} textAnchor="middle" className="piece-num"
202
+ fill={!isVictim && DARK_TEXT_KEYS.has(k) ? '#241e33' : '#fff'}>
203
+ {isVictim ? 'V' : p + 1}
204
+ </text>
205
+ </g>
206
+ );
207
+ })}
208
+
209
+ {/* graying of non-allowed positions for the active person */}
210
+ {legalSet && cells.map(({ cell, x, y }) => {
211
+ if (legalSet.has(cell)) return null;
212
+ if (cellOwner.get(cell) === activePerson) return null;
213
+ return <rect key={`g${cell}`} x={x} y={y} width={CS} height={CS} className="cell-gray" />;
214
+ })}
215
+ {/* legal glow */}
216
+ {legalSet && cells.filter(({ cell }) => legalSet.has(cell)).map(({ cell, x, y }) => (
217
+ <rect key={`l${cell}`} x={x + 3} y={y + 3} width={CS - 6} height={CS - 6}
218
+ rx="10" className="cell-legal" />
219
+ ))}
220
+ {/* illegal tap flash */}
221
+ {snap.flashCell != null && (() => {
222
+ const r = Math.floor(snap.flashCell / N), c = snap.flashCell % N;
223
+ return <rect x={M + c * CS + 3} y={M + r * CS + 3} width={CS - 6} height={CS - 6}
224
+ rx="10" className="cell-flash" />;
225
+ })()}
226
+
227
+ {/* hit layer */}
228
+ {cells.map(({ cell, x, y }) => (
229
+ <rect key={`t${cell}`} x={x} y={y} width={CS} height={CS} fill="transparent"
230
+ style={{ cursor: 'pointer' }}
231
+ onPointerDown={(e) => onCellPointerDown?.(cell, e)}
232
+ onContextMenu={(e) => { e.preventDefault(); store.actions.toggleX(cell); }} />
233
+ ))}
234
+ </svg>
235
+ );
236
+ });
@@ -0,0 +1,43 @@
1
+ // Testimony list: tap to spotlight on the board, tap the badge to cross off.
2
+ import React from 'react';
3
+ import { Portrait } from '../art/portraits.jsx';
4
+ import { clueText, t } from '../i18n/index.js';
5
+
6
+ export function CluePanel({ store, snap }) {
7
+ const { puzzle } = store;
8
+ return (
9
+ <div className="clue-panel">
10
+ <div className="clue-head">
11
+ <h3>{t('clues')}</h3>
12
+ <span className="clue-hint">{t('clueTapHint')}</span>
13
+ </div>
14
+ <ul className="clue-list">
15
+ {puzzle.data.clues.map(clue => {
16
+ const struck = snap.struck.includes(clue.id);
17
+ const active = snap.highlight?.clueId === clue.id;
18
+ const subjects = [clue.s, clue.t].filter(v => v != null);
19
+ return (
20
+ <li key={clue.id}
21
+ className={`clue${struck ? ' clue-struck' : ''}${active ? ' clue-active' : ''}`}>
22
+ <button
23
+ className="clue-check"
24
+ aria-label="strike"
25
+ onClick={(e) => { e.stopPropagation(); store.actions.strike(clue.id); }}
26
+ >{struck ? '✓' : ''}</button>
27
+ <button className="clue-body" onClick={() => store.actions.highlightClue(clue)}>
28
+ <span className="clue-faces">
29
+ {subjects.length
30
+ ? subjects.map(p => (
31
+ <Portrait key={p} k={puzzle.data.cast[p]?.key} size={26} />
32
+ ))
33
+ : <span className="clue-glass">🔍</span>}
34
+ </span>
35
+ <span className="clue-text">{clueText(clue, puzzle)}</span>
36
+ </button>
37
+ </li>
38
+ );
39
+ })}
40
+ </ul>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,114 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { api } from '../api.js';
3
+ import { useApp, navigate, toast } from '../store.js';
4
+ import { t, fmtTime, fmtDate } from '../i18n/index.js';
5
+
6
+ export function DailyView() {
7
+ const app = useApp();
8
+ const [date, setDate] = useState(null);
9
+ const [daily, setDaily] = useState(null);
10
+ const [board, setBoard] = useState(null); // {difficulty, scope, data}
11
+
12
+ const load = (d) => {
13
+ api.daily(d).then(res => {
14
+ setDaily(res);
15
+ setDate(res.date);
16
+ }).catch(() => toast(t('errGeneric')));
17
+ };
18
+ useEffect(() => { load(); }, []);
19
+
20
+ const openBoard = async (difficulty, scope) => {
21
+ try {
22
+ const data = await api.leaderboard(date, difficulty, scope);
23
+ setBoard({ difficulty, scope, data });
24
+ } catch (e) {
25
+ toast(e.code === 'login_required' ? t('friendsLoginNote') : t('errGeneric'));
26
+ }
27
+ };
28
+
29
+ const play = async (difficulty) => {
30
+ try {
31
+ const res = await api.newGame('daily', difficulty, date);
32
+ navigate(`#/play/${res.game.id}`);
33
+ } catch { toast(t('errGeneric')); }
34
+ };
35
+
36
+ if (!daily) return <div className="view-pad loading">{t('loading')}</div>;
37
+
38
+ return (
39
+ <div className="view">
40
+ <div className="card-head view-head">
41
+ <h1>{t('dailyTitle')}</h1>
42
+ <p className="muted">{t('dailySub')}</p>
43
+ </div>
44
+
45
+ <div className="card">
46
+ <div className="archive-row">
47
+ <label className="muted" htmlFor="daily-date">{t('archive')}:</label>
48
+ <input
49
+ id="daily-date" type="date" value={date || ''}
50
+ max={daily.today}
51
+ onChange={e => { if (e.target.value) load(e.target.value); }}
52
+ />
53
+ {date !== daily.today && (
54
+ <button className="link-btn" onClick={() => load(daily.today)}>{t('todayIs')} →</button>
55
+ )}
56
+ </div>
57
+ </div>
58
+
59
+ {daily.tiers.map(tier => (
60
+ <div key={tier.difficulty} className="card daily-tier">
61
+ <div className="daily-tier-row">
62
+ <div className="daily-tier-info">
63
+ <b>{t(tier.difficulty)}</b>
64
+ <span className="muted"> · {tier.size}×{tier.size} · {t('playersSolved', { n: tier.solvedCount })}</span>
65
+ </div>
66
+ <div className="daily-tier-actions">
67
+ {tier.status === 'won'
68
+ ? <span className="won-chip">✓ {fmtTime(tier.timeMs)}</span>
69
+ : <button className="btn btn-sm btn-gold" onClick={() => play(tier.difficulty)}>
70
+ {tier.status === 'active' ? t('resume') : t('play')}
71
+ </button>}
72
+ <button className="btn btn-sm" onClick={() => openBoard(tier.difficulty, 'global')}>
73
+ {t('leaderboard')}
74
+ </button>
75
+ </div>
76
+ </div>
77
+
78
+ {board && board.difficulty === tier.difficulty && (
79
+ <div className="lb">
80
+ <div className="lb-tabs">
81
+ <button className={board.scope === 'global' ? 'on' : ''}
82
+ onClick={() => openBoard(tier.difficulty, 'global')}>{t('global')}</button>
83
+ <button className={board.scope === 'friends' ? 'on' : ''}
84
+ onClick={() => openBoard(tier.difficulty, 'friends')}>{t('friendsScope')}</button>
85
+ </div>
86
+ {board.data.rows.length === 0
87
+ ? <p className="muted">{t('noResults')}</p>
88
+ : (
89
+ <table className="lb-table">
90
+ <tbody>
91
+ {board.data.rows.map(r => (
92
+ <tr key={r.rank} className={app.me && r.username === app.me.username ? 'lb-me' : ''}>
93
+ <td className="lb-rank">#{r.rank}</td>
94
+ <td>{r.username}</td>
95
+ <td className="lb-time">{fmtTime(r.timeMs)}</td>
96
+ <td className="lb-mist">✗{r.mistakes}</td>
97
+ </tr>
98
+ ))}
99
+ </tbody>
100
+ </table>
101
+ )}
102
+ {board.data.me && (
103
+ <p className="muted lb-mine">
104
+ {t('yourTime')}: {fmtTime(board.data.me.timeMs)}
105
+ {board.data.me.rank ? ` · #${board.data.me.rank}` : ` (${t('unranked')})`}
106
+ </p>
107
+ )}
108
+ </div>
109
+ )}
110
+ </div>
111
+ ))}
112
+ </div>
113
+ );
114
+ }