File size: 43,966 Bytes
0cc8837
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
// Hushfox UI: pass-the-phone party mode (works offline), room-code mode (each player on their own phone), and the
// solo Fox Hunt (daily + 30 levels). Rules live in core.js; the room runs on the hushfox Worker (net.js).
// (c) 2026 CyberMax. All rights reserved.
import * as C from './core.js';
import { PACKS, deck, packById } from './packs.js';
import { count, store, toast, share, pwa, themeToggle, canvasBlob, download, today, dayNumber, SITE } from './cmx.js';
import { progress, daily, readChallenge, challengeLink, shareChallenge, sfx, burstAt, shake, renderMap, scoreCard, starsFor, starsHtml, css as lvCss } from './levels.js';
import * as pass from './funpass.js';
import { apiBase, createRoom, roomInfo, connect } from './net.js';

const GAME = 'hushfox';
const PATH = '/games/hushfox/';
const stage = document.getElementById('stage');
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
let muted = !!store(`${GAME}:muted`);
const snd = sfx(() => muted);
const buzz = (p) => { try { if (!muted && navigator.vibrate) navigator.vibrate(p); } catch { /* */ } };
const FOX_SVG = '<svg class="fox" viewBox="0 0 512 512" aria-hidden="true"><path d="M106 132 196 226h120l90-94-26 168-124 112-124-112Z" fill="#FF7A2F"/><path d="M130 178 182 230l-30 30ZM382 178l-52 52 30 30Z" fill="#FFD9B8"/><path d="M132 300 256 412V330l-66-40ZM380 300 256 412V330l66-40Z" fill="#FFF3E6"/><circle cx="256" cy="396" r="19" fill="#1C1530"/><path d="M178 284q24 18 48 0M286 284q24 18 48 0" fill="none" stroke="#1C1530" stroke-width="15" stroke-linecap="round"/></svg>';
const fox = (cls = '') => FOX_SVG.replace('class="fox"', `class="fox ${cls}"`);

let tick = 0; // the one running interval (timers); cleared on every screen change
function show(html, cls = '') {
  clearInterval(tick);
  clearInterval(huntTick);
  stage.innerHTML = `<div class="scr ${cls}">${html}</div>`;
  stage.scrollTop = 0;
  return stage.firstElementChild;
}
function on(id, fn) { const el = $(id); if (el) el.addEventListener('click', (e) => { snd.tap(); fn(e); }); return el; }
const isPro = () => pass.isPro();

// ------------------------------------------------------------------ shared bits

const LS_NAMES = `${GAME}:names`, LS_PACKS = `${GAME}:packs`, LS_ROUNDS = `${GAME}:rounds`, LS_PID = `${GAME}:pid`, LS_ME = `${GAME}:me`;
function pid() {
  let id = store(LS_PID);
  if (!id) { id = `p${Math.random().toString(36).slice(2, 12)}${Date.now().toString(36).slice(-4)}`; store(LS_PID, id); }
  return id;
}
function packChoice() {
  const saved = store(LS_PACKS) || ['classic'];
  const ok = saved.filter((id) => packById(id) && (!packById(id).pass || isPro()));
  return ok.length ? ok : ['classic'];
}
function packsHtml(sel, canEdit = true) {
  return `<div class="packs" role="group" aria-label="Packs">${PACKS.map((p) => {
    const locked = p.pass && !isPro();
    const n = p.cats.reduce((a, c) => a + c[2].split('|').length, 0);
    return `<button type="button" class="pack" data-pack="${p.id}" aria-pressed="${sel.includes(p.id)}" ${canEdit ? '' : 'disabled'}><i>${p.emoji}</i><span><b>${esc(p.name)}</b><span>${esc(p.desc)} · ${n} words</span></span>${locked ? '<em>🔒 Fun Pass</em>' : p.pass ? '<em>✨ Pass</em>' : '<em>Free</em>'}</button>`;
  }).join('')}</div>`;
}
function bindPacks(sel, onChange) {
  stage.querySelectorAll('[data-pack]').forEach((b) => b.addEventListener('click', () => {
    const p = packById(b.dataset.pack);
    if (p.pass && !isPro()) { pass.open(`pack-${p.id}`); return; }
    snd.tap();
    const i = sel.indexOf(p.id);
    if (i >= 0) { if (sel.length > 1) sel.splice(i, 1); } else sel.push(p.id);
    stage.querySelectorAll('[data-pack]').forEach((x) => x.setAttribute('aria-pressed', String(sel.includes(x.dataset.pack))));
    store(LS_PACKS, sel);
    onChange && onChange(sel);
  }));
}
function roundsHtml(n, canEdit = true) {
  return `<div class="chips" role="group" aria-label="Rounds">${C.ROUND_OPTIONS.map((r) => `<button type="button" class="chip" data-rounds="${r}" aria-pressed="${r === n}" ${canEdit ? '' : 'disabled'}>${r} rounds</button>`).join('')}</div>`;
}
function bindRounds(onPick) {
  stage.querySelectorAll('[data-rounds]').forEach((b) => b.addEventListener('click', () => {
    snd.tap();
    const n = Number(b.dataset.rounds);
    stage.querySelectorAll('[data-rounds]').forEach((x) => x.setAttribute('aria-pressed', String(x === b)));
    store(LS_ROUNDS, n);
    onPick(n);
  }));
}
function timerHtml() {
  return `<div class="timer" id="timer"><svg viewBox="0 0 120 120"><circle class="bg" cx="60" cy="60" r="52"/><circle class="fg" id="ring" cx="60" cy="60" r="52" stroke-dasharray="326.7" stroke-dashoffset="0"/></svg><b id="secs">30</b></div>`;
}
/** Drive the ring until `ends` (ms, local clock). onTwist fires once at TWIST_AT left; onEnd at zero. */
function runTimer(ends, total, { twistAt = 0, onTwist, onEnd } = {}) {
  let twisted = !twistAt || Date.now() >= twistAt, lastSec = -1;
  if (twisted && twistAt && onTwist) onTwist(true);
  const step = () => {
    const left = Math.max(0, ends - Date.now());
    const s = Math.ceil(left / 1000);
    const ring = $('ring'), secs = $('secs'), t = $('timer');
    if (!ring) { clearInterval(tick); return; }
    ring.style.strokeDashoffset = String(326.7 * (1 - left / total));
    if (s !== lastSec) {
      secs.textContent = s; lastSec = s;
      t.classList.toggle('hurry', s <= 5 && s > 0);
      if (s <= 5 && s > 0) snd.tone(520 + (5 - s) * 60, 0.05, 'square', 0.04, 1);
    }
    if (!twisted && twistAt && Date.now() >= twistAt) { twisted = true; onTwist && onTwist(false); }
    if (left <= 0) { clearInterval(tick); onEnd && onEnd(); }
  };
  step();
  tick = setInterval(step, 200);
}
function twistHtml(id, waiting = false) {
  if (waiting) return '<div class="twist wait" id="twist"><i>🃏</i><span>Twist card flips at 15 seconds…</span></div>';
  const t = C.twistById(id);
  return `<div class="twist flip" id="twist"><i>${t.icon}</i><span>TWIST! ${esc(t.text)}</span></div>`;
}
function flipTwist(id, quiet) {
  const el = $('twist');
  if (el) el.outerHTML = twistHtml(id);
  if (!quiet) { snd.boss(); buzz([60, 40, 60]); const t = $('twist'); if (t) burstAt(t, 0.5, 0.5, { colors: ['#FFE36B', '#FF7A2F', '#fff'], n: 24 }); }
}

// ------------------------------------------------------------------ home

function home() {
  const dd = dayNumber(today());
  show(`${fox()}<p class="logo">HUSH<i>FOX</i></p><p class="lead">Everyone knows the word. Except the Fox.</p>
    <button class="big hot" id="h-pass" type="button"><b>📱 Pass one phone</b><span>3-12 players around one phone · works offline</span></button>
    <button class="big" id="h-room" type="button"><b>🔗 Room code</b><span>Everyone joins on their own phone</span></button>
    <button class="big" id="h-hunt" type="button"><b>🔎 Fox Hunt (solo)</b><span>Daily #${dd}: spot the word that doesn't belong · 30 levels</span></button>
    <button class="lnk" id="h-how" type="button">How to play</button>`);
  on('h-pass', () => passSetup());
  on('h-room', () => roomHome());
  on('h-hunt', () => huntHome());
  on('h-how', () => howTo());
}

function howTo() {
  show(`<p class="out">How to play</p>
    <ol class="lead" style="text-align:left;font-weight:600;font-size:18px;padding-left:1.2em;max-width:34ch">
      <li>Everyone secretly sees the word. The <b>Fox</b> only sees the category.</li>
      <li>Go round the table: one clue each, out loud. Don't make it too easy!</li>
      <li>At 15 seconds a <b>Twist card</b> flips (mime it, one syllable, opposites…). Do a second lap with the twist.</li>
      <li>At zero, count 3-2-1 and point at the Fox.</li>
      <li>Caught? The Fox can still <b>steal</b> the round by picking the word from six.</li></ol>
    <p class="small muted2">Fox escapes: +2 · each finger on the Fox: +1 · Fox steals the word: +2</p>
    <button class="btn go" id="b-back" type="button">Got it</button>`);
  on('b-back', home);
}

// ------------------------------------------------------------------ pass-the-phone mode (offline)

let G = null; // the local game

function passSetup(keep) {
  let names = keep || store(LS_NAMES) || ['', '', ''];
  while (names.length < 3) names.push('');
  let rounds = store(LS_ROUNDS) || 5;
  const sel = packChoice();
  let useDaily = false;
  const draw = () => {
    show(`<p class="out">Who's playing?</p>
      <ul class="names" id="names">${names.map((n, i) => `<li><input class="inp" maxlength="16" aria-label="Player ${i + 1} name" placeholder="Player ${i + 1}" value="${esc(n)}" data-i="${i}">${names.length > 3 ? `<button class="x" type="button" data-del="${i}" aria-label="Remove player ${i + 1}">×</button>` : ''}</li>`).join('')}</ul>
      ${names.length < C.MAX_PLAYERS ? '<button class="btn ghost sm" id="add" type="button">+ Add player</button>' : ''}
      <div class="field"><label>Rounds</label>${roundsHtml(rounds)}</div>
      <div class="field"><label>Deck</label><div class="chips"><button type="button" class="chip" id="dly" aria-pressed="${useDaily}">📅 Today's deck #${dayNumber(today())} (same 5 words worldwide)</button></div></div>
      <div class="field"><label>Packs</label>${packsHtml(sel)}</div>
      <div class="field"><button class="btn ghost sm" id="ai" type="button">✨ Add a surprise AI category (optional)</button> <span class="small muted2" id="ai-note"></span></div>
      <button class="btn go" id="start" type="button">▶ Start (${names.length} players)</button>
      <button class="lnk" id="back" type="button">Back</button>`, 'top');
    stage.querySelectorAll('[data-i]').forEach((el) => el.addEventListener('input', () => { names[Number(el.dataset.i)] = el.value; }));
    stage.querySelectorAll('[data-del]').forEach((b) => b.addEventListener('click', () => { names.splice(Number(b.dataset.del), 1); draw(); }));
    on('add', () => { names.push(''); draw(); const l = stage.querySelectorAll('[data-i]'); l[l.length - 1].focus(); });
    bindRounds((n) => { rounds = n; });
    bindPacks(sel);
    on('dly', () => { useDaily = !useDaily; $('dly').setAttribute('aria-pressed', String(useDaily)); if (useDaily) rounds = 5; });
    on('ai', () => aiCategory());
    on('back', home);
    on('start', () => {
      const clean = names.map((n, i) => C.cleanName(n || `Player ${i + 1}`));
      const uniq = []; for (const n of clean) uniq.push(C.uniqueName(n, uniq));
      store(LS_NAMES, uniq);
      newPassGame(uniq, useDaily ? 5 : rounds, sel.slice(), useDaily);
    });
    if (aiExtra.length) $('ai-note').textContent = `Added: ${aiExtra.map((x) => x.cat).join(', ')}`;
  };
  draw();
}

let aiExtra = [];
async function aiCategory() {
  const note = $('ai-note');
  if (!navigator.onLine) { toast('Offline: the built-in decks work without internet.'); return; }
  const theme = prompt('A theme for the surprise category (or leave empty for a random one):', '') ?? null;
  if (theme === null) return;
  note.textContent = 'Asking the AI…';
  try {
    const r = await fetch(`${apiBase()}/api/ai?theme=${encodeURIComponent(theme.slice(0, 30))}`);
    const j = await r.json();
    if (!r.ok || !j.cat) throw new Error(j.error || 'No category');
    aiExtra = [...aiExtra.filter((x) => x.cat !== j.cat), { cat: j.cat, words: j.words }].slice(-3);
    note.textContent = `Added: ${aiExtra.map((x) => x.cat).join(', ')}`;
    count(GAME, 'cta', 'ai-category');
  } catch (e) { note.textContent = `${e.message || 'AI is busy'}. The built-in decks are ready.`; }
}

function localDeck(packs) {
  const d = deck(packs, isPro());
  return [...(d.length ? d : deck(['classic'])), ...aiExtra.map((x) => ({ ...x, group: 'ai', pack: 'ai', lang: 'en' }))];
}

function newPassGame(names, rounds, packs, useDaily) {
  G = { players: names.map((n, i) => ({ id: `s${i}`, name: n, score: 0 })), rounds, packs, useDaily, round: 0, history: [], used: new Set(), cur: null, seed: Math.random() };
  count(GAME, 'play', useDaily ? 'pass-daily' : 'pass');
  passDeal();
}

function passDeal() {
  const ids = G.players.map((p) => p.id);
  const r = C.rng(`${G.seed}-${G.round}-${Date.now()}`);
  const d = localDeck(G.packs);
  let cur = C.deal(d, ids, r, { used: G.used, round: G.round });
  if (G.useDaily) {
    const dr = C.dailyRounds(deck(['classic']), today())[G.round % 5];
    const cat = deck(['classic']).find((c) => c.cat === dr.cat);
    const others = C.shuffle(cat.words.filter((w) => w !== dr.word), r).slice(0, C.CHOICES - 1);
    cur = { ...cur, cat: dr.cat, word: dr.word, twist: dr.twist, choices: C.shuffle([dr.word, ...others], r) };
  }
  // one shared phone can't show the Fox a private letter mid-round, so the peek twist is swapped for another
  if (cur.twist === 'peek') cur.twist = C.TWISTS.filter((t) => t.id !== 'peek')[Math.floor(r() * (C.TWISTS.length - 1))].id;
  G.cur = cur;
  G.used.add(`${cur.cat}|${cur.word}`);
  passTo(0);
}

const nameOf = (id) => (G.players.find((p) => p.id === id) || {}).name || '?';

function passTo(i) {
  const p = G.players[i];
  show(`<p class="small muted2">Round ${G.round + 1} of ${G.rounds} · card ${i + 1} of ${G.players.length}</p>
    <p class="out">Pass the phone to</p><p class="code-big" style="letter-spacing:0;font-size:clamp(40px,12vw,64px)">${esc(p.name)}</p>
    <p class="lead">Only ${esc(p.name)} looks!</p>
    <button class="btn go" id="see" type="button">👀 I'm ${esc(p.name)}: show my card</button>`);
  on('see', () => cardFor(i));
}

function cardFor(i) {
  const p = G.players[i];
  const c = C.cardFor(G.cur, p.id);
  const last = i === G.players.length - 1;
  show(`${c.fox ? `<div class="card foxcard flip"><p class="k">You are</p><p class="w">THE FOX 🦊</p><p class="c">Category: ${esc(c.cat)}</p><p class="c small">You don't know the word. Listen, blend in, give a vague clue.</p></div>`
    : `<div class="card flip"><p class="k">${esc(c.cat)}</p><p class="w">${esc(c.word)}</p><p class="c small">Give a clue that proves you know it, without giving it away to the Fox.</p></div>`}
    <button class="btn go" id="hide" type="button">${last ? '🙈 Hide & start the clues' : `🙈 Hide & pass to ${esc(G.players[i + 1].name)}`}</button>`);
  if (c.fox) buzz([30, 30, 30]); else buzz(20);
  on('hide', () => (last ? passClues() : passTo(i + 1)));
}

function passClues() {
  const ends = Date.now() + C.CLUE_MS;
  show(`<p class="small muted2">Round ${G.round + 1} of ${G.rounds}</p><p class="cat">${esc(G.cur.cat)}</p>
    <p class="lead">🎤 ${esc(nameOf(G.cur.starter))} starts. One clue each, go round!</p>${timerHtml()}${twistHtml(G.cur.twist, true)}
    <button class="btn ghost sm" id="skip" type="button">Everyone's done: vote now</button>`);
  snd.win();
  runTimer(ends, C.CLUE_MS, { twistAt: ends - C.TWIST_AT, onTwist: (q) => flipTwist(G.cur.twist, q), onEnd: () => { snd.hit(); buzz(200); passVote(); } });
  on('skip', passVote);
}

function passVote() {
  show(`<p class="out">Where's the Fox?</p><p class="lead">Count 3-2-1 and everyone points at once!</p>
    <button class="btn go" id="count" type="button">3 · 2 · 1</button>
    <div id="pick" hidden><p class="lead">Who got the most fingers?</p><div class="grid">${G.players.map((p) => `<button class="pick" type="button" data-acc="${p.id}">${esc(p.name)}</button>`).join('')}<button class="pick" type="button" data-acc="">🤷 A tie / split</button></div></div>`);
  on('count', () => {
    const b = $('count');
    let n = 3;
    b.disabled = true;
    const t = setInterval(() => {
      snd.tone(440 + (3 - n) * 120, 0.12, 'square', 0.05, 1);
      b.textContent = n > 0 ? String(n) : 'POINT! 👉';
      if (n-- <= 0) { clearInterval(t); buzz(120); $('pick').hidden = false; }
    }, 550);
  });
  stage.querySelectorAll('[data-acc]').forEach((b) => b.addEventListener('click', () => {
    snd.tap();
    const acc = b.dataset.acc;
    G.votes = {};
    if (acc) for (const p of G.players) if (p.id !== acc) G.votes[p.id] = acc;
    if (acc && acc === G.cur.fox) passGuess(); else passResult(null);
  }));
}

function passGuess() {
  show(`${fox('sm')}<p class="out">${esc(nameOf(G.cur.fox))} is the Fox!</p><p class="lead">Fox, one last chance: steal the round by picking the word.</p>
    <div class="grid">${G.cur.choices.map((w) => `<button class="pick" type="button" data-w="${esc(w)}">${esc(w)}</button>`).join('')}</div>`);
  burstAt(stage.querySelector('.fox'), 0.5, 0.5);
  snd.good(8);
  stage.querySelectorAll('[data-w]').forEach((b) => b.addEventListener('click', () => {
    const ok = C.norm(b.dataset.w) === C.norm(G.cur.word);
    b.classList.add(ok ? 'good' : 'bad');
    if (!ok) stage.querySelector(`[data-w="${CSS.escape(G.cur.word)}"]`)?.classList.add('good');
    setTimeout(() => passResult(b.dataset.w), 700);
  }));
}

function passResult(guess) {
  const s = C.score({ fox: G.cur.fox, word: G.cur.word, votes: G.votes || {}, guess });
  for (const [id, n] of Object.entries(s.deltas)) G.players.find((p) => p.id === id).score += n;
  G.history.push({ cat: G.cur.cat, word: G.cur.word, fox: G.cur.fox, foxName: nameOf(G.cur.fox), outcome: s.outcome });
  resultScreen({ outcome: s.outcome, word: G.cur.word, foxName: nameOf(G.cur.fox), accusedName: s.accused ? nameOf(s.accused) : null, deltas: s.deltas, players: G.players, round: G.round, rounds: G.rounds },
    { next: () => { G.round++; if (G.round >= G.rounds) passFinal(); else passDeal(); } });
}

function resultScreen(r, { next, host = true }) {
  const last = r.round + 1 >= r.rounds;
  const sub = r.outcome === 'escaped' ? (r.accusedName ? `You pointed at ${esc(r.accusedName)}. Wrong!` : 'The table split, and the Fox slipped away.')
    : r.outcome === 'stole' ? 'Caught… but the Fox guessed the word!' : r.outcome === 'void' ? 'The Fox left the room. Nobody scores.' : 'Every finger on the Fox scores a point.';
  show(`<p class="emoji">${C.OUTCOME_EMOJI[r.outcome]}</p><p class="out">${esc(C.OUTCOME_TEXT[r.outcome])}</p><p class="lead">${sub}</p>
    <div class="card"><p class="k">The word was</p><p class="w">${esc(r.word)}</p><p class="c">🦊 The Fox: ${esc(r.foxName)}</p></div>
    <ul class="board">${[...r.players].sort((a, b) => b.score - a.score).map((p) => `<li>${esc(p.name)}${r.deltas[p.id] ? ` <span class="d">+${r.deltas[p.id]}</span>` : ''}<b>${p.score}</b></li>`).join('')}</ul>
    ${host ? `<button class="btn go" id="next" type="button">${last ? '🏆 Final scores' : `▶ Round ${r.round + 2}`}</button>` : '<p class="small muted2">Next round starts in a moment…</p>'}`);
  if (r.outcome === 'caught' || r.outcome === 'stole') { snd.win(); burstAt(stage.querySelector('.out'), 0.5, 0.5); } else if (r.outcome === 'escaped') { snd.lose(); shake(stage.querySelector('.out')); }
  if (host) on('next', next);
}

function passFinal() {
  const scores = Object.fromEntries(G.players.map((p) => [p.id, p.score]));
  finalScreen({ players: G.players, history: G.history, winners: C.winners(scores), daily: G.useDaily },
    { rematch: () => { G.players.forEach((p) => { p.score = 0; }); newPassGame(G.players.map((p) => p.name), G.rounds, G.packs, G.useDaily); },
      edit: () => passSetup(G.players.map((p) => p.name)) });
}

function finalScreen({ players, history, winners, daily: dly }, { rematch, edit, host = true, waitingNote = '' }) {
  const names = winners.map((id) => players.find((p) => p.id === id)?.name).filter(Boolean);
  const line = C.emojiLine(history);
  show(`${fox()}<p class="out">${names.length ? `🏆 ${esc(names.join(' & '))} ${names.length > 1 ? 'win' : 'wins'}!` : 'Nobody scored. Sneaky foxes!'}</p>
    <p class="emoji" aria-label="Round results">${line}</p>
    <ul class="board">${[...players].sort((a, b) => b.score - a.score).map((p) => `<li class="${winners.includes(p.id) ? 'win' : ''}">${winners.includes(p.id) ? '👑 ' : ''}${esc(p.name)}<b>${p.score}</b></li>`).join('')}</ul>
    ${host ? '<button class="btn go" id="again" type="button">↻ Rematch</button>' : `<p class="lead">${esc(waitingNote || 'Waiting for the host to start a rematch…')}</p>`}
    <div class="row" style="justify-content:center"><button class="btn sm" id="share" type="button">📤 Share result</button><button class="btn ghost sm" id="card" type="button">🖼 Save card</button>${edit ? '<button class="btn ghost sm" id="edit" type="button">👥 Players</button>' : ''}<button class="btn ghost sm" id="menu" type="button">Menu</button></div>
    <div id="fp"></div>`);
  snd.win(); [0, 1, 2].forEach((i) => setTimeout(() => burstAt(stage.querySelector('.out'), 0.2 + i * 0.3, 0.5), i * 180));
  const text = `Hushfox 🦊 ${history.length} rounds${dly ? ` · Daily deck #${dayNumber(today())}` : ''}\n${line}\n${names.length ? `Winner: ${names.join(' & ')}` : 'The Fox won the night'}`;
  if (host) on('again', () => { count(GAME, 'play', 'rematch'); rematch(); });
  on('share', async () => { const b = await canvasBlob(partyCard(players, history, names)); share(GAME, text, PATH, b, 'hushfox.png'); });
  on('card', async () => { download(await canvasBlob(partyCard(players, history, names)), 'hushfox.png'); count(GAME, 'share', 'card'); });
  if (edit) on('edit', edit);
  on('menu', () => { leaveRoom(); home(); });
  count(GAME, 'win', 'party');
  pass.afterWin($('fp'), 'party');
  installer.offer();
}

function drawFox(x, cx, cy, s) {
  x.save(); x.translate(cx - 256 * s, cy - 256 * s); x.scale(s, s);
  const P = (d, f) => { x.fillStyle = f; x.fill(new Path2D(d)); };
  P('M106 132 196 226h120l90-94-26 168-124 112-124-112Z', '#FF7A2F'); P('M130 178 182 230l-30 30ZM382 178l-52 52 30 30Z', '#FFD9B8');
  P('M132 300 256 412V330l-66-40ZM380 300 256 412V330l66-40Z', '#FFF3E6'); x.beginPath(); x.arc(256, 396, 19, 0, 7); x.fillStyle = '#1C1530'; x.fill();
  x.strokeStyle = '#1C1530'; x.lineWidth = 15; x.lineCap = 'round'; x.stroke(new Path2D('M178 284q24 18 48 0M286 284q24 18 48 0'));
  x.restore();
}
function partyCard(players, history, names) {
  const top = [...players].sort((a, b) => b.score - a.score).slice(0, 4);
  return scoreCard({ title: 'Hushfox', sub: `${history.length} rounds · ${players.length} players`, big: C.emojiLine(history) || '🦊', unit: names.length ? `🏆 ${names.join(' & ')}` : 'The Fox won the night',
    line: 'Can your group catch the Fox?', bg: '#1C1530', fg: '#FFF3E6', accent: '#FF7A2F', foot: 'cybermaxtools.com/games/hushfox' }, (x) => {
    drawFox(x, 540, 470, 1.05);
    x.font = '700 40px system-ui, sans-serif'; x.fillStyle = '#FFF3E6'; x.textAlign = 'center';
    x.fillText(top.map((p) => `${p.name} ${p.score}`).join('  ·  '), 540, 790);
  });
}

// ------------------------------------------------------------------ room-code mode

let R = null; // { code, conn, s, me, error }

function roomHome(prefill = '') {
  if (!navigator.onLine) {
    show(`${fox('sm')}<p class="out">You're offline</p><p class="lead">Room codes need internet. Pass-the-phone works offline!</p><button class="btn go" id="pp" type="button">📱 Pass one phone</button><button class="lnk" id="back" type="button">Back</button>`);
    on('pp', () => passSetup()); on('back', home); return;
  }
  const me = store(LS_ME) || '';
  show(`<p class="out">Room code</p><p class="lead">Each player joins on their own phone. Same room, real voices.</p>
    <div class="field"><label for="me">Your name</label><input class="inp" id="me" maxlength="16" placeholder="Your name" value="${esc(me)}" autocomplete="nickname"></div>
    <button class="btn go" id="create" type="button">➕ Create a room</button>
    <p class="small muted2">or join one</p>
    <div class="field"><input class="inp code" id="code" maxlength="4" placeholder="CODE" value="${esc(prefill)}" aria-label="Room code" autocapitalize="characters" autocomplete="off"></div>
    <button class="btn ghost" id="join" type="button" style="width:min(420px,100%)">Join room</button>
    <p class="small" id="err" role="alert"></p><button class="lnk" id="back" type="button">Back</button>`, 'top');
  const nm = () => { const v = C.cleanName($('me').value); store(LS_ME, v); return v; };
  on('create', async () => {
    const b = $('create'); b.disabled = true;
    try { const code = await createRoom(); count(GAME, 'play', 'room-create'); joinRoom(code, nm()); }
    catch { $('err').textContent = 'Could not create a room right now. Try again, or pass one phone.'; b.disabled = false; }
  });
  on('join', async () => {
    const code = $('code').value.trim().toUpperCase();
    if (!C.validCode(code)) { $('err').textContent = 'Codes are 4 letters, like KQBX.'; shake($('code')); return; }
    try {
      const i = await roomInfo(code);
      if (!i.open) { $('err').textContent = 'No room with that code. Check the letters.'; shake($('code')); return; }
      if (i.players >= C.MAX_PLAYERS) { $('err').textContent = 'That room is full (12 players).'; return; }
    } catch { $('err').textContent = 'Could not reach the room. Check your connection.'; return; }
    count(GAME, 'play', 'room-join');
    joinRoom(code, nm());
  });
  on('back', home);
  if (prefill) $('me').focus();
}

function joinRoom(code, name) {
  leaveRoom();
  R = { code, me: pid(), s: null, peek: false, voted: null, last: '' };
  history.replaceState(null, '', `${location.pathname}?room=${code}${location.search.includes('api=') ? `&api=${encodeURIComponent(new URLSearchParams(location.search).get('api'))}` : ''}`);
  show(`<p class="code-big">${esc(code)}</p><p class="lead">Joining…</p>`);
  R.conn = connect(code, R.me, name, {
    state: (s) => { if (!R) return; R.s = s; renderRoom(); },
    error: (e) => { toast(e, 3000); },
    status: (st) => {
      if (!R) return;
      if (st === 'gone') { const c = R.code; leaveRoom(); toast('You left the room.'); roomHome(c); return; }
      let n = stage.querySelector('.net');
      if (st === 'retry') { if (!n) { n = document.createElement('div'); n.className = 'net'; stage.appendChild(n); } n.textContent = 'Reconnecting…'; }
      else if (n) n.remove();
    },
  });
}
function leaveRoom() {
  if (!R) return;
  try { R.conn.send({ t: 'leave' }); } catch { /* */ }
  setTimeout(((c) => () => c.close())(R.conn), 150);
  R = null;
  if (location.search.includes('room=')) history.replaceState(null, '', location.pathname);
}
const rsend = (o) => R && R.conn.send(o);
const rname = (id) => (R.s.players.find((p) => p.id === id) || {}).name || '?';

function renderRoom() {
  const s = R.s, me = s.players.find((p) => p.id === R.me), host = s.host === R.me;
  const key = `${s.phase}|${s.round}|${s.game}`;
  const fresh = key !== R.last;
  if (fresh) { R.peek = false; R.voted = null; }
  R.last = key;
  const local = (t) => t - (s.offset || 0); // server time -> this phone's clock
  if (me && me.waiting && ['reveal', 'clue', 'vote', 'guess', 'result'].includes(s.phase)) {
    show(`<p class="code-big">${esc(s.code)}</p><p class="out">You're in!</p><p class="lead">A round is on. You join from the next round.</p><p class="small muted2">${s.players.filter((p) => p.on).length} players in the room</p><button class="lnk" id="leave" type="button">Leave room</button>`);
    on('leave', () => { leaveRoom(); home(); });
    return;
  }
  if (s.phase === 'lobby') return roomLobby(s, host);
  if (s.phase === 'reveal' || s.phase === 'clue') {
    if (!fresh && s.phase === 'clue' && $('card')) {
      // same clue phase: only the twist/peek letter may have changed
      if (s.cur.twist && $('twist') && $('twist').classList.contains('wait')) flipTwist(s.cur.twist, false);
      if (s.card && s.card.letter && !stage.querySelector('.letter')) $('card').insertAdjacentHTML('beforeend', `<p class="c letter">👀 First letter: <b>${esc(s.card.letter)}</b></p>`);
      return;
    }
    const c = s.card;
    show(`<p class="small muted2">Room ${esc(s.code)} · round ${s.round + 1} of ${s.rounds}</p>
      ${c ? `<button type="button" id="card" class="card ${c.fox ? 'foxcard' : ''} hidden" aria-label="Tap to peek at your card"><p class="k">${c.fox ? 'You are' : esc(c.cat)}</p><p class="w">${c.fox ? 'THE FOX 🦊' : esc(c.word)}</p><p class="c">${c.fox ? `Category: ${esc(c.cat)}` : 'Tap to hide'}</p>${c.letter ? `<p class="c letter">👀 First letter: <b>${esc(c.letter)}</b></p>` : ''}</button><p class="small muted2">Tap the card to peek. Keep it hidden!</p>` : ''}
      ${s.phase === 'reveal' ? `<p class="lead">Look at your card… clues start in a moment.</p>${timerHtml()}` : `<p class="cat">${esc(s.cur.cat)}</p><p class="lead">🎤 ${esc(rname(s.cur.starter))} starts. One clue each!</p>${timerHtml()}${twistHtml(s.cur.twist, !s.cur.twist)}`}
      ${host ? '<button class="btn ghost sm" id="skip" type="button">Skip ▶</button>' : ''}`);
    const card = $('card');
    if (card) card.addEventListener('click', () => { R.peek = !R.peek; card.classList.toggle('hidden', !R.peek); snd.tap(); buzz(15); });
    if (fresh && s.phase === 'reveal') { snd.boss(); buzz(c && c.fox ? [30, 30, 30] : 30); }
    if (fresh && s.phase === 'clue') snd.win();
    const total = s.phase === 'reveal' ? C.REVEAL_MS : C.CLUE_MS;
    runTimer(local(s.ends), total, s.phase === 'clue' ? { twistAt: local(s.twistAt), onTwist: () => {} } : {});
    if (host) on('skip', () => rsend({ t: 'next' }));
    return;
  }
  if (s.phase === 'vote') {
    const voted = me && s.players.find((p) => p.id === R.me)?.voted;
    const left = s.players.filter((p) => p.on && !p.waiting && !p.voted).length;
    show(`<p class="out">Who's the Fox?</p><p class="cat" style="font-size:22px">${esc(s.cur.cat)}</p>${timerHtml()}
      ${voted ? `<p class="lead">✓ Vote in. Waiting for ${left} more…</p>` : '<p class="lead">Tap the player you think is the Fox.</p>'}
      <div class="grid">${s.players.filter((p) => p.on && !p.waiting && p.id !== R.me).map((p) => `<button class="pick ${R.voted === p.id ? 'on' : ''}" type="button" data-v="${p.id}" ${voted ? 'disabled' : ''}>${esc(p.name)}${p.voted ? '<small>voted</small>' : ''}</button>`).join('')}</div>`);
    if (fresh) { snd.hit(); buzz(150); }
    runTimer(local(s.ends), C.VOTE_MS);
    stage.querySelectorAll('[data-v]').forEach((b) => b.addEventListener('click', () => { R.voted = b.dataset.v; snd.good(3); rsend({ t: 'vote', target: b.dataset.v }); b.classList.add('on'); }));
    return;
  }
  if (s.phase === 'guess') {
    const isFox = s.cur.accused === R.me;
    show(`${fox('sm')}<p class="out">${isFox ? 'You were caught!' : `${esc(rname(s.cur.accused))} is the Fox!`}</p>
      ${isFox ? `<p class="lead">Steal the round: pick the word.</p><div class="grid">${(s.cur.choices || []).map((w) => `<button class="pick" type="button" data-w="${esc(w)}">${esc(w)}</button>`).join('')}</div>` : '<p class="lead">The Fox is trying to steal the word…</p>'}${timerHtml()}`);
    if (fresh) { snd.good(8); burstAt(stage.querySelector('.fox'), 0.5, 0.5); }
    runTimer(local(s.ends), C.GUESS_MS);
    stage.querySelectorAll('[data-w]').forEach((b) => b.addEventListener('click', () => { b.classList.add('on'); stage.querySelectorAll('[data-w]').forEach((x) => { x.disabled = true; }); rsend({ t: 'guess', word: b.dataset.w }); }));
    return;
  }
  if (s.phase === 'result') {
    if (!fresh) return;
    const c = s.cur;
    resultScreen({ outcome: c.outcome, word: c.word, foxName: rname(c.fox), accusedName: c.accused ? rname(c.accused) : null, deltas: c.deltas || {}, players: s.players.filter((p) => p.on), round: s.round, rounds: s.rounds },
      { next: () => rsend({ t: 'next' }), host });
    return;
  }
  if (s.phase === 'final') {
    if (!fresh && stage.querySelector('#share')) return;
    const scores = Object.fromEntries(s.players.map((p) => [p.id, p.score]));
    finalScreen({ players: s.players, history: s.history, winners: C.winners(scores) }, { rematch: () => rsend({ t: 'rematch' }), host });
  }
}

function roomLobby(s, host) {
  const link = `${SITE}${PATH}?room=${s.code}`;
  const active = s.players.filter((p) => p.on);
  show(`<p class="small muted2">Room code</p><p class="code-big" id="rcode">${esc(s.code)}</p>
    <div class="row" style="justify-content:center"><button class="btn sm" id="invite" type="button">📨 Invite link</button><button class="btn ghost sm" id="copy" type="button">Copy code</button></div>
    ${s.note ? `<p class="banner">${esc(s.note)}</p>` : ''}
    <ul class="board" id="plist">${active.map((p) => `<li>${p.id === s.host ? '👑 ' : ''}${esc(p.name)}${p.id === R.me ? ' (you)' : ''}${host && p.id !== R.me ? `<button class="x" type="button" data-kick="${p.id}" aria-label="Remove ${esc(p.name)}" style="margin-left:auto">×</button>` : ''}</li>`).join('')}</ul>
    <p class="small muted2">${active.length}/${C.MAX_PLAYERS} players · need ${C.MIN_PLAYERS}+</p>
    ${host ? `<div class="field"><label>Rounds</label>${roundsHtml(s.rounds)}</div><div class="field"><label>Packs</label>${packsHtml(s.packs)}</div>
      <button class="btn go" id="start" type="button" ${active.length < C.MIN_PLAYERS ? 'disabled' : ''}>▶ Start game</button>`
    : `<p class="lead">Waiting for ${esc(rname(s.host))} to start…</p><p class="small muted2">Packs: ${s.packs.map((id) => esc(packById(id)?.name || id)).join(', ')} · ${s.rounds} rounds</p>`}
    <button class="lnk" id="leave" type="button">Leave room</button>`, 'top');
  on('invite', () => { share(GAME, `Join my Hushfox party game! Room ${s.code}`, `${PATH}?room=${s.code}`); });
  on('copy', async () => { try { await navigator.clipboard.writeText(s.code); toast('Code copied'); } catch { prompt('Room code:', s.code); } });
  on('leave', () => { leaveRoom(); home(); });
  stage.querySelectorAll('[data-kick]').forEach((b) => b.addEventListener('click', () => rsend({ t: 'kick', target: b.dataset.kick })));
  if (host) {
    const passCode = () => (store('cmx-pro:fun-pass') || {}).code || '';
    bindRounds((n) => rsend({ t: 'settings', rounds: n }));
    const sel = s.packs.slice();
    bindPacks(sel, (p) => rsend({ t: 'settings', packs: p, pass: passCode() }));
    on('start', () => { rsend({ t: 'settings', packs: sel, pass: passCode(), extra: aiExtra }); setTimeout(() => rsend({ t: 'start' }), 120); });
  }
  void link;
}

// ------------------------------------------------------------------ solo: Fox Hunt (daily + levels)

const HUNT_DECK = deck(['classic']);
const WORLDS = ['Meadow', 'Den', 'Moonlit woods'];
const LEVELS = Array.from({ length: 30 }, (_, i) => {
  const n = i + 1, sp = C.levelSpec(n);
  const twist = n === 7 ? '5 words' : n === 9 ? 'Sneaky foxes from a neighbouring category' : n === 13 ? '6 words' : n === 19 ? '7 words' : n === 25 ? '8 words' : undefined;
  return { n, name: `${sp.size} words${sp.hard ? ', sneaky' : ''}`, twist, boss: n % 10 === 0 };
});
const prog = progress(GAME, { isPro });
const dly = daily(GAME);

function huntHome() {
  lvCss();
  const done = dly.best() !== undefined;
  const el = show(`<p class="out">🔎 Fox Hunt</p><p class="lead">One word in each set doesn't belong: it's the Fox. Find 5 foxes in 30 seconds.</p>
    <button class="big hot" id="hd" type="button"><b>📅 Daily #${dly.num}</b><span>${done ? `Best today: ${dly.best()} · streak ${dly.streak()}` : 'Same puzzles for everyone today'}</span></button>
    <button class="big" id="hl" type="button"><b>▶ Level ${prog.reached()}</b><span>${prog.cleared()}/30 levels · ${prog.totalStars()}/90 ⭐</span></button>
    <section class="lvmap" aria-label="Levels"><div id="lvmap"></div></section>
    <button class="lnk" id="back" type="button">Back</button>`, 'top');
  on('hd', () => hunt({ daily: true }));
  on('hl', () => hunt({ level: prog.reached() }));
  on('back', home);
  renderMap(el.querySelector('#lvmap'), prog, LEVELS, { worlds: WORLDS, onPick: (n) => hunt({ level: n }), onLocked: (n, why) => (why === 'pass' ? pass.open(`level-${n}`) : toast('Clear the level before first.')) });
}

let H = null;
function hunt({ daily: isDaily = false, level = 0, vs = null } = {}) {
  const spec = isDaily ? { size: 6, hard: true, puzzles: 5, ms: C.CLUE_MS } : C.levelSpec(level);
  if (level) { const lock = prog.lock(level, { challenge: !!vs }); if (lock === 'pass') { pass.open(`level-${level}`); return huntHome(); } prog.open(level); }
  const set = C.huntSet(HUNT_DECK, isDaily ? `hunt-${dly.date}` : `hunt-L${level}`, spec);
  H = { isDaily, level, spec, set, i: 0, marks: [], ends: Date.now() + spec.ms, vs, over: false };
  count(GAME, 'play', isDaily ? 'hunt-daily' : `hunt-l${level}`);
  huntPuzzle();
  const step = () => {
    const left = Math.max(0, H.ends - Date.now());
    const bar = stage.querySelector('.hunt-bar i');
    if (bar) bar.style.width = `${(left / spec.ms) * 100}%`;
    if (left <= 0 && !H.over) huntEnd();
  };
  clearInterval(huntTick);
  huntTick = setInterval(step, 150);
}
let huntTick = 0;
function huntPuzzle() {
  const p = H.set[H.i];
  stage.innerHTML = `<div class="scr top"><p class="small muted2">${H.isDaily ? `Daily #${dly.num}` : `Level ${H.level}`} · fox ${H.i + 1} of ${H.spec.puzzles}</p>
    <div class="hunt-bar"><i style="width:100%"></i></div><div class="dots">${H.set.map((_, k) => `<i class="${H.marks[k] === true ? 'ok' : H.marks[k] === false ? 'no' : k === H.i ? 'now' : ''}"></i>`).join('')}</div>
    <p class="lead">Which word is the Fox?</p><div class="grid">${p.words.map((w) => `<button class="pick" type="button" data-h="${esc(w)}">${esc(w)}</button>`).join('')}</div></div>`;
  stage.querySelectorAll('[data-h]').forEach((b) => b.addEventListener('click', () => {
    if (H.over || H.lock) return;
    H.lock = true;
    const ok = b.dataset.h === p.fox;
    H.marks[H.i] = ok;
    b.classList.add(ok ? 'good' : 'bad');
    if (ok) { snd.good(H.i * 3); burstAt(b, 0.5, 0.5, { n: 18 }); } else { snd.lose(); shake(b); buzz(80); stage.querySelector(`[data-h="${CSS.escape(p.fox)}"]`)?.classList.add('good'); }
    const note = document.createElement('p'); note.className = 'small muted2'; note.textContent = `${p.fox} is from "${p.foxCat}"; the rest are ${p.cat}.`;
    stage.firstElementChild.appendChild(note);
    setTimeout(() => { H.lock = false; H.i++; if (H.i >= H.spec.puzzles) huntEnd(); else if (!H.over) huntPuzzle(); }, ok ? 650 : 1300);
  }));
}
function huntEnd() {
  if (H.over) return;
  H.over = true;
  clearInterval(huntTick);
  const correct = H.marks.filter(Boolean).length;
  const msLeft = Math.max(0, H.ends - Date.now());
  const score = C.huntScore(correct, msLeft, H.spec.puzzles);
  let head = '', stars = 0, rec = null;
  if (H.isDaily) { rec = dly.record(score); head = `Daily #${dly.num}`; } else { stars = starsFor(score, C.LEVEL_STARS); rec = prog.record(H.level, stars, score); head = `Level ${H.level}`; }
  updateUnder();
  const grid = H.set.map((_, k) => (H.marks[k] ? '🦊' : H.marks[k] === false ? '❌' : '⬜')).join('');
  const vs = H.vs !== null && H.vs !== undefined ? (score > H.vs ? `You beat your friend's ${H.vs}! 🎉` : score === H.vs ? `A tie with your friend's ${H.vs}.` : `Your friend's ${H.vs} still stands. Again?`) : '';
  const canNext = !H.isDaily && stars > 0 && H.level < 30;
  show(`<p class="out">${head}: ${correct}/5 foxes</p>${H.isDaily ? '' : `<div class="lv-stars">${starsHtml(stars)}</div>`}
    <p class="code-big" style="font-size:clamp(46px,14vw,72px);letter-spacing:0">${score}</p><p class="emoji">${grid}</p>
    ${vs ? `<p class="banner">${esc(vs)}</p>` : ''}${H.isDaily ? `<p class="small muted2">🔥 streak ${rec.streak} · best today ${rec.best}</p>` : ''}
    <div class="row" style="justify-content:center">${canNext ? '<button class="btn" id="nx" type="button">Next level ▶</button>' : ''}<button class="btn ${canNext ? 'ghost' : ''}" id="re" type="button">↻ Again</button></div>
    <div class="row" style="justify-content:center"><button class="btn sm" id="beat" type="button">🎯 Challenge a friend</button><button class="btn ghost sm" id="hcard" type="button">🖼 Card</button><button class="btn ghost sm" id="hm" type="button">Fox Hunt</button></div><div id="fp"></div>`);
  if (correct >= 4) snd.win(); else snd.lose();
  if (stars) [0, 1, 2].slice(0, stars).forEach((i) => setTimeout(() => snd.star(i), 200 + i * 140));
  const text = `Hushfox Fox Hunt ${H.isDaily ? `#${dly.num}` : `level ${H.level}`}: ${score}\n${grid}`;
  const link = challengeLink(PATH, GAME, H.isDaily ? { date: dly.date, score } : { level: H.level, score });
  const card = () => scoreCard({ title: 'Hushfox', sub: H.isDaily ? `Fox Hunt daily #${dly.num}` : `Fox Hunt level ${H.level}`, big: score, unit: grid, stars: H.isDaily ? undefined : stars, bg: '#1C1530', fg: '#FFF3E6', accent: '#FF7A2F', line: 'Can you spot the Fox faster?', foot: 'cybermaxtools.com/games/hushfox' }, (x) => drawFox(x, 540, 520, 1.1));
  const again = { daily: H.isDaily, level: H.level };
  on('re', () => hunt(again));
  if (canNext) on('nx', () => hunt({ level: H.level + 1 }));
  on('beat', async () => { const b = await canvasBlob(card()); const r = await shareChallenge(`${text}\nBeat me:`, link, b, 'hushfox-hunt.png'); if (r !== 'cancelled') count(GAME, 'share', 'challenge'); });
  on('hcard', async () => { download(await canvasBlob(card()), 'hushfox-hunt.png'); count(GAME, 'share', 'card'); });
  on('hm', huntHome);
  count(GAME, 'win', H.isDaily ? 'hunt-daily' : 'hunt-level');
  if (correct >= 3) pass.afterWin($('fp'), 'hunt');
  installer.offer();
}

function updateUnder() {
  const b = dly.best();
  $('best').textContent = b === undefined ? '-' : b;
  $('streak').textContent = dly.streak();
}

// ------------------------------------------------------------------ boot

const installer = pwa(GAME, $('install'));
pass.init({ game: GAME, name: 'Hushfox', perks: ['8 extra packs: Grown-ups night, Movie night, Office hours, Holidays, Español, Português, Deutsch, Français', 'Fox Hunt levels 21-30'],
  extra: 'The Fun Pass adds 8 party packs (movies, office, holidays, grown-ups night and Spanish, Portuguese, German, French decks) and Fox Hunt levels 21-30, in every CyberMax game.' });
pass.onChange(() => { /* packs re-render on the next screen */ });
on('pass-btn', () => pass.open('button'));
themeToggle($('theme'));
const mb = $('mute');
if (mb) { mb.textContent = muted ? '🔇' : '🔊'; mb.addEventListener('click', () => { muted = !muted; store(`${GAME}:muted`, muted); mb.textContent = muted ? '🔇' : '🔊'; }); }
$('num').textContent = `#${dayNumber(today())}`;
updateUnder();
window.addEventListener('beforeunload', () => { if (R) try { R.conn.send({ t: 'leave' }); } catch { /* */ } });

(function route() {
  const q = new URLSearchParams(location.search);
  const room = (q.get('room') || '').toUpperCase();
  const ch = readChallenge();
  if (room && C.validCode(room)) {
    roomHome(room);
    return;
  }
  if (ch) {
    if (ch.level) return hunt({ level: Math.min(30, ch.level), vs: ch.score });
    if (ch.date === dly.date) return hunt({ daily: true, vs: ch.score });
  }
  home();
})();

// test + studio hooks (no effect for players)
window.__cmxTest = { C, G: () => G, R: () => R, H: () => H, prog, dly, home, passSetup, huntHome, deck };
window.__cmxShot = () => { newPassGame(['Ana', 'Ben', 'Cy', 'Di'], 5, ['classic'], false); passClues(); flipTwist(G.cur.twist, true); muted = true; };
window.__cmxDemo = async () => { // a real round on the real screens, sped up for the demo video (~11 s)
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
  newPassGame(['Ana', 'Ben', 'Cy', 'Di'], 3, ['classic'], false);
  await wait(900); $('see').click(); await wait(1500);
  const fi = G.players.findIndex((p) => p.id === G.cur.fox);
  if (fi !== 0) { cardFor(fi); await wait(1500); }
  passClues(); await wait(1300); flipTwist(G.cur.twist, false); await wait(1900);
  passVote(); await wait(300); $('count').click(); await wait(2300);
  stage.querySelector(`[data-acc="${G.cur.fox}"]`).click(); await wait(1400);
  stage.querySelector(`[data-w="${CSS.escape(G.cur.word)}"]`).click(); await wait(1600);
};