hushfox / core.js
CyberMax-tools's picture
Deploy hushfox
0cc8837 verified
Raw History Blame Contribute Delete
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;
}