diff --git a/db.ts b/db.ts index 6555a05..5ad3c31 100644 --- a/db.ts +++ b/db.ts @@ -4,6 +4,8 @@ import type { RoundState } from "./game.ts"; const dbPath = process.env.DATABASE_PATH ?? "quipslop.sqlite"; export const db = new Database(dbPath, { create: true }); +db.exec("PRAGMA foreign_keys = ON;"); + db.exec(` CREATE TABLE IF NOT EXISTS rounds ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -41,3 +43,140 @@ export function clearAllRounds() { db.exec("DELETE FROM rounds;"); db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';"); } + +// ── Betting tables ────────────────────────────────────────────────────────── + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + nickname TEXT UNIQUE NOT NULL, + balance INTEGER DEFAULT 1000, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS bets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id), + round_num INTEGER NOT NULL, + contestant TEXT NOT NULL, + amount INTEGER NOT NULL, + won INTEGER, + payout INTEGER DEFAULT 0, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, round_num) + ); +`); + +// ── Betting functions ─────────────────────────────────────────────────────── + +export function createUser(id: string, nickname: string) { + const stmt = db.prepare("INSERT INTO users (id, nickname) VALUES ($id, $nickname)"); + stmt.run({ $id: id, $nickname: nickname }); + return { id, nickname, balance: 1000 }; +} + +export function getUser(id: string) { + return db.query("SELECT id, nickname, balance FROM users WHERE id = $id").get({ $id: id }) as { id: string; nickname: string; balance: number } | null; +} + +export function placeBet(userId: string, roundNum: number, contestant: string, amount: number) { + if (amount <= 0) throw new Error("Amount must be positive"); + + const user = getUser(userId); + if (!user) throw new Error("User not found"); + if (amount > user.balance) throw new Error("Insufficient balance"); + + db.exec("BEGIN"); + try { + // Duplicate check inside transaction (UNIQUE constraint is also enforced) + const existing = db.query("SELECT id FROM bets WHERE user_id = $userId AND round_num = $roundNum").get({ $userId: userId, $roundNum: roundNum }); + if (existing) { + db.exec("ROLLBACK"); + throw new Error("Already bet this round"); + } + + db.prepare("INSERT INTO bets (user_id, round_num, contestant, amount) VALUES ($userId, $roundNum, $contestant, $amount)") + .run({ $userId: userId, $roundNum: roundNum, $contestant: contestant, $amount: amount }); + + // Conditional UPDATE — defensive against concurrent balance changes + const result = db.prepare("UPDATE users SET balance = balance - $amount WHERE id = $userId AND balance >= $amount") + .run({ $amount: amount, $userId: userId }); + if (result.changes === 0) { + db.exec("ROLLBACK"); + throw new Error("Insufficient balance"); + } + + db.exec("COMMIT"); + } catch (e) { + try { db.exec("ROLLBACK"); } catch {} + throw e; + } + + return { + bet: { userId, roundNum, contestant, amount }, + balance: user.balance - amount, + }; +} + +export function resolveBets(roundNum: number, winnerName: string | null) { + const bets = db.query("SELECT id, user_id, contestant, amount FROM bets WHERE round_num = $roundNum AND won IS NULL") + .all({ $roundNum: roundNum }) as { id: number; user_id: string; contestant: string; amount: number }[]; + + if (bets.length === 0) return; + + db.exec("BEGIN"); + try { + for (const bet of bets) { + if (winnerName === null) { + // Tie — refund (won = -1 distinguishes from loss) + db.prepare("UPDATE bets SET won = -1, payout = $amount WHERE id = $id") + .run({ $amount: bet.amount, $id: bet.id }); + db.prepare("UPDATE users SET balance = balance + $amount WHERE id = $userId") + .run({ $amount: bet.amount, $userId: bet.user_id }); + } else if (bet.contestant === winnerName) { + const payout = bet.amount * 2; + db.prepare("UPDATE bets SET won = 1, payout = $payout WHERE id = $id") + .run({ $payout: payout, $id: bet.id }); + db.prepare("UPDATE users SET balance = balance + $payout WHERE id = $userId") + .run({ $payout: payout, $userId: bet.user_id }); + } else { + db.prepare("UPDATE bets SET won = 0, payout = 0 WHERE id = $id") + .run({ $id: bet.id }); + } + } + db.exec("COMMIT"); + } catch (e) { + db.exec("ROLLBACK"); + throw e; + } +} + +export function getLeaderboard(limit = 10) { + return db.query("SELECT id, nickname, balance FROM users ORDER BY balance DESC LIMIT $limit") + .all({ $limit: limit }) as { id: string; nickname: string; balance: number }[]; +} + +export function getBetsForRound(roundNum: number) { + const rows = db.query( + "SELECT contestant, COUNT(*) as count, SUM(amount) as total FROM bets WHERE round_num = $roundNum GROUP BY contestant" + ).all({ $roundNum: roundNum }) as { contestant: string; count: number; total: number }[]; + + const result: Record = {}; + for (const row of rows) { + result[row.contestant] = { count: row.count, total: row.total }; + } + return result; +} + +export function getUserBetForRound(userId: string, roundNum: number) { + return db.query("SELECT contestant, amount, won, payout FROM bets WHERE user_id = $userId AND round_num = $roundNum") + .get({ $userId: userId, $roundNum: roundNum }) as { contestant: string; amount: number; won: number | null; payout: number } | null; +} + +export function clearAllBets() { + db.exec("DELETE FROM bets;"); + db.exec("DELETE FROM users;"); + db.exec("DELETE FROM sqlite_sequence WHERE name = 'bets';"); +} diff --git a/frontend.css b/frontend.css index ed778c3..c535886 100644 --- a/frontend.css +++ b/frontend.css @@ -590,6 +590,377 @@ body { ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +/* ── Sidebar ─────────────────────────────────────────────────── */ + +.sidebar { + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +/* ── Nickname Modal ──────────────────────────────────────────── */ + +.nickname-modal { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: fade-in 0.2s ease; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.nickname-modal__card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 32px; + max-width: 360px; + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; +} + +.nickname-modal__title { + font-family: var(--serif); + font-size: 24px; + color: var(--text); +} + +.nickname-modal__sub { + font-family: var(--mono); + font-size: 12px; + color: var(--text-muted); +} + +.nickname-modal__input { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px 14px; + color: var(--text); + font-family: var(--mono); + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +.nickname-modal__input:focus { + border-color: var(--accent); +} + +.nickname-modal__error { + font-family: var(--mono); + font-size: 12px; + color: #ef4444; +} + +.nickname-modal__btn { + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 6px; + padding: 10px 20px; + font-family: var(--mono); + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + transition: opacity 0.2s; +} + +.nickname-modal__btn:hover { opacity: 0.9; } +.nickname-modal__btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ── Join Button ─────────────────────────────────────────────── */ + +.join-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--bg); + background: var(--accent); + border: none; + border-radius: 999px; + padding: 6px 14px; + cursor: pointer; + transition: opacity 0.2s; + white-space: nowrap; +} + +.join-btn:hover { opacity: 0.9; } + +/* ── User Balance ────────────────────────────────────────────── */ + +.user-balance { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.4px; + text-transform: uppercase; + border: 1px solid var(--accent); + background: rgba(217, 119, 87, 0.08); + border-radius: 999px; + padding: 6px 10px; + white-space: nowrap; +} + +.user-balance__coins { + font-weight: 700; + color: var(--accent); +} + +.user-balance__label { + color: var(--text-muted); +} + +/* ── Betting Panel ───────────────────────────────────────────── */ + +.betting-panel { + margin-top: 24px; + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 12px; + background: var(--surface); + max-width: 900px; + width: 100%; + flex-shrink: 0; +} + +.betting-panel--placed { + background: rgba(217, 119, 87, 0.05); + border-color: rgba(217, 119, 87, 0.2); +} + +.betting-panel__title { + font-family: var(--mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-dim); +} + +.betting-panel__placed { + font-family: var(--mono); + font-size: 13px; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.betting-panel__placed strong { + color: var(--accent); +} + +.betting-panel__pools { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.betting-panel__pool { + display: flex; + align-items: center; + gap: 6px; +} + +.betting-panel__pool-stat { + font-family: var(--mono); + font-size: 11px; + color: var(--text-muted); +} + +.betting-panel__options { + display: flex; + gap: 10px; +} + +.bet-option { + flex: 1; + border: 1px solid var(--border); + background: transparent; + border-radius: 6px; + padding: 12px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 6px; + align-items: center; + transition: all 0.2s; + color: var(--text); +} + +.bet-option:hover { + border-color: var(--accent); + background: rgba(255, 255, 255, 0.02); +} + +.bet-option--selected { + border-color: var(--accent); + background: rgba(217, 119, 87, 0.1); +} + +.bet-option__pool { + font-family: var(--mono); + font-size: 10px; + color: var(--text-muted); +} + +.bet-input { + display: flex; + align-items: center; + gap: 10px; +} + +.bet-input__presets { + display: flex; + gap: 6px; +} + +.bet-input__preset { + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 10px; + font-family: var(--mono); + font-size: 11px; + color: var(--text-dim); + cursor: pointer; + transition: all 0.2s; +} + +.bet-input__preset:hover { + border-color: var(--text-muted); + color: var(--text); +} + +.bet-input__preset--active { + border-color: var(--accent); + color: var(--accent); + background: rgba(217, 119, 87, 0.1); +} + +.bet-input__field { + width: 80px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + font-family: var(--mono); + font-size: 13px; + color: var(--text); + outline: none; + text-align: center; +} + +.bet-input__field:focus { + border-color: var(--accent); +} + +.betting-panel__error { + font-family: var(--mono); + font-size: 12px; + color: #ef4444; +} + +.betting-panel__confirm { + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 6px; + padding: 10px 20px; + font-family: var(--mono); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + transition: opacity 0.2s; + align-self: flex-start; +} + +.betting-panel__confirm:hover { opacity: 0.9; } +.betting-panel__confirm:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ── Leaderboard ─────────────────────────────────────────────── */ + +.leaderboard { + border-top: 1px solid var(--border); + background: var(--surface); + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 8px; + flex-shrink: 0; +} + +.leaderboard__title { + font-family: var(--mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-dim); +} + +.leaderboard__list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.leaderboard__row { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0; + font-family: var(--mono); + font-size: 12px; +} + +.leaderboard__rank { + width: 18px; + text-align: center; + color: var(--text-muted); + flex-shrink: 0; +} + +.leaderboard__name { + flex: 1; + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.leaderboard__bal { + color: var(--accent); + font-weight: 700; + flex-shrink: 0; +} + /* ── Desktop (1024px+) ───────────────────────────────────────── */ @media (min-width: 1024px) { @@ -636,12 +1007,21 @@ body { .contestant { flex: 1; } - .standings { + .sidebar { width: 280px; - border-top: none; border-left: 1px solid var(--border); - max-height: none; overflow-y: auto; + } + + .standings { + border-top: none; + max-height: none; + overflow-y: visible; padding: 24px; } + + .leaderboard { + border-top: 1px solid var(--border); + padding: 16px 24px; + } } diff --git a/frontend.tsx b/frontend.tsx index 9357f83..165778b 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -39,12 +39,18 @@ type GameState = { isPaused: boolean; generation: number; }; +type BetState = { + roundNum: number; + open: boolean; + totals: Record; +}; type StateMessage = { type: "state"; data: GameState; totalRounds: number; viewerCount: number; version?: string; + betState: BetState | null; }; type ViewerCountMessage = { type: "viewerCount"; @@ -109,6 +115,271 @@ function ModelTag({ model, small }: { model: Model; small?: boolean }) { ); } +// ── Nickname Modal ────────────────────────────────────────────────────────── + +function NicknameModal({ onJoin }: { onJoin: (id: string, nickname: string) => void }) { + const [nickname, setNickname] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = nickname.trim(); + if (!trimmed || trimmed.length > 20) { + setError("1-20 characters"); + return; + } + setLoading(true); + setError(""); + const id = crypto.randomUUID(); + try { + const res = await fetch("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, nickname: trimmed }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || "Failed"); + setLoading(false); + return; + } + localStorage.setItem("qs_userId", data.user.id); + localStorage.setItem("qs_nickname", data.user.nickname); + localStorage.setItem("qs_balance", String(data.user.balance)); + onJoin(data.user.id, data.user.nickname); + } catch { + setError("Network error"); + setLoading(false); + } + }; + + return ( +
+
+
Join the Betting Pool
+
Pick a nickname to start betting with 1,000 coins
+ setNickname(e.target.value)} + maxLength={20} + autoFocus + /> + {error &&
{error}
} + +
+
+ ); +} + +// ── Betting Panel ──────────────────────────────────────────────────────────── + +function BettingPanel({ + round, + betState, + userId, + balance, + onBalanceChange, +}: { + round: RoundState; + betState: BetState | null; + userId: string; + balance: number; + onBalanceChange: (b: number) => void; +}) { + const [selected, setSelected] = useState(null); + const [amount, setAmount] = useState(50); + const [myBet, setMyBet] = useState<{ contestant: string; amount: number } | null>(null); + const [error, setError] = useState(""); + const [placing, setPlacing] = useState(false); + const lastRoundRef = React.useRef(0); + + // Reset when round changes + useEffect(() => { + if (round.num !== lastRoundRef.current) { + lastRoundRef.current = round.num; + setMyBet(null); + setSelected(null); + setError(""); + // Check if we already bet this round + if (userId) { + fetch(`/api/me?id=${encodeURIComponent(userId)}`) + .then(r => r.json()) + .then(data => { + if (data.currentBet) { + setMyBet({ contestant: data.currentBet.contestant, amount: data.currentBet.amount }); + } + if (data.user) { + onBalanceChange(data.user.balance); + } + }) + .catch(() => {}); + } + } + }, [round.num, userId, onBalanceChange]); + + const isOpen = betState?.open && round.num === betState.roundNum; + const [contA, contB] = round.contestants; + + if (myBet) { + return ( +
+
+ Your bet: {myBet.amount} coins on{" "} + +
+ {betState && ( +
+ {[contA, contB].map(c => { + const t = betState.totals[c.name]; + return ( +
+ + + {t ? `${t.count} bet${t.count !== 1 ? "s" : ""} · ${t.total} coins` : "No bets"} + +
+ ); + })} +
+ )} +
+ ); + } + + if (!isOpen) return null; + + const handlePlace = async () => { + if (!selected || amount <= 0) return; + const placedForRound = round.num; + setPlacing(true); + setError(""); + try { + const res = await fetch("/api/bet", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, roundNum: placedForRound, contestant: selected, amount }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || "Failed"); + setPlacing(false); + return; + } + // Only update if we're still on the same round + if (lastRoundRef.current === placedForRound) { + setMyBet({ contestant: selected, amount }); + onBalanceChange(data.balance); + localStorage.setItem("qs_balance", String(data.balance)); + } + } catch { + setError("Network error"); + } + setPlacing(false); + }; + + return ( +
+
Place Your Bet
+
+ {[contA, contB].map(c => ( + + ))} +
+
+
+ {[10, 50, 100].map(v => ( + + ))} + +
+ setAmount(Math.max(1, Math.min(balance, parseInt(e.target.value) || 0)))} + /> +
+ {error &&
{error}
} + +
+ ); +} + +// ── Leaderboard ────────────────────────────────────────────────────────────── + +function LeaderboardPanel() { + const [board, setBoard] = useState<{ id: string; nickname: string; balance: number }[]>([]); + + useEffect(() => { + const load = () => { + fetch("/api/leaderboard") + .then(r => r.json()) + .then(data => { if (Array.isArray(data)) setBoard(data); }) + .catch(() => {}); + }; + load(); + const interval = setInterval(load, 15_000); + return () => clearInterval(interval); + }, []); + + if (board.length === 0) return null; + + return ( +
+
Top Bettors
+
+ {board.map((u, i) => ( +
+ {i + 1} + {u.nickname} + {u.balance} +
+ ))} +
+
+ ); +} + // ── Prompt ─────────────────────────────────────────────────────────────────── function PromptCard({ round }: { round: RoundState }) { @@ -412,6 +683,40 @@ function App() { const [totalRounds, setTotalRounds] = useState(null); const [viewerCount, setViewerCount] = useState(0); const [connected, setConnected] = useState(false); + const [betState, setBetState] = useState(null); + + // User identity + const [userId, setUserId] = useState(() => localStorage.getItem("qs_userId") || ""); + const [nickname, setNickname] = useState(() => localStorage.getItem("qs_nickname") || ""); + const [balance, setBalance] = useState(() => parseInt(localStorage.getItem("qs_balance") || "0", 10)); + const [showNicknameModal, setShowNicknameModal] = useState(false); + + // Sync user on first load + useEffect(() => { + if (userId) { + fetch(`/api/me?id=${encodeURIComponent(userId)}`) + .then(r => { + if (!r.ok) { + // User was deleted (e.g. admin reset) + localStorage.removeItem("qs_userId"); + localStorage.removeItem("qs_nickname"); + localStorage.removeItem("qs_balance"); + setUserId(""); + setNickname(""); + setBalance(0); + return null; + } + return r.json(); + }) + .then(data => { + if (data?.user) { + setBalance(data.user.balance); + localStorage.setItem("qs_balance", String(data.user.balance)); + } + }) + .catch(() => {}); + } + }, [userId]); useEffect(() => { const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; @@ -437,6 +742,7 @@ function App() { setState(msg.data); setTotalRounds(msg.totalRounds); setViewerCount(msg.viewerCount); + setBetState(msg.betState); } else if (msg.type === "viewerCount") { setViewerCount(msg.viewerCount); } @@ -457,8 +763,18 @@ function App() { const displayRound = isNextPrompting && state.lastCompleted ? state.lastCompleted : state.active; + const handleJoin = (id: string, nick: string) => { + setUserId(id); + setNickname(nick); + setBalance(1000); + setShowNicknameModal(false); + }; + + const bettingRound = state.active; + return (
+ {showNicknameModal && }
@@ -474,6 +790,16 @@ function App() { Paused
)} + {userId ? ( +
+ {balance} + coins +
+ ) : ( + + )}
{viewerCount} viewer{viewerCount === 1 ? "" : "s"} watching @@ -484,7 +810,21 @@ function App() { {state.done ? ( ) : displayRound ? ( - + <> + + {userId && bettingRound && ( + { + setBalance(b); + localStorage.setItem("qs_balance", String(b)); + }} + /> + )} + ) : (
Starting @@ -501,7 +841,10 @@ function App() { )} - +
+ + +
); diff --git a/game.ts b/game.ts index 87d10b7..a06ca6b 100644 --- a/game.ts +++ b/game.ts @@ -260,7 +260,7 @@ export async function callVote( return cleaned.startsWith("A") ? "A" : "B"; } -import { saveRound } from "./db.ts"; +import { saveRound, resolveBets } from "./db.ts"; // ── Game loop ─────────────────────────────────────────────────────────────── @@ -453,8 +453,18 @@ export async function runGame( round.phase = "done"; if (votesA > votesB) { state.scores[contA.name] = (state.scores[contA.name] || 0) + 1; + try { resolveBets(r, contA.name); } catch (e) { + log("ERROR", "betting", `Failed to resolve bets for round ${r}`, { error: e instanceof Error ? e.message : String(e) }); + } } else if (votesB > votesA) { state.scores[contB.name] = (state.scores[contB.name] || 0) + 1; + try { resolveBets(r, contB.name); } catch (e) { + log("ERROR", "betting", `Failed to resolve bets for round ${r}`, { error: e instanceof Error ? e.message : String(e) }); + } + } else { + try { resolveBets(r, null); } catch (e) { + log("ERROR", "betting", `Failed to refund bets for round ${r}`, { error: e instanceof Error ? e.message : String(e) }); + } } rerender(); diff --git a/server.ts b/server.ts index e7e02c7..7511705 100644 --- a/server.ts +++ b/server.ts @@ -4,7 +4,18 @@ import indexHtml from "./index.html"; import historyHtml from "./history.html"; import adminHtml from "./admin.html"; import broadcastHtml from "./broadcast.html"; -import { clearAllRounds, getRounds, getAllRounds } from "./db.ts"; +import { + clearAllRounds, + getRounds, + getAllRounds, + createUser, + getUser, + placeBet, + getLeaderboard, + getBetsForRound, + getUserBetForRound, + clearAllBets, +} from "./db.ts"; import { MODELS, LOG_FILE, @@ -228,13 +239,35 @@ function getClientState() { }; } +let betTotalsCache: { roundNum: number; totals: Record } | null = null; + +function invalidateBetCache() { + betTotalsCache = null; +} + function broadcast() { + let betState: { roundNum: number; open: boolean; totals: Record } | null = null; + const active = gameState.active; + if (active) { + const open = active.phase === "prompting" || active.phase === "answering"; + // Use cached totals if same round, otherwise refresh + if (!betTotalsCache || betTotalsCache.roundNum !== active.num) { + betTotalsCache = { roundNum: active.num, totals: getBetsForRound(active.num) }; + } + betState = { + roundNum: active.num, + open, + totals: betTotalsCache.totals, + }; + } + const msg = JSON.stringify({ type: "state", data: getClientState(), totalRounds: runs, viewerCount: clients.size, version: VERSION, + betState, }); for (const ws of clients) { ws.send(msg); @@ -423,6 +456,8 @@ const server = Bun.serve({ } clearAllRounds(); + clearAllBets(); + invalidateBetCache(); historyCache.clear(); gameState.completed = []; gameState.active = null; @@ -526,6 +561,134 @@ const server = Bun.serve({ }); } + // ── Betting endpoints ─────────────────────────────────────────────────── + + if (url.pathname === "/api/register") { + if (req.method !== "POST") { + return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } }); + } + if (isRateLimited(`register:${ip}`, 5, WINDOW_MS)) { + return new Response("Too Many Requests", { status: 429 }); + } + + let id = "", nickname = ""; + try { + const body = await req.json(); + id = String((body as Record).id ?? "").trim(); + nickname = String((body as Record).nickname ?? "").trim() + .normalize("NFKC") + .replace(/[\x00-\x1F\x7F\u200B-\u200F\u2028-\u202F\uFEFF]/g, ""); // strip control/zero-width chars + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + if (!id || !nickname) { + return new Response(JSON.stringify({ error: "id and nickname required" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + if (nickname.length < 1 || nickname.length > 20) { + return new Response(JSON.stringify({ error: "Nickname must be 1-20 characters" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + try { + const user = createUser(id, nickname); + return new Response(JSON.stringify({ ok: true, user }), { status: 200, headers: { "Content-Type": "application/json" } }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("UNIQUE")) { + if (msg.includes("users.id")) { + // Same client re-registering — return their existing record + const existing = getUser(id); + if (existing) { + return new Response(JSON.stringify({ ok: true, user: existing }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + } + return new Response(JSON.stringify({ error: "Nickname taken" }), { status: 409, headers: { "Content-Type": "application/json" } }); + } + return new Response(JSON.stringify({ error: msg }), { status: 500, headers: { "Content-Type": "application/json" } }); + } + } + + if (url.pathname === "/api/bet") { + if (req.method !== "POST") { + return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } }); + } + if (isRateLimited(`bet:${ip}`, 30, WINDOW_MS)) { + return new Response("Too Many Requests", { status: 429 }); + } + + let userId = "", roundNum = 0, contestant = "", amount = 0; + try { + const body = await req.json(); + const b = body as Record; + userId = String(b.userId ?? ""); + roundNum = Number(b.roundNum ?? 0); + contestant = String(b.contestant ?? ""); + amount = Number(b.amount ?? 0); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + if (!Number.isFinite(amount) || !Number.isInteger(amount) || amount <= 0) { + return new Response(JSON.stringify({ error: "Invalid amount" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + // Validate round is active and betting is open + const active = gameState.active; + if (!active || active.num !== roundNum) { + return new Response(JSON.stringify({ error: "Round not active" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + if (active.phase !== "prompting" && active.phase !== "answering") { + return new Response(JSON.stringify({ error: "Betting closed" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + // Validate contestant is in this round + const validContestants: string[] = [active.contestants[0].name, active.contestants[1].name]; + if (!validContestants.includes(contestant)) { + return new Response(JSON.stringify({ error: "Invalid contestant" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + try { + const result = placeBet(userId, roundNum, contestant, amount); + invalidateBetCache(); + broadcast(); // update bet totals for all viewers + return new Response(JSON.stringify({ ok: true, bet: result.bet, balance: result.balance }), { status: 200, headers: { "Content-Type": "application/json" } }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return new Response(JSON.stringify({ error: msg }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + } + + if (url.pathname === "/api/leaderboard") { + if (isRateLimited(`leaderboard:${ip}`, 30, WINDOW_MS)) { + return new Response("Too Many Requests", { status: 429 }); + } + const board = getLeaderboard(10); + return new Response(JSON.stringify(board), { + status: 200, + headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=5" }, + }); + } + + if (url.pathname === "/api/me") { + if (isRateLimited(`me:${ip}`, 30, WINDOW_MS)) { + return new Response("Too Many Requests", { status: 429 }); + } + const userId = url.searchParams.get("id") ?? ""; + if (!userId) { + return new Response(JSON.stringify({ error: "id required" }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + const user = getUser(userId); + if (!user) { + return new Response(JSON.stringify({ error: "User not found" }), { status: 404, headers: { "Content-Type": "application/json" } }); + } + const activeRound = gameState.active; + const currentBet = activeRound ? getUserBetForRound(userId, activeRound.num) : null; + return new Response(JSON.stringify({ user, currentBet }), { + status: 200, + headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, + }); + } + if (url.pathname === "/ws") { if (req.method !== "GET") { return new Response("Method Not Allowed", {