Spaces:
Running
Running
File size: 15,343 Bytes
9759528 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | // 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() : '' };
}
|