// Stoopshake rules engine: classic Cee-lo with three dice, plus the free-chip economy, ranks, bots, the daily table and // the async challenge link. Pure (no DOM, no clock unless passed, no Math.random unless no rng is passed), so the unit tests // cover every outcome and the Telegram table (data Worker src/tgdice.js) scores rolls the same way. // Rules (shown on first open): // 4-5-6 wins on the spot, 1-2-3 loses on the spot; trips beat any point (higher trips win); a pair plus a single sets the // point (the single die is the point, 1-6); anything else is no score: roll again. // Chips are free play chips: daily refill, earned by playing, never bought, sold, traded or cashed out. No prizes. // (c) 2026 CyberMax. All rights reserved. export const FACES = ['⚀', '⚁', '⚂', '⚃', '⚄', '⚅']; export const face = (n) => FACES[n - 1] || '?'; /** Score three dice. Returns { kind: '456'|'123'|'trips'|'point'|'none', value, rank, instant, label, short }. */ export function score(dice) { if (!Array.isArray(dice) || dice.length !== 3 || dice.some((d) => !Number.isInteger(d) || d < 1 || d > 6)) throw new Error('three dice 1-6'); const [a, b, c] = [...dice].sort((x, y) => x - y); if (a === 4 && b === 5 && c === 6) return { kind: '456', value: 6, rank: 100, instant: 'win', label: '4-5-6! Instant win', short: '4-5-6' }; if (a === 1 && b === 2 && c === 3) return { kind: '123', value: 1, rank: -100, instant: 'loss', label: '1-2-3. Instant loss', short: '1-2-3' }; if (a === b && b === c) return { kind: 'trips', value: a, rank: 50 + a, instant: null, label: `Trip ${a}s`, short: `Trips ${a}` }; if (a === b || b === c) { const single = a === b ? c : a; return { kind: 'point', value: single, rank: 10 + single, instant: null, label: `Point ${single}`, short: `Point ${single}` }; } return { kind: 'none', value: 0, rank: null, instant: null, label: 'No score. Roll again', short: 'No score' }; } export const scores = (s) => s.kind !== 'none'; /** -1 / 0 / 1: does score a beat score b? (both must be scoring rolls) */ export function compare(a, b) { return Math.sign(a.rank - b.rank); } /** Table (2-6 seats, everyone antes the same stake). results: [{ seat, score }] in roll order; a 4-5-6 ends the round at once. * Returns { winners: [seat], instant: bool, out: [seat] (1-2-3) }. Several winners = a roll-off between them. */ export function tableWinners(results) { const inst = results.find((r) => r.score.kind === '456'); if (inst) return { winners: [inst.seat], instant: true, out: results.filter((r) => r.score.kind === '123').map((r) => r.seat) }; const live = results.filter((r) => r.score.kind !== '123' && r.score.kind !== 'none'); const out = results.filter((r) => r.score.kind === '123').map((r) => r.seat); if (!live.length) return { winners: results.map((r) => r.seat), instant: false, out: [] }; // everyone rolled 1-2-3: all roll again const best = Math.max(...live.map((r) => r.score.rank)); return { winners: live.filter((r) => r.score.rank === best).map((r) => r.seat), instant: false, out }; } /** Banker mode, from the player's side: 'win' | 'lose' | 'push'. bank = banker's scoring roll; player = player's (or null if * the banker's roll already settled every bet: 4-5-6 = bank takes all, 1-2-3 = bank pays all). */ export function bankerSettle(bank, player) { if (bank.kind === '456') return 'lose'; if (bank.kind === '123') return 'win'; if (!player) throw new Error('player roll needed'); if (player.kind === '456') return 'win'; if (player.kind === '123') return 'lose'; const c = compare(player, bank); return c > 0 ? 'win' : c < 0 ? 'lose' : 'push'; } /** The bank passes to a player who beat it with 4-5-6 or trips (classic rule); else the banker keeps it. */ export const takesBank = (bank, player, outcome) => outcome === 'win' && player && (player.kind === '456' || player.kind === 'trips'); /** Quick 1v1: the first roller's instant result ends it; else both roll and compare (tie = shoot-off). */ export function duel(first, second) { if (first.kind === '456') return { winner: 0, instant: true }; if (first.kind === '123') return { winner: 1, instant: true }; if (!second) return { winner: null, instant: false }; if (second.kind === '456') return { winner: 1, instant: true }; if (second.kind === '123') return { winner: 0, instant: true }; const c = compare(first, second); return { winner: c > 0 ? 0 : c < 0 ? 1 : null, instant: false, tie: c === 0 }; } // ------------------------------------------------------------------ free chips (never bought, sold or cashed out) export const START_CHIPS = 500; export const DAILY_FLOOR = 500; // each new day tops you back up to at least this export const DAILY_BONUS = 100; // + this for showing up (streak days add a little more, capped) export const STAKES = [10, 25, 50, 100, 250]; export function newWallet(day) { return { chips: START_CHIPS, day, streakDays: 1, bestStreak: 0, winStreak: 0, rep: 0, played: 0, won: 0, best: null, big: { r456: 0, trips: 0, r123: 0 } }; } /** Daily refill: on a new day, top up to DAILY_FLOOR and add the visit bonus. Returns { wallet, gift } (gift = chips added). */ export function dailyRefill(w, day) { if (!w) return { wallet: newWallet(day), gift: START_CHIPS }; if (w.day === day) return { wallet: w, gift: 0 }; const streakDays = w.day === day - 1 ? Math.min(30, (w.streakDays || 1) + 1) : 1; const bonus = DAILY_BONUS + Math.min(10, streakDays - 1) * 10; const chips = Math.max(w.chips, DAILY_FLOOR) + bonus; return { wallet: { ...w, day, streakDays, chips }, gift: chips - w.chips }; } /** Settle one round for the local player: delta chips (+ or -), rep earned, streak. */ export function settle(w, { delta, sc, won }) { const out = { ...w, big: { ...w.big } }; out.chips = Math.max(0, w.chips + delta); out.played += 1; if (won) { out.won += 1; out.winStreak = (w.winStreak || 0) + 1; out.bestStreak = Math.max(w.bestStreak || 0, out.winStreak); } else if (won === false) out.winStreak = 0; let rep = won ? 10 : 2; if (sc && sc.kind === '456') { rep += 5; out.big.r456 += 1; } if (sc && sc.kind === 'trips') { rep += 3 + sc.value; out.big.trips += 1; } if (sc && sc.kind === '123') out.big.r123 += 1; out.rep = (w.rep || 0) + rep; if (sc && scores(sc) && (!w.best || sc.rank > w.best.rank)) out.best = { kind: sc.kind, value: sc.value, rank: sc.rank, short: sc.short }; return out; } export const RANKS = [ { id: 'rookie', name: 'Rookie', at: 0 }, { id: 'regular', name: 'Regular', at: 100 }, { id: 'shooter', name: 'Shooter', at: 400 }, { id: 'captain', name: 'Captain', at: 1200 }, { id: 'champ', name: 'Champ', at: 3000 }, { id: 'legend', name: 'Legend', at: 7500 }, ]; export function rankOf(rep = 0) { let i = 0; for (let k = 0; k < RANKS.length; k++) if (rep >= RANKS[k].at) i = k; const next = RANKS[i + 1]; return { ...RANKS[i], index: i, next: next || null, toNext: next ? next.at - rep : 0, pct: next ? (rep - RANKS[i].at) / (next.at - RANKS[i].at) : 1 }; } // ------------------------------------------------------------------ bots with personalities (friendly trash talk, 13+) export const BOTS = [ { id: 'dot', name: 'Ms. Dot', vibe: 'the block grandma', power: 0.55, risk: 0.3, color: '#F4B6C2', lines: { hi: ['Sit down, baby. Let Ms. Dot show you.', 'I was shaking dice before your phone was born.'], win: ['Mm-hm. Grandma still got it.', 'Go get you some practice, sugar.'], lose: ['Beginner\'s luck. I\'ll allow it.', 'Okay, okay. Run it back.'], big: ['That\'s how we did it back in my day.', 'Now THAT is a roll.'] } }, { id: 'pip', name: 'Lil Pip', vibe: 'the showboat', power: 0.95, risk: 0.8, color: '#7CF7C8', lines: { hi: ['Cameras out. Lil Pip in the building.', 'Hope you stretched.'], win: ['Too easy. Somebody clip that.', 'Light work.'], lose: ['Nah, the curb moved.', 'I let you have that one.'], big: ['SHEEESH. Did you see that?', 'Put it on the big screen.'] } }, { id: 'prof', name: 'The Professor', vibe: 'calm, does the maths', power: 0.7, risk: 0.45, color: '#9EC5FF', lines: { hi: ['Probability is on nobody\'s side. Shall we?', 'Three dice, 216 outcomes. Only one of them is mine.'], win: ['As calculated.', 'The numbers were kind.'], lose: ['Variance. Noted.', 'A fair result. Again?'], big: ['Fascinating.', 'A 1 in 36 shot. Lovely.'] } }, { id: 'nova', name: 'Nova', vibe: 'cool, quiet, dangerous', power: 0.8, risk: 0.6, color: '#C9A2FF', lines: { hi: ['...', 'Your roll.'], win: ['Mm.', 'Again.'], lose: ['Fine.', 'Respect.'], big: ['Ice cold.', 'Yeah.'] } }, { id: 'ace', name: 'Big Ace', vibe: 'loud, loves the game', power: 1.0, risk: 0.7, color: '#FFC56B', lines: { hi: ['WHO\'S NEXT?! Big Ace on the stoop!', 'Bring the whole block!'], win: ['THAT\'S WHAT I\'M TALKIN\' ABOUT!', 'Run it back, run it back!'], lose: ['AW, COME ON!', 'Okay, you got me. ONE more.'], big: ['OHHHH! THE BLOCK IS LOUD TONIGHT!', 'Somebody call the whole street!'] } }, { id: 'kiko', name: 'Kiko', vibe: 'new on the block, hyped', power: 0.65, risk: 0.5, color: '#FF8FA3', lines: { hi: ['Wait, how does the point work again? Kidding. Let\'s go!', 'First time on this stoop. Be nice.'], win: ['I WON? I WON!', 'Ha! Ranked up!'], lose: ['Next one\'s mine.', 'Learning, learning.'], big: ['NO WAY!', 'Did that just happen?!'] } }, ]; export const botById = (id) => BOTS.find((b) => b.id === id); export function line(bot, mood, rnd = Math.random) { const l = bot.lines[mood] || bot.lines.hi; return l[Math.floor(rnd() * l.length) % l.length]; } /** A bot's stake choice for the Table (pot) or as a Banker player. */ export function botStake(bot, wallet, base, rnd = Math.random) { const opts = STAKES.filter((s) => s <= Math.max(10, wallet)); const pick = Math.min(opts.length - 1, Math.max(0, Math.round((bot.risk + (rnd() - 0.5) * 0.4) * (opts.length - 1)))); return Math.max(base || 10, opts[pick] || 10); } // ------------------------------------------------------------------ the daily table (same lineup and goal for everyone) export const EPOCH = Date.UTC(2026, 9, 2); // CyberMax daily #1 = 2 Oct 2026 (shared with every CyberMax game) export const dayNumber = (now = Date.now()) => Math.floor((now - EPOCH) / 864e5) + 1; function mulberry(seed) { let t = seed >>> 0; return () => { t += 0x6D2B79F5; let r = Math.imul(t ^ (t >>> 15), 1 | t); r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } export const rng = mulberry; export const DAILY_ROUNDS = 5; export function dailyTable(day) { const r = mulberry(day * 7919 + 17); const pool = [...BOTS]; const lineup = []; while (lineup.length < 3) lineup.push(pool.splice(Math.floor(r() * pool.length), 1)[0].id); const scenes = ['corner', 'corner', 'stoop', 'rooftop', 'arcade', 'court', 'boardwalk']; return { day, lineup, stake: [25, 50, 50, 100][Math.floor(r() * 4)], rounds: DAILY_ROUNDS, scene: scenes[day % scenes.length], label: `Daily Table #${day}` }; } /** Spoiler-free share text for a finished daily (Wordle-style): one emoji per round. */ export function dailyCard(day, rounds, net) { const em = rounds.map((x) => (x.sc && x.sc.kind === '456' ? '🔥' : x.sc && x.sc.kind === 'trips' ? '👑' : x.sc && x.sc.kind === '123' ? '💀' : x.won ? '✅' : '❌')).join(''); return `Stoopshake Daily #${day} 🎲\n${em} ${net >= 0 ? '+' : ''}${net} chips\nNo real money. Just for fun.`; } // ------------------------------------------------------------------ async challenge links (state in the URL, no server) // { v, n: [nameA, nameB], s: [winsA, winsB], k: round, t: whose turn (0/1), d: last roll [x,y,z] or null, w: first to } const b64u = (s) => (typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(s))) : Buffer.from(s, 'utf8').toString('base64')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const unb64u = (s) => { const t = s.replace(/-/g, '+').replace(/_/g, '/'); return typeof atob === 'function' ? decodeURIComponent(escape(atob(t))) : Buffer.from(t, 'base64').toString('utf8'); }; function sum(str) { let h = 2166136261; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(36).slice(-4); } export const cleanName = (s) => String(s || '').replace(/[^\p{L}\p{N} ._-]/gu, '').replace(/\s+/g, ' ').trim().slice(0, 14) || 'Player'; export function encodeChallenge(st) { const o = { v: 1, n: st.n.map(cleanName), s: st.s.map((x) => Math.max(0, Math.min(9, x | 0))), k: Math.max(1, st.k | 0), t: st.t ? 1 : 0, d: st.d || null, w: st.w || 3 }; const j = JSON.stringify(o); return `${b64u(j)}.${sum(j)}`; } export function decodeChallenge(code) { try { const [p, c] = String(code || '').split('.'); const j = unb64u(p); if (sum(j) !== c) return null; const o = JSON.parse(j); if (o.v !== 1 || !Array.isArray(o.n) || o.n.length !== 2 || !Array.isArray(o.s)) return null; if (o.d && (!Array.isArray(o.d) || o.d.length !== 3 || o.d.some((x) => !Number.isInteger(x) || x < 1 || x > 6))) return null; return { n: o.n.map(cleanName), s: o.s.map((x) => x | 0), k: o.k | 0, t: o.t ? 1 : 0, d: o.d, w: o.w || 3 }; } catch { return null; } } /** Apply one async turn: the player whose turn it is rolled `dice` (a scoring roll). Returns the next state + what happened. * Round flow: A rolls (d stored), B rolls and the round is decided against A's stored roll; then the loser of the round * starts the next one. A 4-5-6 or 1-2-3 by the first roller decides the round at once. Ties replay the round. */ export function challengeTurn(st, dice) { const me = st.t; const sc = score(dice); if (!scores(sc)) throw new Error('scoring roll needed'); const next = { ...st, s: [...st.s] }; if (!st.d) { // first roller of this round if (sc.instant) { const w = sc.instant === 'win' ? me : 1 - me; next.s[w] += 1; next.k += 1; next.d = null; next.t = 1 - w; return { st: next, sc, round: { winner: w, instant: true } }; } next.d = dice; next.t = 1 - me; return { st: next, sc, round: null }; } const first = score(st.d); const r = duel(first, sc); // first = the other player next.d = null; if (r.winner === null) { next.t = 1 - me; return { st: next, sc, round: { winner: null, tie: true } }; } // tie: the other player rolls first again const w = r.winner === 0 ? 1 - me : me; next.s[w] += 1; next.k += 1; next.t = 1 - w; return { st: next, sc, round: { winner: w } }; } export const seriesOver = (st) => (st.s[0] >= st.w ? 0 : st.s[1] >= st.w ? 1 : null); // ------------------------------------------------------------------ region board (opt-in, coarse, never precise) /** Coarse region from what the browser already reports (time zone + language). Never GPS. e.g. "New York · US". */ export function regionOf(tz, lang) { const t = String(tz || ''); const city = (t.split('/').pop() || '').replace(/_/g, ' ').replace(/[^A-Za-z .-]/g, '').slice(0, 24); const cc = (/[-_]([A-Za-z]{2})$/.exec(String(lang || '')) || [])[1]; const area = t.split('/')[0] || ''; const label = city ? `${city}${cc ? ` · ${cc.toUpperCase()}` : ''}` : (cc ? cc.toUpperCase() : 'Earth'); return { id: `${area}/${city}`.toLowerCase().replace(/[^a-z/ .-]/g, '').slice(0, 40) || 'earth', label, cc: cc ? cc.toUpperCase() : '' }; }