Spaces:
Running
Running
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;
}
|