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