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() : '' };
}