Spaces:
Running
Running
Download core.js from CyberMax-tools/hushfox: direct link, hf CLI and curl.
- Browser
- Download file 19.5 kB
-
https://huggingface.co/spaces/CyberMax-tools/hushfox/resolve/main/core.js
- Command line
-
hf download hf://spaces/CyberMax-tools/hushfox/core.js
-
curl -L -o core.js https://huggingface.co/spaces/CyberMax-tools/hushfox/resolve/main/core.js
19.5 kB
| // 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; | |
| } | |