stoopshake / game.js
CyberMax-tools's picture
Deploy stoopshake
9759528 verified
Raw History Blame Contribute Delete
52.8 kB
// Stoopshake: free 3D street dice (classic Cee-lo, 4-5-6) on a night-time stoop. Real-physics dice (dice3d.js), the rules
// engine (rules.js), an original soundtrack (audio.js). Modes: Quick 1v1, Table (up to 6, pot), Banker, the Daily Table,
// pass-and-play, async challenge links (state in the URL, no server), Crew tables (Crew Pass), Telegram Mini App + group
// chats (/dice on the bot, rolled with Telegram's own animated dice). Free play chips only: daily top-up, earned by playing,
// never bought, sold or cashed out; no prizes. Paid = looks and crew features only, never odds. (c) 2026 CyberMax.
import { count, store, toast, share, download } from './cmx.js';
import * as FP from './funpass.js';
import * as pack from './pack.js';
import * as TGM from './tg.js';
import * as R from './rules.js';
import * as A from './audio.js';
const GAME = 'stoopshake';
const SITE = 'https://cybermaxtools.com';
const PATH = '/games/stoopshake/';
const DATA = 'https://data.cybermaxtools.com';
const BOT_LINK = 'https://t.me/CyberMaxTools_bot';
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms * (CAP ? 0 : FAST ? 0.15 : 1)));
const Q = new URLSearchParams(location.search);
const CAP = Q.has('cap'); // trailer / screenshots: frame-stepped from outside (window.__cap)
const FAST = Q.has('fast'); // tests: shorter pauses
const TODAY = R.dayNumber();
const dz = (d) => (d || []).map(R.face).join('');
// ------------------------------------------------------------------ paid extras (cosmetic / convenience only)
pack.capture({ game: GAME, name: 'Stoopshake', title: 'Dice & Table Pack', skins: 4, price: '$1.99', preview: ['#D9DDE3', '#E6B54A', '#E4D6B8', '#3CFFE0'],
perks: ['4 dice: Chrome, Gold, Bone and Neon', '3 tables: Brownstone Stoop, Rooftop and Arcade', '5 win emotes for your big rolls'] });
const CREW_PERKS = ['Crew tables: save your crew (up to 6) and seat them in one tap', 'Your crew name and colours on the dice and every share card', 'Stats history: every round, win rate, best rolls', '2 more tables: Night Court and Boardwalk'];
// Named exactly as the Stripe product (prod "Stoopshake Crew Pass: crew tables, colours and stats"); the Crew Pass also
// turns on every Dice & Table Pack look (ownPack() below), so the sheet says so. Every mode stays free (17 Oct 2026).
FP.init({ game: GAME, name: 'Stoopshake', title: 'Stoopshake Crew Pass', short: 'Crew Pass', exact: true,
free: 'Every mode, the Daily Table and the daily free chips stay free for everyone.',
perks: [...CREW_PERKS, 'Also every Dice & Table Pack look: 4 dice, 3 tables, 5 emotes'],
extra: 'The Crew Pass opens crew tables, your crew name and colours, stats history and two more tables. Same dice odds for everyone.' });
const ownPack = () => pack.owned() || FP.isPro();
const ownCrew = () => FP.isPro();
const EMOTES = [{ id: 'none', t: '' }, { id: 'mic', t: '🎤 MIC DROP' }, { id: 'ice', t: '🧊 ICE COLD' }, { id: 'easy', t: '😤 TOO EASY' }, { id: 'crown', t: '👑 RUN IT BACK' }, { id: 'dance', t: '🕺 DANCE BREAK' }];
// ------------------------------------------------------------------ saved on this device only
const DEF = { name: '', wallet: null, rules: false, skin: 'classic', theme: 'corner', emote: 'none', daily: {}, hist: [], crew: { name: 'MY CREW', a: '#E23B5A', b: '#FFFFFF', members: [] }, board: false, dev: '', locals: [] };
const S = Object.assign({}, DEF, store('ssk:s') || {});
if (!S.dev) S.dev = Math.random().toString(36).slice(2, 12);
if (!S.name) S.name = ['ACE', 'BLAZE', 'DOT', 'KASH', 'NOVA', 'RICO', 'JAY', 'MIKA', 'ZEE', 'LUX'][Math.floor(Math.random() * 10)] + (10 + Math.floor(Math.random() * 89));
const save = () => store('ssk:s', S);
let gift = 0;
({ wallet: S.wallet, gift } = R.dailyRefill(S.wallet, TODAY)); save();
// ------------------------------------------------------------------ challenge link from a friend (?c=...)
let chal = Q.get('c') ? R.decodeChallenge(Q.get('c')) : null;
// ------------------------------------------------------------------ 3D boot
let table = null, DT = null;
const cv = $('c');
async function boot() {
const ok = (() => { try { return !!document.createElement('canvas').getContext('webgl2'); } catch { return false; } })();
if (!ok) { $('loading').hidden = true; $('nogl').hidden = false; count(GAME, 'nogl'); return; }
$('lbar').style.width = '35%';
DT = await import('./dice3d.js');
$('lbar').style.width = '75%';
try { await document.fonts.load('800 20px "Barlow Condensed"'); } catch { /* */ }
table = new DT.DiceTable(cv, { theme: S.theme, skin: skinFor(), record: CAP, onImpact: (k, v) => A.hit(k, v), onSlow: () => { A.whoosh(0.3); $('app').classList.add('slow'); } });
if (S.skin === 'crew') table.setSkin('crew', S.crew);
if (FAST) { table.speed = 3; table.stage.setQuality(0.5); table.stage.q.bloom = false; table.r.shadowMap.enabled = false; } // tests on software GL: same physics, faster clock, cheap frames
addEventListener('resize', () => table.resize()); table.resize();
// compile every shader behind the loading screen (parallel where the GPU allows), so the first frame doesn't stall
$('lbar').style.width = '90%';
const tc = performance.now();
try { table.stage.setCam(table.camPos, table.camLook); await table.r.compileAsync(table.scene, table.stage.cam); } catch { /* older three: compiles on first frame */ }
table.update(1 / 60); // first frame (post-processing programs)
window.__sskCompile = Math.round(performance.now() - tc);
$('loading').hidden = true;
count(GAME, 'view', TGM.app() ? 'telegram' : chal ? 'challenge' : '');
if (!CAP) requestAnimationFrame(loop);
window.__ssk = { ready: true, readyAt: Math.round(performance.now()), rolls: 0, state: () => ({ mode: M && M.mode, turn: turnSeat && turnSeat.name, chips: S.wallet.chips, waiting: !!waiting }), table };
renderTop();
if (gift > 0 && S.wallet.played > 0) setTimeout(() => toast(`Daily top-up: +${gift} free chips. Come back tomorrow for more.`, 3500), 600);
if (chal) home(); else if (!S.rules) { $('rules').hidden = false; } else home();
}
const skinFor = () => { const sk = DT.SKINS[S.skin]; if (!sk || (sk.pack === 'skins' && !ownPack()) || (sk.pack === 'crew' && !ownCrew())) return 'classic'; return S.skin; };
const themeFor = () => { const th = DT.THEMES[S.theme]; if (!th || (th.pack === 'skins' && !ownPack()) || (th.pack === 'crew' && !ownCrew())) return 'corner'; return S.theme; };
let last = performance.now();
function loop(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
if (holding && table.state === 'hand') { table.shake(holdX, holdK); A.rattle(holdK); holdK = Math.max(0.35, holdK * 0.96); }
table.update(dt); rec.frame();
if (table.state !== 'rolling') $('app').classList.remove('slow');
requestAnimationFrame(loop);
}
// frame-stepped capture for the trailer and store shots (no wall clock)
window.__cap = {
step: (dt = 1 / 30, draw = true) => { if (!table) return; table.noDraw = !draw; table.update(dt); table.noDraw = false; }, ready: () => !!table,
// trailer + store shots: drive the real scene and the real HUD from outside (tests/trailer.mjs)
hideAll: () => { hideAll(); actions([]); },
seats: (list, turn) => { M = { mode: 'cap', seats: list.map((x) => ({ ...x, last: x.last || null, sc: x.last ? R.score(x.last) : null })), chipsShown: !!list[0].chips, potLabel: () => list.pot || '' }; turnSeat = M.seats[turn ?? -1] || null; renderSeats(); },
pot: (t) => { $('pot').textContent = t; },
banner: (kind, text, sub, emote) => { const b = $('banner'); b.className = `banner k${kind}`; b.innerHTML = `${esc(text)}${sub ? `<small>${esc(sub)}</small>` : ''}${emote ? `<span class="emote">${esc(emote)}</span>` : ''}`; b.classList.add('show'); },
unbanner: () => $('banner').classList.remove('show'),
talk: (who, text) => { const t = $('talk'); t.innerHTML = `<b>${esc(who)}:</b> ${esc(text)}`; t.classList.add('show'); },
untalk: () => $('talk').classList.remove('show'),
hint: (t) => { $('hint').textContent = t || ''; $('hint').classList.toggle('hide', !t); },
roll: (on) => { $('roll').disabled = !on; },
hud: (on) => { $('hud').style.display = on ? '' : 'none'; },
theme: (id) => { table.setTheme(id); table.hand(0); }, skin: (id) => table.setSkin(id, S.crew),
find: (want, from = 1, n = 400, power = 0.62) => { for (let s = from; s < from + n; s++) { const r = table.simulate({ seed: s, power }); if (!r.off && !r.cocked && R.score(r.dice).kind === want) return { seed: s, dice: r.dice }; } return null; },
throwSeed: (seed, power = 0.62) => { table.hand(0); table.throw({ seed, power }); },
state: () => table.state, celebrate: (k) => table.celebrate(k), shake: (x, k) => table.shake(x, k), hand: (x) => table.hand(x || 0),
read: () => table.read().map((r) => r.val),
score: () => R.score(table.read().map((r) => r.val)),
};
// ------------------------------------------------------------------ input: hold to shake, flick to throw
let waiting = null, holding = false, holdX = 0, holdK = 0, trail = [];
const toX = (clientX) => { const r = cv.getBoundingClientRect(); return ((clientX - r.left) / r.width - 0.5) * 8; };
cv.addEventListener('pointerdown', (e) => {
A.ensure(); A.startMusic();
if (!waiting || table.state !== 'hand') return;
holding = true; holdX = toX(e.clientX); holdK = 1; trail = [{ x: e.clientX, y: e.clientY, t: performance.now() }];
try { cv.setPointerCapture(e.pointerId); } catch { /* */ }
$('hint').classList.add('hide');
});
cv.addEventListener('pointermove', (e) => {
if (!holding) return;
const p = { x: e.clientX, y: e.clientY, t: performance.now() }; const q = trail[trail.length - 1];
holdX = toX(e.clientX); holdK = Math.min(1, holdK + Math.hypot(p.x - q.x, p.y - q.y) / 60);
trail.push(p); if (trail.length > 30) trail.shift();
});
const endHold = (e) => {
if (!holding) return; holding = false;
const now = performance.now(); const pts = trail.filter((p) => now - p.t < 110); const a = pts[0] || trail[0], b = trail[trail.length - 1];
const dt = Math.max(16, b.t - a.t), vx = (b.x - a.x) / dt, vy = (b.y - a.y) / dt; // px per ms
const r = cv.getBoundingClientRect(), k = 900 / Math.max(320, r.height);
const flick = -vy * k;
const power = flick > 0.25 ? Math.min(1, 0.3 + flick * 0.42) : 0.55; // a tap or a sideways drag = a steady toss
doThrow({ x: Math.max(-1, Math.min(1, vx * k * 0.6)), power });
};
cv.addEventListener('pointerup', endHold); cv.addEventListener('pointercancel', endHold);
addEventListener('keydown', (e) => { if ((e.key === ' ' || e.key === 'Enter') && waiting && document.activeElement === cv) { e.preventDefault(); doThrow({ power: 0.6 + Math.random() * 0.3, x: (Math.random() - 0.5) * 0.4 }); } });
$('roll').onclick = () => { A.ensure(); A.startMusic(); if (waiting) doThrow({ power: 0.55 + Math.random() * 0.4, x: (Math.random() - 0.5) * 0.5 }); };
function doThrow(o) { if (!waiting || table.state !== 'hand') return; if (window.__ssk && !window.__ssk.firstThrow) window.__ssk.firstThrow = Math.round(performance.now()); const w = waiting; waiting = null; $('roll').disabled = true; A.whoosh(o.power); TGM.haptic('medium'); w(o); }
// ------------------------------------------------------------------ clip recorder (on this device only, watermark link)
const rec = (() => {
let mr = null, chunks = [], c2 = null, g = null, type = '', on = false;
const ok = () => !CAP && window.MediaRecorder && HTMLCanvasElement.prototype.captureStream;
return {
start() {
if (!ok() || on) return;
type = ['video/mp4;codecs=avc1', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'].find((t) => MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)); if (!type) return;
const w = 720, h = Math.round(720 * cv.clientHeight / Math.max(1, cv.clientWidth)); c2 = c2 || document.createElement('canvas'); c2.width = w; c2.height = Math.min(1280, Math.max(480, h)); g = c2.getContext('2d');
try { const s = c2.captureStream(30); const au = A.stream(); if (au) au.getAudioTracks().forEach((t) => s.addTrack(t)); mr = new MediaRecorder(s, { mimeType: type, videoBitsPerSecond: 4_000_000 }); } catch { mr = null; return; }
chunks = []; mr.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); }; mr.start(250); on = true;
},
frame() {
if (!on || !g) return;
const W = c2.width, H = c2.height, sw = cv.width, sh = cv.height, k = Math.max(W / sw, H / sh);
g.drawImage(cv, (W - sw * k) / 2, (H - sh * k) / 2, sw * k, sh * k);
g.fillStyle = 'rgba(0,0,0,.55)'; g.fillRect(0, H - 54, W, 54);
g.font = '800 30px "Barlow Condensed", sans-serif'; g.fillStyle = '#FFC56B'; g.textBaseline = 'middle'; g.fillText('STOOPSHAKE', 16, H - 27);
g.fillStyle = '#FFFFFF'; g.textAlign = 'right'; g.font = '700 22px "Barlow Condensed", sans-serif'; g.fillText('cybermaxtools.com/games/stoopshake · free play chips', W - 16, H - 27); g.textAlign = 'left';
},
stop() { return new Promise((res) => { if (!on || !mr) { res(null); return; } on = false; mr.onstop = () => res(chunks.length ? { blob: new Blob(chunks, { type: type.split(';')[0] }), ext: type.includes('mp4') ? 'mp4' : 'webm' } : null); try { mr.stop(); } catch { res(null); } }); },
};
})();
let clip = null;
// ------------------------------------------------------------------ UI bits
function renderTop() {
$('chipsN').textContent = S.wallet.chips.toLocaleString('en-US');
const rk = R.rankOf(S.wallet.rep); $('rank').textContent = rk.name.toUpperCase();
$('b-snd').classList.toggle('off', !A.settings().music);
document.body.classList.toggle('cmx-pro', ownCrew());
}
let bannerT = 0;
function banner(kind, text, sub = '', ms = 1700, emote = '') {
const b = $('banner'); b.className = `banner k${kind}`; b.innerHTML = `${esc(text)}${sub ? `<small>${esc(sub)}</small>` : ''}${emote ? `<span class="emote">${esc(emote)}</span>` : ''}`;
requestAnimationFrame(() => b.classList.add('show')); clearTimeout(bannerT); bannerT = setTimeout(() => b.classList.remove('show'), ms * (FAST ? 0.3 : 1));
}
let talkT = 0;
function talk(who, text, ms = 2600) { const t = $('talk'); t.innerHTML = `<b>${esc(who)}:</b> ${esc(text)}`; t.classList.add('show'); clearTimeout(talkT); talkT = setTimeout(() => t.classList.remove('show'), ms); }
function renderSeats() {
if (!M) { $('seats').innerHTML = ''; $('pot').textContent = ''; return; }
$('seats').innerHTML = M.seats.map((s, i) => `<div class="seat${s === turnSeat ? ' on' : ''}${s.out ? ' out' : ''}${s.win ? ' win' : ''}" style="border-left:4px solid ${s.color}">${M.bank === i ? '<span class="bank">BANK</span>' : ''}<b>${esc(s.name)}</b>`
+ `<span class="dz">${s.last ? dz(s.last) : '&nbsp;'}</span><small>${s.sc ? esc(s.sc.short) : s.kind === 'bot' ? esc(s.bot.vibe) : s.kind === 'me' ? 'you' : '&nbsp;'}${M.chipsShown && s.chips != null ? ` · ◉${s.chips}` : ''}</small></div>`).join('');
$('pot').textContent = M.potLabel ? M.potLabel() : '';
}
function actions(btns) { $('actions').innerHTML = ''; for (const [label, fn, cls] of btns) { const b = document.createElement('button'); b.type = 'button'; b.className = cls || 'chipb'; b.textContent = label; b.onclick = fn; $('actions').appendChild(b); } }
const hideAll = () => { for (const id of ['home', 'rules', 'setup', 'pass', 'end', 'style', 'board', 'me', 'shop']) $(id).hidden = true; };
// ------------------------------------------------------------------ one turn: a seat rolls until it scores
let M = null, turnSeat = null;
async function rollFor(seat, { label } = {}) {
turnSeat = seat; renderSeats();
for (let tries = 0; tries < 40; tries++) {
table.hand(seat.kind === 'bot' ? (Math.random() - 0.5) * 3 : 0);
let throwOpts;
if (seat.kind === 'bot') {
$('roll').disabled = true; $('hint').classList.add('hide');
await sleep(450 + Math.random() * 400);
if (tries === 0 && Math.random() < 0.45) talk(seat.name, R.line(seat.bot, 'hi'));
const t0 = performance.now(); while (performance.now() - t0 < (CAP ? 0 : FAST ? 120 : 700)) { table.shake((Math.random() - 0.5) * 2, 1); A.rattle(0.8); await sleep(50); }
throwOpts = { power: seat.bot.power * (0.75 + Math.random() * 0.25), x: (Math.random() - 0.5) * 0.6 };
A.whoosh(throwOpts.power);
} else {
if (seat.kind === 'local' && M.locals > 1) { $('pass-n').textContent = seat.name.toUpperCase(); $('pass').hidden = false; await new Promise((r) => { $('pass-ok').onclick = () => { $('pass').hidden = true; r(); }; }); }
$('roll').disabled = false; $('hint').textContent = `${seat.kind === 'me' ? 'Your roll' : `${seat.name}'s roll`} · hold to shake, flick up to throw`; $('hint').classList.remove('hide');
throwOpts = await new Promise((r) => { waiting = r; });
}
rec.start();
const res = await table.throw(throwOpts);
window.__ssk && (window.__ssk.rolls += 1);
if (res.off) { banner('none', 'OFF THE CURB!', 'Free re-roll', 1300); A.sting('none'); await sleep(1000); continue; }
if (res.cocked) { banner('none', 'COCKED DIE', 'Tilted against something. Free re-roll', 1400); A.sting('none'); await sleep(1100); continue; }
const sc = R.score(res.dice); seat.last = res.dice; seat.sc = R.scores(sc) ? sc : null; renderSeats();
if (!R.scores(sc)) { banner('none', 'NO SCORE', `${dz(res.dice)} · roll again`, 1100); A.sting('none'); await sleep(950); continue; }
table.celebrate(sc.kind); A.sting(sc.kind);
const big = sc.kind === '456' || sc.kind === 'trips' || sc.kind === '123';
banner(sc.kind === '456' ? '456' : sc.kind, sc.kind === '456' ? '4-5-6!' : sc.kind === '123' ? '1-2-3' : sc.kind === 'trips' ? `TRIP ${sc.value}s` : `POINT ${sc.value}`,
`${seat.name} · ${sc.kind === '456' ? 'instant win' : sc.kind === '123' ? 'instant loss' : sc.kind === 'trips' ? 'beats any point' : 'the odd die'}`, big ? 2300 : 1500);
if (seat.kind === 'bot' && big) setTimeout(() => talk(seat.name, R.line(seat.bot, 'big')), 900);
if (big && seat.kind !== 'bot') TGM.haptic(sc.kind === '123' ? 'error' : 'success');
await sleep(big ? 1500 : 900);
const c = await rec.stop(); if (c && big) { clip = { ...c, label: `${seat.name}-${sc.short}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-') }; }
return { dice: res.dice, sc };
}
return { dice: [2, 2, 1], sc: R.score([2, 2, 1]) };
}
// ------------------------------------------------------------------ seats
const ME = () => ({ id: 'me', name: S.name, kind: 'me', color: '#FFC56B' });
const botSeat = (id) => { const b = R.botById(id); return { id, name: b.name, kind: 'bot', bot: b, color: b.color, chips: 1000 }; };
const pickBots = (n) => { const ids = R.BOTS.map((b) => b.id).sort(() => Math.random() - 0.5); return ids.slice(0, n).map(botSeat); };
function settleMe(delta, sc, won) {
const before = R.rankOf(S.wallet.rep).index;
S.wallet = R.settle(S.wallet, { delta, sc, won });
S.hist.push({ d: TODAY, m: M ? M.mode : '', w: won ? 1 : won === false ? 0 : -1, c: delta, k: sc ? sc.kind : '' }); if (S.hist.length > 400) S.hist.splice(0, S.hist.length - 400);
save(); renderTop(); if (delta) A.chips(Math.min(8, 2 + Math.round(Math.abs(delta) / 25)));
const after = R.rankOf(S.wallet.rep); if (after.index > before) setTimeout(() => { banner('trips', `RANK UP`, after.name.toUpperCase(), 2400); A.sting('trips'); }, 1200);
}
const myEmote = () => (ownPack() ? (EMOTES.find((e) => e.id === S.emote) || EMOTES[0]).t : '');
// ------------------------------------------------------------------ modes
async function runDuel({ stake, opp, practice = false }) {
const me = ME(), them = opp || pickBots(1)[0];
M = { mode: practice ? 'practice' : 'duel', seats: [me, them], chipsShown: false, potLabel: () => (practice ? 'PRACTICE · no chips' : `STAKE ◉${stake} each`) };
let first = 0;
count(GAME, 'play', M.mode);
for (;;) {
M.seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; }); renderSeats();
const a = M.seats[first], b = M.seats[1 - first];
const ra = await rollFor(a);
let r = R.duel(ra.sc, null), rb = null;
if (r.winner === null) { rb = await rollFor(b); r = R.duel(ra.sc, rb.sc); }
if (r.winner === null) { banner('point', 'TIE!', 'Shoot-off', 1300); await sleep(1300); continue; }
const winner = r.winner === 0 ? a : b; winner.win = true; renderSeats();
const iWon = winner === me, mySc = a === me ? ra.sc : rb && rb.sc;
if (!practice) settleMe(iWon ? stake : -stake, mySc, iWon);
if (them.kind === 'bot') setTimeout(() => talk(them.name, R.line(them.bot, iWon ? 'lose' : 'win')), 400);
A.sting(iWon ? 'win' : 'lose');
await sleep(500);
banner(iWon ? '456' : '123', iWon ? 'YOU WIN' : `${winner.name.toUpperCase()} WINS`, practice ? '' : `${iWon ? '+' : '-'}${stake} chips`, 2000, iWon ? myEmote() : '');
await sleep(1800);
const again = await endChoice(iWon, practice ? null : stake);
if (again !== 'again') return;
if (!practice && S.wallet.chips < stake) { broke(); return; }
first = iWon ? 1 : 0; // loser of the last round shoots first
}
}
async function runTable({ stake, seats, mode = 'table', rounds = 0, daily = null }) {
M = { mode, seats, chipsShown: true, pot: 0, potLabel: () => `POT ◉${M.pot}` };
count(GAME, 'play', daily ? 'daily' : mode);
const log = [];
for (let round = 1; ; round++) {
seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; s.out = false; });
M.pot = 0; for (const s of seats) { const st = s.kind === 'me' ? stake : Math.min(stake, s.chips); s.stake = st; s.chips = (s.chips ?? 0) - (s.kind === 'me' ? 0 : st); M.pot += st; }
renderSeats(); A.chips(5);
if (daily) banner('point', `ROUND ${round}/${rounds}`, daily.label, 1200);
await sleep(daily ? 1100 : 300);
let live = [...seats], winners = null, mySc = null;
for (;;) {
const res = [];
for (const s of live) {
const r = await rollFor(s); res.push({ seat: s, score: r.sc }); if (s.kind === 'me') mySc = r.sc;
if (r.sc.kind === '456') break;
if (r.sc.kind === '123') { s.out = true; renderSeats(); }
}
const tw = R.tableWinners(res);
if (tw.winners.length === 1) { winners = tw.winners; break; }
banner('point', 'ROLL-OFF', tw.winners.map((s) => s.name).join(' vs '), 1500); await sleep(1500);
live = tw.winners; live.forEach((s) => { s.last = null; s.sc = null; }); renderSeats();
}
const w = winners[0]; w.win = true; if (w.kind !== 'me') w.chips += M.pot;
const iWon = w.kind === 'me' || (w.kind === 'local' && false);
if (seats.some((s) => s.kind === 'me')) settleMe(iWon ? M.pot - stake : -stake, mySc, iWon);
if (w.kind === 'local') { w.chips = (w.chips ?? 500) + M.pot; }
renderSeats(); A.sting(iWon ? 'win' : 'lose');
banner(iWon ? '456' : 'point', iWon ? 'YOU TAKE THE POT' : `${w.name.toUpperCase()} TAKES IT`, `◉${M.pot}`, 2000, iWon ? myEmote() : '');
if (w.kind === 'bot') setTimeout(() => talk(w.name, R.line(w.bot, 'win')), 500);
log.push({ sc: mySc, won: iWon, delta: iWon ? M.pot - stake : -stake });
await sleep(2000);
if (daily && round >= rounds) return finishDaily(daily, log);
if (daily) { if (S.wallet.chips < stake) { broke(); return; } continue; }
const again = await endChoice(iWon, stake, true);
if (again !== 'again') return;
if (seats.some((s) => s.kind === 'me') && S.wallet.chips < stake) { broke(); return; }
seats.push(seats.shift()); // the deal moves round the table
}
}
async function runBanker({ stake }) {
const me = ME(); const bots = pickBots(3); const seats = [me, ...bots];
M = { mode: 'banker', seats, chipsShown: true, bank: 1, potLabel: () => `BANK: ${seats[M.bank].name}` };
count(GAME, 'play', 'banker');
for (;;) {
seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; s.out = false; s.stake = s.kind === 'me' ? stake : R.botStake(s.bot, s.chips, 10); });
const bank = seats[M.bank]; renderSeats();
banner('point', `${bank.name.toUpperCase()} HAS THE BANK`, bank.kind === 'me' ? 'You cover every bet' : `Your stake ◉${stake}`, 1500); await sleep(1400);
const br = await rollFor(bank); let next = M.bank; let myDelta = 0, mySc = bank.kind === 'me' ? br.sc : null;
const players = seats.filter((s) => s !== bank);
if (br.sc.kind === '456' || br.sc.kind === '123') {
const o = R.bankerSettle(br.sc, null);
banner(o === 'lose' ? '456' : '123', o === 'lose' ? 'BANK TAKES ALL' : 'BANK PAYS ALL', '', 1800); await sleep(1600);
for (const p of players) { const d = o === 'win' ? p.stake : -p.stake; if (p.kind === 'me') myDelta += d; else p.chips += d; if (bank.kind === 'me') myDelta -= d; else bank.chips -= d; }
} else {
for (const p of players) {
const pr = await rollFor(p); if (p.kind === 'me') mySc = pr.sc;
const o = R.bankerSettle(br.sc, pr.sc); const d = o === 'win' ? p.stake : o === 'lose' ? -p.stake : 0;
if (p.kind === 'me') myDelta += d; else p.chips += d; if (bank.kind === 'me') myDelta -= d; else bank.chips -= d;
p.win = o === 'win'; renderSeats();
banner(o === 'win' ? 'point' : o === 'push' ? 'none' : '123', o === 'win' ? `${p.name.toUpperCase()} BEATS THE BANK` : o === 'push' ? 'PUSH' : 'BANK WINS', o === 'push' ? 'Stake back' : `◉${p.stake}`, 1300);
await sleep(1200);
if (R.takesBank(br.sc, pr.sc, o) && next === M.bank) next = seats.indexOf(p);
}
}
const won = myDelta > 0 ? true : myDelta < 0 ? false : null;
settleMe(myDelta, mySc, won);
banner(myDelta >= 0 ? '456' : '123', myDelta > 0 ? 'UP THIS ROUND' : myDelta < 0 ? 'DOWN THIS ROUND' : 'EVEN', `${myDelta >= 0 ? '+' : ''}${myDelta} chips`, 1800, won ? myEmote() : '');
await sleep(1700);
if (next !== M.bank) { toast(`${seats[next].name} beat the bank with ${seats[next].sc.short} and takes the bank.`, 2600); M.bank = next; }
else if (Math.random() < 0.3) M.bank = (M.bank + 1) % seats.length;
const again = await endChoice(won, stake, true);
if (again !== 'again') return;
if (S.wallet.chips < stake) { broke(); return; }
}
}
// pass-and-play / crew: local players only, session chips (500 each)
async function runLocal(names, crew = false) {
const seats = names.map((n, i) => ({ id: `l${i}`, name: n, kind: 'local', color: ['#FFC56B', '#2FE6FF', '#FF3D6E', '#7CF7C8', '#C9A2FF', '#FF9A3D'][i % 6], chips: 500 }));
if (crew) seats.forEach((s) => { s.color = S.crew.a; });
M = { mode: crew ? 'crew' : 'local', seats, locals: seats.length, chipsShown: true, pot: 0, potLabel: () => `POT ◉${M.pot}` };
count(GAME, 'play', M.mode);
for (;;) {
seats.forEach((s) => { s.last = null; s.sc = null; s.win = false; s.out = false; });
M.pot = 0; for (const s of seats) { s.chips -= 25; M.pot += 25; } renderSeats();
let live = [...seats], w = null;
for (;;) {
const res = []; for (const s of live) { const r = await rollFor(s); res.push({ seat: s, score: r.sc }); if (r.sc.kind === '456') break; if (r.sc.kind === '123') { s.out = true; renderSeats(); } }
const tw = R.tableWinners(res); if (tw.winners.length === 1) { w = tw.winners[0]; break; }
banner('point', 'ROLL-OFF', tw.winners.map((s) => s.name).join(' vs '), 1500); await sleep(1500); live = tw.winners;
}
w.win = true; w.chips += M.pot; renderSeats(); A.sting('win');
banner('456', `${w.name.toUpperCase()} TAKES IT`, `◉${M.pot}`, 2000);
if (crew) { S.crew.stats = S.crew.stats || {}; for (const s of seats) { const k = s.name; S.crew.stats[k] = S.crew.stats[k] || { p: 0, w: 0, best: 0 }; S.crew.stats[k].p++; if (s === w) S.crew.stats[k].w++; if (s.sc && s.sc.rank > S.crew.stats[k].best) S.crew.stats[k].best = s.sc.rank; } save(); }
await sleep(2000);
const again = await endChoice(null, null, true); if (again !== 'again') return;
seats.push(seats.shift());
}
}
// async challenge link (no server): one turn per link
async function runLinkTurn() {
const st = chal; const me = st.t; const meName = st.n[me], them = st.n[1 - me];
const seat = { id: 'me', name: meName, kind: 'me', color: '#FFC56B' }, other = { id: 'them', name: them, kind: 'remote', color: '#2FE6FF', last: st.d, sc: st.d ? R.score(st.d) : null };
M = { mode: 'link', seats: me === 0 ? [seat, other] : [other, seat], chipsShown: false, potLabel: () => `${st.n[0]} ${st.s[0]} - ${st.s[1]} ${st.n[1]} · first to ${st.w}` };
count(GAME, 'play', 'link');
if (st.d) talk(them, `I rolled ${dz(st.d)} (${R.score(st.d).short}). Beat that.`, 4000);
const r = await rollFor(seat);
const out = R.challengeTurn(st, r.dice); chal = out.st;
const code = R.encodeChallenge(chal); const url = `${SITE}${PATH}?c=${code}`;
history.replaceState(null, '', `${location.pathname}?c=${code}`);
const over = R.seriesOver(chal);
let msg;
if (out.round && out.round.tie) msg = `Tie on the point! Round ${chal.k} replays. ${them} rolls first.`;
else if (out.round) msg = `${out.round.winner === me ? 'You take' : `${them} takes`} round ${chal.k - 1}. ${chal.n[0]} ${chal.s[0]} - ${chal.s[1]} ${chal.n[1]}.`;
else msg = `You set ${r.sc.short}. Now ${them} has to beat it.`;
if (over !== null) { msg = `${chal.n[over]} wins the series ${Math.max(...chal.s)}-${Math.min(...chal.s)}!`; if (over === me) settleMe(0, r.sc, true); }
const text = over !== null ? `${msg} Rematch on Stoopshake:` : `🎲 ${meName} rolled ${dz(r.dice)} (${r.sc.short}) on Stoopshake. Your turn, ${them}. No real money, just bragging rights:`;
const shareUrl = over !== null ? `${SITE}${PATH}?c=${R.encodeChallenge({ n: [chal.n[1], chal.n[0]], s: [0, 0], k: 1, t: 0, d: null, w: chal.w })}` : url;
showEnd(`<div class="endsum"><p class="sub">CHALLENGE · FIRST TO ${chal.w}</p><div class="big">${esc(chal.n[0])} ${chal.s[0]}-${chal.s[1]} ${esc(chal.n[1])}</div><p class="sub">${esc(msg)}</p><div class="dzs">${dz(r.dice)}</div>`
+ `<p class="sub">${over !== null ? 'Send a rematch link:' : `Send this link to <b>${esc(them)}</b> so they can take their turn (anywhere, any time):`}</p></div>`,
[['📤 SEND TO ' + them.toUpperCase().slice(0, 12), () => shareLink(text, shareUrl), 'btn'], ['COPY LINK', () => copy(shareUrl), 'ghost'], ['HOME', () => { chal = null; history.replaceState(null, '', location.pathname); home(); }, 'ghost']]);
}
function shareLink(text, url) { count(GAME, 'share', 'challenge'); if (TGM.app()) { TGM.challenge(text, url); return; } share(GAME, text, url.replace(SITE, ''), null, null); }
async function copy(t) { try { await navigator.clipboard.writeText(t); toast('Link copied.'); } catch { prompt('Copy this link', t); } }
// daily table
async function runDaily() {
const d = R.dailyTable(TODAY); const seats = [ME(), ...d.lineup.map(botSeat)];
if (S.daily[TODAY]) toast('You already played today\'s table. This run is for fun and won\'t change your daily card.', 3500);
if (themeFor() !== d.scene && DT.THEMES[d.scene] && !DT.THEMES[d.scene].pack) table.setTheme(d.scene);
await runTable({ stake: Math.min(d.stake, Math.max(10, S.wallet.chips)), seats, mode: 'daily', rounds: d.rounds, daily: d });
}
function finishDaily(d, log) {
const net = log.reduce((a, x) => a + x.delta, 0); const first = !S.daily[TODAY];
const text = R.dailyCard(d.day, log, net);
if (first) { S.daily[TODAY] = { net, em: text.split('\n')[1] }; save(); count(GAME, 'done', 'daily'); }
table.setTheme(themeFor());
showEnd(`<div class="endsum"><p class="sub">${esc(d.label)}</p><div class="big">${net >= 0 ? '+' : ''}${net}</div><p class="delta ${net >= 0 ? 'up' : 'down'}">chips over ${d.rounds} rounds</p><pre class="sub" style="white-space:pre-wrap;font-size:22px">${esc(text)}</pre></div>`,
[['📤 SHARE', () => { count(GAME, 'share', 'daily'); if (TGM.app()) TGM.share(text, `${SITE}${PATH}`); else share(GAME, text, PATH, null, null); }, 'btn'], ['🏆 BOARD', () => openBoard(), 'ghost'], ['HOME', home, 'ghost']]);
}
// ------------------------------------------------------------------ round end / home / setup
function endChoice(won, stake, table_ = false) {
return new Promise((res) => {
const b = [['RUN IT BACK', () => { actions([]); res('again'); }, 'btn']];
if (clip) b.push(['🎬 SAVE CLIP', () => { download(clip.blob, `stoopshake-${clip.label}.${clip.ext}`); count(GAME, 'share', 'clip'); toast('Clip saved on this device. Post it anywhere.'); }, 'chipb']);
if (won && S.wallet.winStreak >= 2) b.push([`🔥 STREAK ${S.wallet.winStreak} CARD`, () => streakCard(), 'chipb']);
b.push(['HOME', () => { actions([]); res('home'); home(); }, 'chipb']);
actions(b); $('hint').classList.add('hide');
});
}
function broke() { M = null; renderSeats(); showEnd(`<div class="endsum"><div class="big">OUT OF CHIPS</div><p class="sub">Free chips top up every day. Come back tomorrow, or keep playing the Practice table (no chips).</p></div>`, [['PRACTICE', () => { hideAll(); runDuel({ stake: 0, practice: true }); }, 'btn'], ['HOME', home, 'ghost']]); }
function showEnd(html, btns) { hideAll(); actions([]); $('end-c').innerHTML = html + '<div class="btnrow" id="end-b"></div><p class="tiny" style="text-align:center">No real money. Just for fun.</p>'; for (const [l, f, c] of btns) { const b = document.createElement('button'); b.type = 'button'; b.className = c; b.textContent = l; b.onclick = f; $('end-b').appendChild(b); } $('end').hidden = false; }
function home() {
hideAll(); actions([]); M = null; turnSeat = null; renderSeats(); waiting = null; $('roll').disabled = true;
if (table && table.state !== 'rolling') { if (table.themeId !== themeFor()) table.setTheme(themeFor()); table.hand(0); }
const d = R.dailyTable(TODAY), done = S.daily[TODAY];
$('h-daily').textContent = `DAILY TABLE #${TODAY}${done ? ' ✓' : ''}`;
$('h-daily-s').textContent = done ? `Played: ${done.em} ${done.net >= 0 ? '+' : ''}${done.net}` : `${d.lineup.map((id) => R.botById(id).name).join(', ')} · ${d.rounds} rounds`;
$('crew-lock').hidden = ownCrew();
const hc = $('h-chal');
if (chal) { hc.hidden = false; const them = chal.n[1 - chal.t]; hc.innerHTML = `<b>${esc(them)}</b> challenged you${chal.d ? ` and rolled <b>${dz(chal.d)}</b> (${esc(R.score(chal.d).short)})` : ''}. Series ${chal.s[0]}-${chal.s[1]}, first to ${chal.w}.<br><button class="btn" id="h-go" type="button" style="margin-top:8px">🎲 TAKE YOUR TURN AS ${esc(chal.n[chal.t]).toUpperCase()}</button>`; $('h-go').onclick = () => { hideAll(); runLinkTurn(); }; }
else hc.hidden = true;
$('home').hidden = false;
}
function setup(mode) {
if (mode === 'daily') { hideAll(); runDaily(); return; }
if (mode === 'tg') { count(GAME, 'cta', 'tg-group'); window.open(`${BOT_LINK}?startgroup=dice`, '_blank', 'noopener'); return; }
if (mode === 'crew' && !ownCrew()) { openCrew('crew-mode'); return; }
const c = $('setup-c'); const stakes = R.STAKES.filter((s) => s <= Math.max(10, S.wallet.chips));
let stake = stakes.includes(25) ? 25 : stakes[0]; let n = mode === 'table' ? 4 : 2;
const draw = () => {
const stakeRow = `<span class="lbl">STAKE (free play chips · you have ◉${S.wallet.chips})</span><div class="chips-row">${stakes.map((s) => `<button class="chipb${s === stake ? ' on' : ''}" data-s="${s}" type="button">◉${s}</button>`).join('')}</div>`;
if (mode === 'duel' || mode === 'banker') c.innerHTML = `<h2 class="tag">${mode === 'duel' ? 'QUICK 1v1' : 'BANKER'}</h2>${stakeRow}<div class="btnrow"><button class="btn" id="go" type="button">🎲 PLAY</button><button class="ghost" id="back" type="button">BACK</button></div>`;
else if (mode === 'table') c.innerHTML = `<h2 class="tag">TABLE</h2><span class="lbl">SEATS (you + bots)</span><div class="chips-row">${[2, 3, 4, 5, 6].map((k) => `<button class="chipb${k === n ? ' on' : ''}" data-n="${k}" type="button">${k}</button>`).join('')}</div>${stakeRow}<div class="btnrow"><button class="btn" id="go" type="button">🎲 PLAY</button><button class="ghost" id="back" type="button">BACK</button></div>`;
else if (mode === 'local' || mode === 'crew') {
const names = mode === 'crew' ? (S.crew.members.length ? S.crew.members : [S.name, '', '']) : (S.locals.length ? S.locals : [S.name, 'Player 2']);
c.innerHTML = `<h2 class="tag">${mode === 'crew' ? esc(S.crew.name) : 'PASS & PLAY'}</h2><p class="sub">${mode === 'crew' ? 'Your saved crew. Everyone shoots on this phone; crew stats are kept.' : 'Everyone on one phone. Each round: 25 table chips in the pot.'}</p>`
+ `<div id="nm">${[0, 1, 2, 3, 4, 5].map((i) => `<input class="inp" data-i="${i}" maxlength="14" placeholder="Player ${i + 1}${i > 1 ? ' (optional)' : ''}" value="${esc(names[i] || '')}" style="margin:4px 0">`).join('')}</div>`
+ `<div class="btnrow"><button class="btn" id="go" type="button">🎲 PLAY</button><button class="ghost" id="back" type="button">BACK</button></div>`;
} else if (mode === 'link') c.innerHTML = `<h2 class="tag">CHALLENGE A FRIEND</h2><p class="sub">You roll first, then send the link. Your friend rolls on their phone and sends it back. No account, no server, first to 3.</p>`
+ `<span class="lbl">YOUR NAME</span><input class="inp" id="n1" maxlength="14" value="${esc(S.name)}"><span class="lbl">FRIEND'S NAME</span><input class="inp" id="n2" maxlength="14" placeholder="Their name"><div class="btnrow"><button class="btn" id="go" type="button">🎲 ROLL FIRST</button><button class="ghost" id="back" type="button">BACK</button></div>`;
for (const b of c.querySelectorAll('[data-s]')) b.onclick = () => { stake = Number(b.dataset.s); A.tap(); draw(); };
for (const b of c.querySelectorAll('[data-n]')) b.onclick = () => { n = Number(b.dataset.n); A.tap(); draw(); };
$('back').onclick = home;
$('go').onclick = () => {
A.ensure(); A.startMusic(); hideAll();
if (mode === 'duel') runDuel({ stake });
else if (mode === 'banker') runBanker({ stake });
else if (mode === 'table') runTable({ stake, seats: [ME(), ...pickBots(n - 1)] });
else if (mode === 'local' || mode === 'crew') {
const names = [...c.querySelectorAll('#nm input')].map((i) => R.cleanName(i.value)).filter((v, i, a) => v && v !== 'Player' || i < 2).slice(0, 6);
const list = names.length >= 2 ? names : [S.name, 'Player 2'];
if (mode === 'crew') S.crew.members = list; else S.locals = list; save();
runLocal(list, mode === 'crew');
} else if (mode === 'link') {
S.name = R.cleanName($('n1').value) || S.name; save();
chal = { n: [S.name, R.cleanName($('n2').value || 'Friend')], s: [0, 0], k: 1, t: 0, d: null, w: 3 };
runLinkTurn();
}
};
};
hideAll(); draw(); $('setup').hidden = false;
}
// ------------------------------------------------------------------ style, profile, board, shop
function openStyle() {
const c = $('style-c');
const lockP = (p) => (p === 'skins' && !ownPack()) || (p === 'crew' && !ownCrew());
const tagP = (p) => (p === 'skins' ? 'PACK' : p === 'crew' ? 'CREW' : '');
c.innerHTML = `<h2 class="tag">STYLE</h2><span class="lbl">DICE</span><div class="stylegrid">${Object.entries(DT.SKINS).map(([id, s]) => `<button class="chipb${S.skin === id ? ' on' : ''}" data-k="${id}" type="button"><span class="sw" style="background:${id === 'crew' ? S.crew.a : s.base}"></span>${esc(s.name)}${lockP(s.pack) ? `<i class="lock">${tagP(s.pack)}</i>` : ''}</button>`).join('')}</div>`
+ `<span class="lbl">TABLE</span><div class="stylegrid">${Object.entries(DT.THEMES).map(([id, t]) => `<button class="chipb${S.theme === id ? ' on' : ''}" data-t="${id}" type="button">${esc(t.name)}${lockP(t.pack) ? `<i class="lock">${tagP(t.pack)}</i>` : ''}</button>`).join('')}</div>`
+ `<span class="lbl">WIN EMOTE</span><div class="stylegrid">${EMOTES.map((e) => `<button class="chipb${S.emote === e.id ? ' on' : ''}" data-e="${e.id}" type="button">${e.t || 'None'}${e.id !== 'none' && !ownPack() ? '<i class="lock">PACK</i>' : ''}</button>`).join('')}</div>`
+ (ownCrew() ? `<span class="lbl">CREW NAME AND COLOURS</span><input class="inp" id="cn" maxlength="14" value="${esc(S.crew.name)}"><div class="chips-row" style="margin-top:6px"><input type="color" id="ca" value="${S.crew.a}"><input type="color" id="cb" value="${S.crew.b}"><button class="chipb" id="csave" type="button">SAVE CREW</button></div>` : '<p class="tiny">Crew colours, crew tables and stats history come with the <a href="#" id="s-crew">Crew Pass</a>.</p>')
+ `<p class="tiny">Looks only: every die rolls with exactly the same physics and odds.</p><div class="btnrow"><button class="btn" id="s-done" type="button">DONE</button></div>`;
for (const b of c.querySelectorAll('[data-k]')) b.onclick = () => { const s = DT.SKINS[b.dataset.k]; if (lockP(s.pack)) { s.pack === 'crew' ? openCrew('style-dice') : openPack('style-dice'); return; } S.skin = b.dataset.k; save(); table.setSkin(S.skin, S.crew); A.tap(); openStyle(); };
for (const b of c.querySelectorAll('[data-t]')) b.onclick = () => { const t = DT.THEMES[b.dataset.t]; if (lockP(t.pack)) { t.pack === 'crew' ? openCrew('style-table') : openPack('style-table'); return; } S.theme = b.dataset.t; save(); table.setTheme(S.theme); table.hand(0); A.tap(); openStyle(); };
for (const b of c.querySelectorAll('[data-e]')) b.onclick = () => { if (b.dataset.e !== 'none' && !ownPack()) { openPack('style-emote'); return; } S.emote = b.dataset.e; save(); A.tap(); openStyle(); };
if ($('csave')) $('csave').onclick = () => { S.crew.name = R.cleanName($('cn').value).toUpperCase(); S.crew.a = $('ca').value; S.crew.b = $('cb').value; save(); if (S.skin === 'crew') table.setSkin('crew', S.crew); toast('Crew saved.'); };
if ($('s-crew')) $('s-crew').onclick = (e) => { e.preventDefault(); openCrew('style'); };
$('s-done').onclick = home; hideAll(); $('style').hidden = false;
}
function openMe() {
const w = S.wallet, rk = R.rankOf(w.rep), hist = ownCrew() ? S.hist : S.hist.slice(-10);
const wr = w.played ? Math.round((w.won / w.played) * 100) : 0;
$('me-c').innerHTML = `<h2 class="tag">${esc(S.name)}</h2><p class="sub">${rk.name.toUpperCase()}${rk.next ? ` · ${rk.toNext} rep to ${rk.next.name}` : ' · top rank'}</p>`
+ `<div class="lbar" style="width:100%"><i style="width:${Math.round(rk.pct * 100)}%"></i></div>`
+ `<div class="statgrid" style="margin-top:10px"><div><b>${w.played}</b>rounds</div><div><b>${wr}%</b>won</div><div><b>${w.bestStreak || 0}</b>best streak</div><div><b>${w.big.r456}</b>4-5-6s</div><div><b>${w.big.trips}</b>trips</div><div><b>${w.best ? esc(w.best.short) : '-'}</b>best roll</div></div>`
+ `<span class="lbl">${ownCrew() ? `STATS HISTORY (last ${hist.length} rounds)` : 'LAST 10 ROUNDS (full history with the Crew Pass)'}</span><div class="hist">${hist.map((h) => `<i class="${h.w === 0 ? 'l' : ''}" style="height:${Math.min(100, 12 + Math.abs(h.c) / 2)}%"></i>`).join('')}</div>`
+ (ownCrew() && S.crew.stats ? `<span class="lbl">${esc(S.crew.name)}</span><table class="lb">${Object.entries(S.crew.stats).sort((a, b) => b[1].w - a[1].w).map(([n, s]) => `<tr><td>${esc(n)}</td><td>${s.w}/${s.p} won</td></tr>`).join('')}</table>` : '')
+ `<span class="lbl">NAME</span><input class="inp" id="me-n" maxlength="14" value="${esc(S.name)}">`
+ `<div class="btnrow"><button class="btn" id="me-card" type="button">🔥 STREAK CARD</button><button class="ghost" id="me-save" type="button">SAVE</button><button class="ghost" id="me-done" type="button">BACK</button></div>`;
$('me-save').onclick = () => { S.name = R.cleanName($('me-n').value); save(); toast('Saved.'); };
$('me-card').onclick = () => streakCard(); $('me-done').onclick = home; hideAll(); $('me').hidden = false;
}
async function streakCard() {
const c = document.createElement('canvas'); c.width = 1080; c.height = 1350; const g = c.getContext('2d'); const w = S.wallet, rk = R.rankOf(w.rep);
const bg = g.createLinearGradient(0, 0, 0, 1350); bg.addColorStop(0, '#1D1430'); bg.addColorStop(1, '#07060C'); g.fillStyle = bg; g.fillRect(0, 0, 1080, 1350);
try { g.drawImage(cv, 0, 0, cv.width, cv.height, 0, 260, 1080, 1080 * cv.height / cv.width); } catch { /* */ }
g.fillStyle = 'rgba(7,6,12,.55)'; g.fillRect(0, 0, 1080, 1350);
g.textAlign = 'center'; g.fillStyle = '#FFC56B'; g.font = '120px Spray, "Barlow Condensed", sans-serif'; g.fillText('STOOPSHAKE', 540, 190);
g.fillStyle = '#fff'; g.font = '800 110px "Barlow Condensed", sans-serif'; g.fillText(`🔥 ${w.winStreak || w.bestStreak || 0} WIN STREAK`, 540, 520);
g.font = '800 64px "Barlow Condensed", sans-serif'; g.fillStyle = '#2FE6FF'; g.fillText(`${(ownCrew() ? S.crew.name + ' · ' : '')}${S.name} · ${rk.name.toUpperCase()}`, 540, 640);
g.fillStyle = '#fff'; g.font = '800 54px "Barlow Condensed", sans-serif'; g.fillText(`Best roll: ${w.best ? w.best.short : '-'} · 4-5-6s: ${w.big.r456}`, 540, 760);
g.font = '700 46px "Barlow Condensed", sans-serif'; g.fillStyle = '#B9B1C9'; g.fillText('Free 3D street dice · No real money. Just for fun.', 540, 1170); g.fillStyle = '#FFC56B'; g.fillText('cybermaxtools.com/games/stoopshake', 540, 1250);
const blob = await new Promise((r) => c.toBlob(r, 'image/png'));
count(GAME, 'share', 'streak');
const text = `🔥 ${w.winStreak || w.bestStreak || 0} win streak on Stoopshake (${rk.name}). Come take it from me:`;
if (TGM.app()) { TGM.share(text, `${SITE}${PATH}`); return; }
share(GAME, text, PATH, blob, 'stoopshake-streak.png');
}
async function openBoard() {
const reg = R.regionOf(Intl.DateTimeFormat().resolvedOptions().timeZone, navigator.language);
const c = $('board-c'); hideAll(); $('board').hidden = false;
const top = `<h2 class="tag">BOARD</h2><p class="sub">Ranked by rep (wins and big rolls). Region comes from your device's time zone (${esc(reg.label)}), never your exact location.</p>`;
if (!S.board) {
c.innerHTML = top + `<p class="sub">Show <b>${esc(S.name)}</b> on the <b>${esc(reg.label)}</b> and world boards? Only your nickname, rank and rep are sent, once a day.</p><div class="btnrow"><button class="btn" id="bd-on" type="button">JOIN THE BOARD</button><button class="ghost" id="bd-x" type="button">NOT NOW</button></div>`;
$('bd-on').onclick = () => { S.board = true; save(); count(GAME, 'cta', 'board-join'); openBoard(); }; $('bd-x').onclick = home; return;
}
c.innerHTML = top + '<p class="sub">Loading…</p>';
let j = null;
try {
if (S.boardDay !== TODAY || S.boardRep !== S.wallet.rep) { const r = await fetch(`${DATA}/dice/board`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dev: S.dev, name: S.name, region: reg.id, label: reg.label, rep: S.wallet.rep, wins: S.wallet.won }) }); j = await r.json(); S.boardDay = TODAY; S.boardRep = S.wallet.rep; save(); }
else { const r = await fetch(`${DATA}/dice/board?region=${encodeURIComponent(reg.id)}`); j = await r.json(); }
} catch { j = null; }
if (!j || !j.world) { c.innerHTML = top + '<p class="sub">The board is resting right now. Try again in a bit.</p><div class="btnrow"><button class="ghost" id="bd-b" type="button">BACK</button></div>'; $('bd-b').onclick = home; return; }
const rows = (list) => `<table class="lb">${list.map((x, i) => `<tr class="${x.me ? 'me' : ''}"><td>${i + 1}. ${esc(x.name)} <small>${esc(x.label || '')}</small></td><td>${esc(R.rankOf(x.rep).name)} · ${x.rep}</td></tr>`).join('') || '<tr><td>Be the first.</td><td></td></tr>'}</table>`;
let tab = 'near';
const draw = () => { c.innerHTML = top + `<div class="tabs"><button class="chipb${tab === 'near' ? ' on' : ''}" data-t="near" type="button">NEAR ME</button><button class="chipb${tab === 'world' ? ' on' : ''}" data-t="world" type="button">WORLD</button><button class="chipb${tab === 'regions' ? ' on' : ''}" data-t="regions" type="button">CITIES</button></div>`
+ (tab === 'near' ? rows(j.region || []) : tab === 'world' ? rows(j.world || []) : `<table class="lb">${(j.regions || []).map((x, i) => `<tr><td>${i + 1}. ${esc(x.label)}</td><td>${x.players} players · ${x.rep}</td></tr>`).join('')}</table>`)
+ `<div class="btnrow"><button class="ghost" id="bd-off" type="button">LEAVE THE BOARD</button><button class="btn" id="bd-b" type="button">BACK</button></div>`;
for (const b of c.querySelectorAll('[data-t]')) b.onclick = () => { tab = b.dataset.t; draw(); };
$('bd-b').onclick = home; $('bd-off').onclick = () => { S.board = false; save(); fetch(`${DATA}/dice/board`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ dev: S.dev, leave: true }) }).catch(() => {}); home(); }; };
draw();
}
function openShop() {
const c = $('shop-c'); const tg = !!TGM.app();
c.innerHTML = `<h2 class="tag">EXTRAS</h2><p class="sub">The game, every mode and the daily chips are free. Extras are looks and crew features, paid once. They never change the dice.</p>`
+ `<div class="offer"><h3>Stoopshake Crew Pass <span class="pr">${tg ? '235 ⭐' : '$2.99 once'}</span></h3><ul>${CREW_PERKS.map((p) => `<li>${esc(p)}</li>`).join('')}<li>Also every Dice &amp; Table Pack look</li></ul><button class="btn pink" id="sh-c" type="button">${ownCrew() ? 'YOURS ✓' : 'GET THE CREW PASS'}</button></div>`
+ `<div class="offer"><h3>Stoopshake Dice &amp; Table Pack <span class="pr">${tg ? '155 ⭐' : '$1.99 once'}</span></h3><ul><li>Dice: Chrome, Gold, Bone, Neon</li><li>Tables: Brownstone Stoop, Rooftop, Arcade</li><li>5 win emotes</li><li>Looks only: same dice odds</li></ul><button class="btn" id="sh-p" type="button">${ownPack() ? 'YOURS ✓' : 'GET THE PACK'}</button></div>`
+ (tg ? '' : `<p class="tiny">Prices in USD; tax for your country is added at checkout. Play more than one CyberMax game? The <a href="/games/#pass">CyberMax Fun Pass</a> is a separate pass that covers every CyberMax game, both Stoopshake extras included. Chips can never be bought.</p>`)
+ `<div class="btnrow"><button class="ghost" id="sh-b" type="button">BACK</button></div>`;
$('sh-p').onclick = () => { if (!ownPack()) openPack('shop'); }; $('sh-c').onclick = () => { if (!ownCrew()) openCrew('shop'); }; $('sh-b').onclick = home;
hideAll(); $('shop').hidden = false; count(GAME, 'cta', 'shop-open');
}
// ------------------------------------------------------------------ Telegram Mini App
let tgInfo = {};
function openCrew(from) { if (TGM.app()) TGM.sheet('stoopshake-full', { perks: CREW_PERKS, onPaid: claimTG }); else FP.open(from); }
function openPack(from) { if (TGM.app()) TGM.sheet('stoopshake-skins', { perks: ['4 dice: Chrome, Gold, Bone, Neon', '3 tables: Stoop, Rooftop, Arcade', '5 win emotes', 'Looks only: same dice odds'], onPaid: claimTG }); else pack.open(from); }
async function claimTG() {
tgInfo = { ...tgInfo, ...(await TGM.status()) }; const codes = tgInfo.codes || {};
if (codes['stoopshake-full'] && !FP.ownsGame()) await FP.unlockGame(codes['stoopshake-full']);
if (codes['stoopshake-skins'] && !pack.owned()) await pack.unlock(codes['stoopshake-skins']);
renderTop();
}
FP.onChange(() => renderTop()); pack.onChange(() => renderTop());
// ------------------------------------------------------------------ wiring
for (const b of document.querySelectorAll('.mode')) b.onclick = () => { A.tap(); setup(b.dataset.m); };
$('rules-ok').onclick = () => { S.rules = true; save(); A.ensure(); A.startMusic(); hideAll(); if (chal) home(); else runDuel({ stake: Math.min(25, S.wallet.chips) || 0, practice: S.wallet.chips < 10 }); };
$('b-rules').onclick = () => { hideAll(); $('rules').hidden = false; $('rules-ok').textContent = 'BACK'; $('rules-ok').onclick = home; };
$('b-style').onclick = () => openStyle(); $('b-me').onclick = () => openMe(); $('b-board').onclick = () => openBoard();
$('b-shop').onclick = (e) => { e.preventDefault(); openShop(); };
$('b-extras').onclick = () => { A.tap(); openShop(); };
if (TGM.app()) $('b-extras').textContent = '✨ EXTRAS: CREW PASS · DICE & TABLE PACK';
$('b-menu').onclick = () => { A.tap(); if (M && !confirm('Leave this table? Chips already settled stay settled.')) return; location.hash = ''; if (M) { location.href = location.pathname + (chal ? `?c=${R.encodeChallenge(chal)}` : ''); return; } home(); };
$('b-snd').onclick = () => { A.ensure(); const on = !A.settings().music; A.setMusic(on); if (!on) A.stopMusic(); renderTop(); };
TGM.init({ onResize: () => table && table.resize(), onBack: () => home() }).then(async (W) => { if (!W) return; count(GAME, 'tg-open'); tgInfo = await TGM.hello(); claimTG(); });
boot();