stoopshake / rules.js
CyberMax-tools's picture
Deploy stoopshake
9759528 verified
Raw History Blame Contribute Delete
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() : '' };
}