File size: 19,479 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
// Hushfox core rules: pure logic shared by the browser (pass-the-phone + solo) and the room Worker (Durable Object).
// No DOM, no network: everything here runs in node tests too. (c) 2026 CyberMax. All rights reserved.
//
// The game, in one breath: everyone sees the secret word except the Fox, who only sees the category. Each player
// says one clue out loud. At the 15-second mark a Twist card flips and changes how the second lap of clues works.
// After 30 seconds everyone votes for the Fox. A caught Fox can still steal the round by picking the word from six.

export const MIN_PLAYERS = 3;
export const MAX_PLAYERS = 12;
export const CLUE_MS = 30000;
export const TWIST_AT = 15000; // the Twist flips when this much of the clue time is left
export const REVEAL_MS = 6000;
export const VOTE_MS = 20000;
export const GUESS_MS = 15000;
export const RESULT_MS = 8000;
export const CHOICES = 6;
export const ROUND_OPTIONS = [3, 5, 8];

export const TWISTS = [
  { id: 'sound', icon: '🔊', text: 'Sound lap: make a noise instead of a word' },
  { id: 'mime', icon: '🤫', text: 'Mime lap: act your clue, no talking' },
  { id: 'short', icon: '✂️', text: 'One-syllable clues only' },
  { id: 'point', icon: '👉', text: 'Point at something in the room that fits' },
  { id: 'color', icon: '🎨', text: 'Say a colour that fits the word' },
  { id: 'animal', icon: '🐾', text: 'Say an animal that fits the word' },
  { id: 'whisper', icon: '🗣️', text: 'Whisper lap: each clue quieter than the last' },
  { id: 'reverse', icon: '🔄', text: 'Reverse: go round the other way' },
  { id: 'peek', icon: '👀', text: 'Fox peek: the Fox now sees the first letter' },
  { id: 'number', icon: '🔢', text: 'Rate the word from 1 to 10' },
  { id: 'not', icon: '🚫', text: 'Opposites: say what it is NOT' },
  { id: 'size', icon: '🙌', text: 'Show how big it is with your hands' },
  { id: 'face', icon: '😮', text: 'Make the face the word would make' },
  { id: 'hum', icon: '🎵', text: 'Hum two seconds of a tune it reminds you of' },
  { id: 'hot', icon: '🔥', text: 'Hot seat: the starter explains their clue' },
  { id: 'double', icon: '✌️', text: 'Two-word clues this lap' },
];
export const twistById = (id) => TWISTS.find((t) => t.id === id) || TWISTS[0];

// ------------------------------------------------------------------ randomness (same seed = same game everywhere)

export function hash(str) {
  let h = 0x811c9dc5;
  for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); }
  return h >>> 0;
}
export function rng(seed) {
  let a = typeof seed === 'string' ? hash(seed) : seed >>> 0;
  return function () {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
export const pick = (arr, r) => arr[Math.floor(r() * arr.length)];
export function shuffle(arr, r) {
  const a = arr.slice();
  for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
  return a;
}
export const norm = (w) => String(w).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();

// ------------------------------------------------------------------ names and room codes

export function cleanName(s) {
  const n = String(s || '').replace(/[\u0000-\u001f<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, 16);
  return n || 'Player';
}
/** Make names unique within a room: "Sam", "Sam 2". */
export function uniqueName(name, taken) {
  const base = cleanName(name);
  const lower = new Set(taken.map((t) => t.toLowerCase()));
  if (!lower.has(base.toLowerCase())) return base;
  for (let i = 2; i < 99; i++) { const n = `${base.slice(0, 13)} ${i}`; if (!lower.has(n.toLowerCase())) return n; }
  return base;
}
export const CODE_CHARS = 'BCDFGHJKLMNPQRSTVWXZ'; // no vowels: codes never spell words; no 0/O or 1/I mix-ups
export function makeCode(r = Math.random) {
  let c = '';
  for (let i = 0; i < 4; i++) c += CODE_CHARS[Math.floor(r() * CODE_CHARS.length)];
  return c;
}
export const validCode = (c) => typeof c === 'string' && /^[BCDFGHJKLMNPQRSTVWXZ]{4}$/.test(c);

// ------------------------------------------------------------------ rounds

/**
 * Deal one round. deck = [{cat, group, words[]}], ids = player ids in seat order, used = Set of "cat|word" already
 * played this game (avoided when possible), round = 0-based round number (rotates the starter).
 * Returns { cat, word, fox, twist, choices[6], starter, pack, lang }.
 */
export function deal(deck, ids, r, { used = new Set(), round = 0, twist } = {}) {
  if (!deck.length) throw new Error('empty deck');
  if (ids.length < MIN_PLAYERS) throw new Error('not enough players');
  let c, word;
  for (let i = 0; i < 40; i++) {
    c = pick(deck, r);
    word = pick(c.words, r);
    if (!used.has(`${c.cat}|${word}`)) break;
  }
  const others = shuffle(c.words.filter((w) => norm(w) !== norm(word)), r).slice(0, CHOICES - 1);
  const choices = shuffle([word, ...others], r);
  const fox = pick(ids, r);
  const starter = ids[(round + Math.floor(r() * ids.length)) % ids.length];
  return { cat: c.cat, word, fox, twist: twist || pick(TWISTS, r).id, choices, starter, pack: c.pack || 'classic', lang: c.lang || 'en' };
}

/** What one player may see: the word, or (for the Fox) only the category, plus the first letter after a peek twist. */
export function cardFor(round, id, { peek = false } = {}) {
  if (!round) return null;
  if (id === round.fox) return { fox: true, cat: round.cat, letter: peek && round.twist === 'peek' ? Array.from(round.word)[0].toUpperCase() : null };
  return { fox: false, cat: round.cat, word: round.word };
}

/** votes = {voter: target}. top = the single most-voted id, or null on a tie / no votes (a split table lets the Fox slip away). */
export function tally(votes) {
  const counts = {};
  for (const t of Object.values(votes || {})) if (t) counts[t] = (counts[t] || 0) + 1;
  let top = null, best = 0, tie = false;
  for (const [id, n] of Object.entries(counts)) {
    if (n > best) { best = n; top = id; tie = false; } else if (n === best) tie = true;
  }
  return { counts, top: tie ? null : top, best };
}

/**
 * Score a finished round. Outcomes:
 *   'escaped' the table voted someone else (or tied): Fox +2
 *   'caught'  the Fox was voted and guessed wrong (or not at all): every voter who named the Fox +1
 *   'stole'   the Fox was voted but picked the right word: Fox +2, voters who named the Fox still +1
 *   'void'    the Fox left mid-round: nobody scores
 */
export function score({ fox, word, votes = {}, guess = null, left = false }) {
  if (left) return { outcome: 'void', deltas: {}, accused: null };
  const { top } = tally(votes);
  const deltas = {};
  const add = (id, n) => { deltas[id] = (deltas[id] || 0) + n; };
  if (top !== fox) { add(fox, 2); return { outcome: 'escaped', deltas, accused: top }; }
  for (const [v, t] of Object.entries(votes)) if (t === fox && v !== fox) add(v, 1);
  if (guess && norm(guess) === norm(word)) { add(fox, 2); return { outcome: 'stole', deltas, accused: top }; }
  return { outcome: 'caught', deltas, accused: top };
}

export const OUTCOME_TEXT = {
  escaped: 'The Fox got away!',
  caught: 'Fox caught!',
  stole: 'Caught, but the Fox stole the word!',
  void: 'The Fox left: round off',
};
export const OUTCOME_EMOJI = { escaped: '🦊', caught: '🎯', stole: '🎭', void: '💨' };

/** The emoji line for share cards: one symbol per round. */
export const emojiLine = (history) => history.map((h) => OUTCOME_EMOJI[h.outcome] || '·').join('');

/** Winners = everyone on the top score. scores = {id: n}. */
export function winners(scores) {
  const top = Math.max(0, ...Object.values(scores));
  return Object.keys(scores).filter((id) => scores[id] === top && top > 0);
}

// ------------------------------------------------------------------ daily deck (everyone plays the same 5 words today)

export function dailyRounds(deck, iso, n = 5) {
  const r = rng(`hushfox-daily-${iso}`);
  const out = [];
  const seen = new Set();
  while (out.length < n && seen.size < deck.length) {
    const c = pick(deck, r);
    if (seen.has(c.cat)) continue;
    seen.add(c.cat);
    out.push({ cat: c.cat, word: pick(c.words, r), twist: pick(TWISTS, r).id });
  }
  return out;
}

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

/** Level n (1..30) settings: more words, a sneakier fox from a neighbouring category, same 30 s clock. */
export function levelSpec(n) {
  return { n, size: Math.min(8, 4 + Math.floor((n - 1) / 6)), hard: n > 8, puzzles: 5, ms: CLUE_MS };
}
export const LEVEL_STARS = [300, 450, 560];

/**
 * One Fox Hunt puzzle: size-1 words from one category plus one fox word from another (a neighbouring one when hard).
 * The fox word never appears in the main category. Returns { cat, words[], fox, foxCat }.
 */
export function huntPuzzle(deck, r, { size = 6, hard = false } = {}) {
  for (let tries = 0; tries < 60; tries++) {
    const a = pick(deck, r);
    if (a.words.length < size) continue;
    const pool = deck.filter((c) => c.cat !== a.cat && (!hard || c.group === a.group));
    const b = pick(pool.length ? pool : deck.filter((c) => c.cat !== a.cat), r);
    const inA = new Set(a.words.map(norm));
    const foxes = b.words.filter((w) => !inA.has(norm(w)));
    if (!foxes.length) continue;
    const fox = pick(foxes, r);
    const words = shuffle([...shuffle(a.words, r).slice(0, size - 1), fox], r);
    return { cat: a.cat, words, fox, foxCat: b.cat };
  }
  throw new Error('no puzzle');
}
export function huntSet(deck, seed, spec) {
  const r = rng(seed);
  return Array.from({ length: spec.puzzles }, () => huntPuzzle(deck, r, spec));
}
/** Fox Hunt score: 100 per fox found + 5 per second left on the clock (only when all five are found). */
export function huntScore(correct, msLeft, puzzles = 5) {
  return correct * 100 + (correct === puzzles ? Math.max(0, Math.round(msLeft / 1000)) * 5 : 0);
}

// ------------------------------------------------------------------ the room (server-authoritative state machine)

/**
 * The room-code game as a pure reducer, so the Durable Object and the tests run the same rules.
 * room = newRoom(code); then room = act(room, action, now, deckFn, r). deckFn(packs, pro) returns the deck.
 * Phases: lobby -> reveal -> clue -> vote -> (guess) -> result -> reveal ... -> final -> (rematch) reveal.
 */
export function newRoom(code, now = 0) {
  return { code, phase: 'lobby', players: [], host: null, round: 0, rounds: 5, packs: ['classic'], pro: false,
    cur: null, history: [], used: [], ends: 0, twistAt: 0, created: now, updated: now, game: 0 };
}
export const active = (room) => room.players.filter((p) => p.on && !p.waiting);
const P = (room, id) => room.players.find((p) => p.id === id);

function resetScores(room) { room.players.forEach((p) => { p.score = 0; if (p.on) p.waiting = false; }); }

function startRound(room, now, deckFn, r) {
  const ids = active(room).map((p) => p.id);
  const deck = deckFn(room.packs, room.pro);
  const cur = deal(deck, ids, r, { used: new Set(room.used), round: room.round });
  room.cur = { ...cur, votes: {}, guess: null, outcome: null, deltas: {}, accused: null };
  room.used.push(`${cur.cat}|${cur.word}`);
  room.phase = 'reveal';
  room.ends = now + REVEAL_MS;
}

function finishRound(room, now, { left = false } = {}) {
  const c = room.cur;
  const s = score({ fox: c.fox, word: c.word, votes: c.votes, guess: c.guess, left });
  c.outcome = s.outcome; c.deltas = s.deltas; c.accused = s.accused;
  for (const [id, n] of Object.entries(s.deltas)) { const p = P(room, id); if (p) p.score += n; }
  room.history.push({ cat: c.cat, word: c.word, fox: c.fox, foxName: (P(room, c.fox) || {}).name || 'Fox', outcome: c.outcome });
  room.phase = 'result';
  room.ends = now + RESULT_MS;
}

function afterVotes(room, now) {
  const { top } = tally(room.cur.votes);
  if (top && top === room.cur.fox) { room.phase = 'guess'; room.ends = now + GUESS_MS; }
  else finishRound(room, now);
}

function toLobbyIfShort(room, now) {
  if (room.phase !== 'lobby' && room.phase !== 'final' && active(room).length < MIN_PLAYERS) {
    room.phase = 'lobby'; room.cur = null; room.ends = 0; room.note = 'Not enough players: back to the lobby';
    room.players.forEach((p) => { p.waiting = false; });
    room.updated = now;
    return true;
  }
  return false;
}

/** Apply one action. Returns { room, error? }. Unknown or out-of-phase actions are ignored with an error string. */
export function act(room, a, now, deckFn, r = Math.random) {
  room = structuredClone(room);
  const me = a.id ? P(room, a.id) : null;
  const isHost = a.id && a.id === room.host;
  const err = (e) => ({ room, error: e });
  switch (a.t) {
    case 'join': {
      if (me) { me.on = true; if (a.name) me.name = uniqueName(a.name, room.players.filter((p) => p !== me).map((p) => p.name)); }
      else {
        if (room.players.filter((p) => p.on).length >= MAX_PLAYERS) return err('Room is full (12 players)');
        const playing = room.phase !== 'lobby' && room.phase !== 'final';
        room.players.push({ id: a.id, name: uniqueName(a.name, room.players.map((p) => p.name)), score: 0, on: true, waiting: playing });
      }
      if (!room.host || !P(room, room.host)?.on) room.host = a.id;
      break;
    }
    case 'leave': {
      if (!me) return err('Not in room');
      const wasFox = room.cur && room.cur.fox === a.id && ['reveal', 'clue', 'vote', 'guess'].includes(room.phase);
      if (room.phase === 'lobby' || room.phase === 'final' || a.hard) room.players = room.players.filter((p) => p.id !== a.id);
      else me.on = false;
      if (room.host === a.id) room.host = (room.players.find((p) => p.on) || {}).id || null;
      if (room.cur && room.cur.votes) { delete room.cur.votes[a.id]; for (const [v, t] of Object.entries(room.cur.votes)) if (t === a.id) delete room.cur.votes[v]; }
      if (toLobbyIfShort(room, now)) break;
      if (wasFox) finishRound(room, now, { left: true });
      else if (room.phase === 'vote' && active(room).every((p) => room.cur.votes[p.id])) afterVotes(room, now);
      break;
    }
    case 'settings': {
      if (!isHost) return err('Only the host can change settings');
      if (room.phase !== 'lobby' && room.phase !== 'final') return err('Game in progress');
      if (ROUND_OPTIONS.includes(a.rounds)) room.rounds = a.rounds;
      if (Array.isArray(a.packs) && a.packs.length) room.packs = a.packs.slice(0, 9).map(String);
      if (typeof a.pro === 'boolean') room.pro = a.pro;
      break;
    }
    case 'start':
    case 'rematch': {
      if (!isHost) return err('Only the host can start');
      if (a.t === 'start' && room.phase !== 'lobby') return err('Already playing');
      if (a.t === 'rematch' && room.phase !== 'final') return err('Game not finished');
      room.players = room.players.filter((p) => p.on);
      room.players.forEach((p) => { p.waiting = false; });
      if (active(room).length < MIN_PLAYERS) return err(`Need at least ${MIN_PLAYERS} players`);
      resetScores(room);
      room.note = '';
      room.round = 0; room.history = []; room.used = room.used.slice(-60); room.game += 1;
      startRound(room, now, deckFn, r);
      break;
    }
    case 'vote': {
      if (room.phase !== 'vote') return err('Not voting now');
      if (!me || me.waiting || !me.on) return err('Not playing this round');
      if (a.target === a.id || !active(room).some((p) => p.id === a.target)) return err('Pick another player');
      room.cur.votes[a.id] = a.target;
      if (active(room).every((p) => room.cur.votes[p.id])) afterVotes(room, now);
      break;
    }
    case 'guess': {
      if (room.phase !== 'guess') return err('No guess now');
      if (a.id !== room.cur.fox) return err('Only the Fox guesses');
      room.cur.guess = String(a.word || '').slice(0, 60);
      finishRound(room, now);
      break;
    }
    case 'next': {
      if (!isHost) return err('Only the host can skip');
      if (room.phase === 'reveal') { room.phase = 'clue'; room.ends = now + CLUE_MS; room.twistAt = room.ends - TWIST_AT; }
      else if (room.phase === 'clue') { room.phase = 'vote'; room.ends = now + VOTE_MS; }
      else if (room.phase === 'result') return tick(room, now + RESULT_MS + 1, deckFn, r, true);
      else return err('Nothing to skip');
      break;
    }
    case 'kick': {
      if (!isHost || a.target === a.id) return err('Only the host can remove a player');
      return act(room, { t: 'leave', id: a.target, hard: true }, now, deckFn, r);
    }
    default:
      return err('Unknown action');
  }
  room.updated = now;
  return { room };
}

/** Advance on the clock (the Durable Object alarm calls this). */
export function tick(room, now, deckFn, r = Math.random, cloned = false) {
  if (!cloned) room = structuredClone(room);
  if (!room.ends || now < room.ends) return { room };
  switch (room.phase) {
    case 'reveal': room.phase = 'clue'; room.ends = now + CLUE_MS; room.twistAt = room.ends - TWIST_AT; break;
    case 'clue': room.phase = 'vote'; room.ends = now + VOTE_MS; break;
    case 'vote': afterVotes(room, now); break;
    case 'guess': finishRound(room, now); break;
    case 'result':
      room.round += 1;
      if (room.round >= room.rounds) { room.phase = 'final'; room.ends = 0; }
      else {
        room.players.forEach((p) => { if (p.on) p.waiting = false; });
        if (!toLobbyIfShort(room, now)) startRound(room, now, deckFn, r);
      }
      break;
    default: room.ends = 0;
  }
  room.updated = now;
  return { room };
}

/** The next moment the room needs the clock (phase end or the Twist flip), or 0. */
export function nextWake(room, now) {
  const t = [room.ends];
  if (room.phase === 'clue' && room.twistAt > now) t.push(room.twistAt);
  const f = t.filter((x) => x && x > 0);
  return f.length ? Math.min(...f) : 0;
}

/** The state one player receives: public room + their own card. Never leaks the word to the Fox or the Fox to anyone. */
export function view(room, id, now) {
  const c = room.cur;
  const playing = c && ['reveal', 'clue', 'vote', 'guess'].includes(room.phase);
  const me = P(room, id);
  const twistOn = room.phase === 'clue' ? now >= room.twistAt : ['vote', 'guess', 'result'].includes(room.phase);
  const out = {
    code: room.code, phase: room.phase, host: room.host, you: id, round: room.round, rounds: room.rounds, packs: room.packs, pro: room.pro,
    ends: room.ends, twistAt: room.twistAt, now, note: room.note || '', game: room.game,
    players: room.players.map((p) => ({ id: p.id, name: p.name, score: p.score, on: p.on, waiting: !!p.waiting, voted: !!(c && c.votes && c.votes[p.id]) })),
    history: room.history.map((h) => ({ ...h })),
  };
  if (c) {
    out.cur = { cat: c.cat, starter: c.starter, twist: twistOn ? c.twist : null, votes: room.phase === 'result' ? c.votes : undefined };
    if (playing && me && !me.waiting) out.card = cardFor(c, id, { peek: twistOn });
    if (room.phase === 'guess') { out.cur.accused = c.fox; if (id === c.fox) out.cur.choices = c.choices; }
    if (room.phase === 'result' || room.phase === 'final') Object.assign(out.cur, { word: c.word, fox: c.fox, guess: c.guess, outcome: c.outcome, deltas: c.deltas, accused: c.accused });
  }
  return out;
}