#!/usr/bin/env node /* Meyer – drinking dice game server. * Pure Node, no dependencies. SSE for live updates, POST /api for actions. */ const http = require('http'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const PORT = process.env.PORT || 3000; const PUBLIC = path.join(__dirname, 'public'); const MAX_LOG = 40; const CHALLENGE_TIMEOUT_MS = 25000; const AFK_PASS_MS = 90000; /* ---------------- Meyer rules ---------------- */ const COMBOS = [ { a: 1, b: 2, label: 'Meyer', sub: '1 + 2' }, { a: 1, b: 3, label: 'Lille-Meyer', sub: '1 + 3' }, { a: 6, b: 6, label: 'Par 6', sub: '6 + 6' }, { a: 5, b: 5, label: 'Par 5', sub: '5 + 5' }, { a: 4, b: 4, label: 'Par 4', sub: '4 + 4' }, { a: 3, b: 3, label: 'Par 3', sub: '3 + 3' }, { a: 2, b: 2, label: 'Par 2', sub: '2 + 2' }, { a: 1, b: 1, label: 'Par 1', sub: '1 + 1' }, { a: 6, b: 5, label: '6 + 5', sub: '' }, { a: 6, b: 4, label: '6 + 4', sub: '' }, ]; // dynamic combos (higher than Par 1): 63, 62, 54, 53, 52, 43, 42, 32 const DYN = [[6, 3], [6, 2], [5, 4], [5, 3], [5, 2], [4, 3], [4, 2], [3, 2]]; function rankOf(a, b) { const x = Math.min(a, b), y = Math.max(a, b); for (let i = 0; i < COMBOS.length; i++) { const c = COMBOS[i]; if (c.a === y && c.b === x) return i; } const i = DYN.findIndex(c => c[0] === y && c[1] === x); return i >= 0 ? 10 + i : -1; } function comboOf(rank) { if (rank >= 0 && rank < COMBOS.length) return COMBOS[rank]; const i = rank - 10; if (i >= 0 && i < DYN.length) { const [a, b] = DYN[i]; return { a, b, label: `${a} + ${b}`, sub: '' }; } return { a: 6, b: 6, label: '6 + 6', sub: '' }; } function comboKey(a, b) { const x = Math.min(a, b), y = Math.max(a, b); for (let i = 0; i < COMBOS.length; i++) { const c = COMBOS[i]; if (c.a === y && c.b === x) return `c${i}`; } return `d${y}_${x}`; } function labelOfCombo(c) { if (c.sub === '1 + 2') return 'Meyer'; if (c.sub === '1 + 3') return 'Lille-Meyer'; return c.label; } const d6 = () => 1 + Math.floor(Math.random() * 6); /* ---------------- state ---------------- */ const rooms = new Map(); const clients = new Map(); // uid -> Set let uidCounter = 1; function newUid() { return crypto.randomBytes(4).toString('hex') + (uidCounter++).toString(36); } function log(room, msg) { room.log.push({ t: msg, ts: Date.now() }); if (room.log.length > MAX_LOG * 2) room.log.splice(0, room.log.length - MAX_LOG); } function pName(p) { return p ? p.name : '?'; } function byUid(room, uid) { return room.players.find(p => p.uid === uid); } function nextPlayer(room, fromUid) { const i = room.players.findIndex(p => p.uid === fromUid); for (let k = 1; k <= room.players.length; k++) { const p = room.players[(i + k) % room.players.length]; if (!p.eliminated) return p; } return room.players.find(p => !p.eliminated); } function aliveCount(room) { return room.players.filter(p => !p.eliminated).length; } function makePlayer(name, isBot) { return { uid: newUid(), name, score: 6, eliminated: false, isBot: !!isBot, connected: true, lastSeen: Date.now(), hasSeenThisTurn: false, }; } function makeRoom(name, hostName, maxPlayers) { const r = { id: crypto.randomBytes(3).toString('hex').toUpperCase(), name, hostUid: null, maxPlayers: Math.max(2, Math.min(4, maxPlayers | 0)), players: [], log: [], phase: 'waiting', turn: null, current: null, lastClaim: null, pending: null, createdAt: Date.now(), lastTick: Date.now(), botStage: 0, }; rooms.set(r.id, r); return r; } /* ---------------- snapshots ---------------- */ function pubPlayer(p) { return { uid: p.uid, name: p.name, score: p.score, eliminated: p.eliminated, isBot: p.isBot, connected: p.connected, }; } function snapshot(room, forUid) { const turn = byUid(room, room.turn); const snap = { id: room.id, name: room.name, hostUid: room.hostUid, phase: room.phase, gameOver: !!room.gameOver, turnUid: room.turn, turnName: turn ? turn.name : null, maxPlayers: room.maxPlayers, players: room.players.map(pubPlayer), hasDice: !!room.current, lastClaim: room.lastClaim ? { by: room.lastClaim.by, byName: pName(byUid(room, room.lastClaim.by)), rank: room.lastClaim.rank, label: labelOfCombo(room.lastClaim), a: room.lastClaim.a, b: room.lastClaim.b } : null, pending: room.pending ? { type: 'challenge', from: room.pending.from, challenger: room.pending.challenger, ts: room.pending.ts } : null, log: room.log.slice(-MAX_LOG).map(e => ({ t: e.t, ts: e.ts })), }; // Private: only the active player, having peeks, may see the dice. if (forUid && room.current && room.turn === forUid) { const p = byUid(room, forUid); if (p && p.hasSeenThisTurn) snap.myDice = [room.current.d1, room.current.d2]; } return snap; } function broadcast(room, forUids = null) { const evs = new Map(); const uids = forUids || room.players.map(p => p.uid); for (const uid of uids) { const s = clients.get(uid); if (!s || !s.size) continue; const snap = snapshot(room, uid); evs.set(uid, JSON.stringify({ type: 'state', room: room.id, snap })); } for (const [uid, data] of evs) { for (const res of clients.get(uid) || []) res.write('data: ' + data + '\n\n'); } } /* ---------------- game flow ---------------- */ function startGame(room) { room.phase = 'acting'; room.gameOver = false; room.turn = room.players[0].uid; room.current = null; room.lastClaim = null; room.pending = null; room.botStage = 0; log(room, `🍺 Spillet begynder. ${pName(byUid(room, room.turn))} starter.`); broadcast(room); } function restartGame(room) { for (const p of room.players) { p.score = 6; p.eliminated = false; p.hasSeenThisTurn = false; } room.gameOver = false; log(room, '🔄 Nyt spil. Alle starter ved 6 øjne.'); startGame(room); } function advanceTurn(room, fromUid, note) { room.current = null; room.pending = null; // NB: lastClaim persists – the bar climbs until someone is busted room.turn = nextPlayer(room, fromUid).uid; room.phase = 'acting'; room.botStage = 0; const p = byUid(room, room.turn); if (p) p.hasSeenThisTurn = false; log(room, `${note || '➡️'} ${pName(p)}'s tur.`); if (aliveCount(room) < 2) endGame(room); broadcast(room); } function endGame(room) { room.phase = 'waiting'; room.gameOver = true; room.turn = null; room.current = null; room.lastClaim = null; room.pending = null; const alive = room.players.filter(p => !p.eliminated).sort((a, b) => b.score - a.score); if (alive.length) log(room, `🏆 Spillet er slut. ${alive[0].name} vandt med ${alive[0].score} øjne tilbage!`); else log(room, '🏆 Spillet er slut!'); broadcast(room); } function drain(room, p, label) { p.score--; if (p.score <= 0) { p.score = 0; p.eliminated = true; log(room, `💀 ${p.name} er ude af spillet – drækket ud!`); } else { log(room, `${label} ${p.name} taber ét øje – ${p.score} tilbage.`); } } function applyClaim(room, { rank, a, b }, byUidClaim) { if (room.lastClaim && rank <= room.lastClaim.rank) throw new Error('For lavt slag.'); room.lastClaim = { rank, a, b, by: byUidClaim, label: labelOfCombo(comboOf(rank)) }; room.pending = { type: 'challenge', from: byUidClaim, challenger: nextPlayer(room, byUidClaim).uid, ts: Date.now() }; room.phase = 'challenging'; room.botStage = 0; const ch = byUid(room, room.pending.challenger); log(room, `🗣️ ${pName(byUid(room, byUidClaim))} siger: ${room.lastClaim.label}`); broadcast(room); void ch; } function resolveChallenge(room, outcome) { const pend = room.pending; if (!pend) return; const claimer = byUid(room, pend.from); const challenger = byUid(room, pend.challenger); room.pending = null; if (outcome === 'pass' || outcome === 'timeout') { log(room, outcome === 'timeout' ? `⏰ Ingen svarer – ${claimer.name}'s ${room.lastClaim.label} står. Øllerne bliver ved.` : `👍 ${challenger.name} tror på ${room.lastClaim.label}. Øllerne bliver ved.`); advanceTurn(room, challenger.uid); return; } const truth = rankOf(room.current.d1, room.current.d2); const claimed = room.lastClaim.rank; if (truth < claimed) { // challenge was right – claimer drinks, the bar resets log(room, `🎲 Øllerne løftes: ${room.current.d1} + ${room.current.d2}. ${claimer.name} lyver!`); drain(room, claimer, '🍺'); room.lastClaim = null; advanceTurn(room, challenger.uid); } else { log(room, `🎲 Øllerne løftes: ${room.current.d1} + ${room.current.d2}. ${challenger.name} tar sig fejl!`); drain(room, challenger, '🍺'); advanceTurn(room, claimer.uid); } } /* ---------------- bot brain ---------------- */ function botAct(room) { const p = byUid(room, room.turn); if (!p || !p.isBot) return; if (room.phase === 'acting') { room.current = { d1: d6(), d2: d6() }; room.phase = 'peeking'; room.botStage = 1; log(room, `🎲 ${p.name} ruller under øllerne…`); broadcast(room); return; } if (room.phase === 'peeking') { p.hasSeenThisTurn = true; room.botStage = 2; log(room, `👀 ${p.name} kigger under øllerne.`); broadcast(room); // now decide what to say const truth = rankOf(room.current.d1, room.current.d2); const bar = room.lastClaim ? room.lastClaim.rank : -1; const minClaim = Math.min(bar + 1, 17); const claimable = []; for (let r = minClaim; r <= 17; r++) claimable.push(r); if (!claimable.length) { advanceTurn(room, p.uid); return; } const honest = claimable.filter(r => r >= truth); let rank; if (honest.length && Math.random() < 0.8) { rank = honest[Math.floor(Math.random() * honest.length)]; if (Math.random() < 0.25) rank = Math.min(rank + 1, 17); } else { rank = claimable[Math.floor(Math.random() * 2)]; // risky bluff } const c = comboOf(rank); try { applyClaim(room, { rank: rankOf(c.a, c.b), a: c.a, b: c.b }, p.uid); } catch (e) { advanceTurn(room, p.uid); } return; } } /* ---------------- tick ---------------- */ setInterval(() => { for (const room of rooms.values()) { // bot moves if (room.phase === 'acting' || room.phase === 'peeking') { const p = byUid(room, room.turn); if (p && p.isBot) botAct(room); } // bot as challenger if (room.phase === 'challenging' && room.pending) { const ch = byUid(room, room.pending.challenger); if (ch && ch.isBot) { const truth = rankOf(room.current.d1, room.current.d2); const claimed = room.lastClaim.rank; const shouldChallenge = truth < claimed ? Math.random() < 0.85 : Math.random() < 0.1; resolveChallenge(room, shouldChallenge ? 'challenge' : 'pass'); continue; } // timeout if (Date.now() - room.pending.ts > CHALLENGE_TIMEOUT_MS) { resolveChallenge(room, 'timeout'); continue; } } // absent humans: auto-pass if (room.phase === 'acting' || room.phase === 'peeking') { const p = byUid(room, room.turn); if (p && !p.isBot && !p.connected && Date.now() - p.lastSeen > AFK_PASS_MS) { log(room, `😴 ${p.name} svarer ikke – runderne går videre.`); advanceTurn(room, p.uid, '🔁'); } } } // cleanup empty rooms const now = Date.now(); for (const [id, r] of rooms) { if (r.players.length === 0 && now - r.lastTick > 30 * 60000) rooms.delete(id); r.lastTick = now; } }, 1500); /* ---------------- actions ---------------- */ function err(res, msg, code = 400) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: msg })); } function ok(res, obj) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); } function handleAction(action, body, res) { const name = (body.name || '').trim().slice(0, 24); switch (action) { case 'create': { if (!name) return err(res, 'Du skal have et navn.'); const roomName = (body.roomName || `${name}'s bord`).trim().slice(0, 30); const room = makeRoom(roomName || 'Meyer', null, body.maxPlayers || 4); const host = makePlayer(name); room.players.push(host); room.hostUid = host.uid; log(room, `👋 ${name} lavede bordet.`); if (body.addBot) { const bots = ['Bjørn', 'Gitte', 'Knud', 'Mette', 'Ole', 'Signe']; const b = makePlayer(bots[Math.floor(Math.random() * bots.length)], true); room.players.push(b); log(room, `🤖 ${b.name} sidder klar (robot).`); } broadcast(room); return ok(res, { uid: host.uid, room: room.id, snap: snapshot(room, host.uid) }); } case 'join': { if (!name) return err(res, 'Du skal have et navn.'); const room = rooms.get((body.roomId || '').toUpperCase()); if (!room) return err(res, 'Bordet findes ikke (længere).', 404); if (room.players.length >= room.maxPlayers) return err(res, 'Bordet er fyldt.'); const p = makePlayer(name); room.players.push(p); log(room, `👋 ${name} satte sig ved bordet.`); broadcast(room); return ok(res, { uid: p.uid, room: room.id, snap: snapshot(room, p.uid) }); } case 'rejoin': { if (!name) return err(res, 'Du skal have et navn.'); const room = rooms.get((body.roomId || '').toUpperCase()); if (!room) return ok(res, { gone: true }); let p = room.players.find(x => x.uid === body.uid); if (!p) { if (room.players.length >= room.maxPlayers) return err(res, 'Bordet er fyldt.'); p = makePlayer(name); room.players.push(p); log(room, `👋 ${name} vendte tilbage.`); } p.connected = true; p.lastSeen = Date.now(); broadcast(room); return ok(res, { uid: p.uid, room: room.id, snap: snapshot(room, p.uid) }); } case 'leave': { const room = findRoomByUid(body.uid); if (!room) return err(res, 'Ikke på bordet.'); const p = byUid(room, body.uid); log(room, `👋 ${p.name} forlod bordet.`); room.players = room.players.filter(x => x.uid !== body.uid); if (room.hostUid === body.uid && room.players[0]) room.hostUid = room.players[0].uid; if (room.phase !== 'waiting' && room.turn === body.uid) advanceTurn(room, body.uid, '🔁'); if (room.players.length === 0) { rooms.delete(room.id); break; } broadcast(room); return ok(res, {}); } case 'start': { const room = findRoomByUid(body.uid); if (!room) return err(res, 'Ikke på bordet.'); if (room.hostUid !== body.uid) return err(res, 'Kun værten kan starte.'); if (room.players.length < 2) return err(res, 'Der skal være mindst 2 spillere.'); startGame(room); return ok(res, {}); } case 'restart': { const room = findRoomByUid(body.uid); if (!room) return err(res, 'Ikke på bordet.'); if (room.hostUid !== body.uid) return err(res, 'Kun værten kan starte et nyt spil.'); restartGame(room); return ok(res, {}); } case 'roll': { const room = findRoomByUid(body.uid); if (!room) return err(res, 'Ikke på bordet.'); if (room.phase !== 'acting' || room.turn !== body.uid) return err(res, 'Ikke din tur.'); room.current = { d1: d6(), d2: d6() }; room.phase = 'peeking'; room.botStage = 0; log(room, `🎲 ${pName(byUid(room, body.uid))} ruller under øllerne…`); broadcast(room); return ok(res, {}); } case 'peek': { const room = findRoomByUid(body.uid); if (!room) return err(res, 'Ikke på bordet.'); if (room.phase !== 'peeking' || room.turn !== body.uid) return err(res, 'Ikke din tur.'); const p = byUid(room, body.uid); p.hasSeenThisTurn = true; room.botStage = 2; if (body.revealed) log(room, `👀 ${p.name} kigger under øllerne.`); broadcast(room, room.players.map(x => x.uid)); return ok(res, {}); } case 'declare': { const room = findRoomByUid(body.uid); if (!room) return err(res, 'Ikke på bordet.'); if (room.phase !== 'peeking' || room.turn !== body.uid) return err(res, 'Ikke din tur.'); const rank = Number(body.rank) | 0; const c = comboOf(rank); const truth = rankOf(room.current.d1, room.current.d2); if (rank < truth) return err(res, 'Du kan ikke sige noget lavere end hvad du har.'); if (room.lastClaim && rank <= room.lastClaim.rank) return err(res, 'Det skal være højere end det forrige slag.'); try { applyClaim(room, { rank, a: c.a, b: c.b }, body.uid); } catch (e) { return err(res, e.message); } return ok(res, {}); } case 'reroll': { // you saw a weak roll: roll again and send forward without looking const room = findRoomByUid(body.uid); if (!room) return err(res, 'Ikke på bordet.'); if (room.phase !== 'peeking' || room.turn !== body.uid) return err(res, 'Ikke din tur.'); room.current = { d1: d6(), d2: d6() }; const p = byUid(room, body.uid); p.hasSeenThisTurn = false; log(room, `🔁 ${p.name} ruller igen og sender øllerne videre uden at kigge.`); advanceTurn(room, body.uid); return ok(res, {}); } case 'sendForward': { // pass your cups along without claiming anything (silent bluff risk) const room = findRoomByUid(body.uid); if (!room) return err(res, 'Ikke på bordet.'); if (room.phase !== 'peeking' || room.turn !== body.uid) return err(res, 'Ikke din tur.'); log(room, `📤 ${pName(byUid(room, body.uid))} sender øllerne videre uden at sige noget.`); advanceTurn(room, body.uid); return ok(res, {}); } case 'pass': { // challenger trusts the claim const room = findRoomByUid(body.uid); if (!room) return err(res, 'Ikke på bordet.'); if (room.phase !== 'challenging' || !room.pending || room.pending.challenger !== body.uid) return err(res, 'Det er ikke dig, der skal svare nu.'); resolveChallenge(room, 'pass'); return ok(res, {}); } case 'challenge': { const room = findRoomByUid(body.uid); if (!room) return err(res, 'Ikke på bordet.'); if (room.phase !== 'challenging' || !room.pending || room.pending.challenger !== body.uid) return err(res, 'Det er ikke dig, der skal løfte øllerne.'); log(room, `🫙 ${pName(byUid(room, body.uid))} løfter øllerne…`); resolveChallenge(room, 'challenge'); return ok(res, {}); } case 'chat': { const room = findRoomByUid(body.uid); if (!room) return err(res, 'Ikke på bordet.'); const p = byUid(room, body.uid); const text = (body.text || '').trim().slice(0, 200); if (!text) return ok(res, {}); const msg = { type: 'chat', room: room.id, from: p.name, text, ts: Date.now() }; const data = JSON.stringify(msg); for (const uid of room.players.map(x => x.uid)) for (const r of clients.get(uid) || []) r.write('data: ' + data + '\n\n'); return ok(res, {}); } default: return err(res, 'Ukendt action.'); } } function findRoomByUid(uid) { for (const r of rooms.values()) if (r.players.some(p => p.uid === uid)) return r; return null; } /* ---------------- HTTP ---------------- */ const server = http.createServer((req, res) => { const url = new URL(req.url, 'http://localhost'); if (req.method === 'GET' && url.pathname === '/api/rooms') { const list = []; for (const r of rooms.values()) { list.push({ id: r.id, name: r.name, phase: r.phase, players: r.players.map(pubPlayer), maxPlayers: r.maxPlayers, }); } return ok(res, { rooms: list }); } if (req.method === 'GET' && url.pathname === '/api/state') { const room = rooms.get((url.searchParams.get('room') || '').toUpperCase()); const uid = url.searchParams.get('uid') || ''; if (!room) return ok(res, { gone: true }); if (uid && byUid(room, uid)) { const p = byUid(room, uid); p.connected = true; p.lastSeen = Date.now(); return ok(res, { snap: snapshot(room, uid) }); } return ok(res, { snap: snapshot(room, null) }); } if (req.method === 'GET' && url.pathname === '/api/events') { const uid = url.searchParams.get('uid') || ''; res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); res.write('retry: 2000\n\n'); let set = clients.get(uid); if (!set) { set = new Set(); clients.set(uid, set); } set.add(res); const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch (e) {} }, 15000); const hb = setInterval(() => { const room = findRoomByUid(uid); if (room) { const p = byUid(room, uid); if (p) { p.lastSeen = Date.now(); } broadcast(room, [uid]); } }, 2500); req.on('close', () => { clearInterval(ping); clearInterval(hb); set.delete(res); if (set.size === 0) clients.delete(uid); const room = findRoomByUid(uid); if (room) { const p = byUid(room, uid); if (p) { p.connected = false; p.lastSeen = Date.now(); } broadcast(room); } }); return; } if (req.method === 'POST' && url.pathname === '/api') { let data = ''; req.on('data', c => { data += c; if (data.length > 1e5) req.destroy(); }); req.on('end', () => { let body; try { body = JSON.parse(data || '{}'); } catch (e) { return err(res, 'Ugyldig JSON.'); } try { handleAction(body.action, body, res); } catch (e) { console.error(e); err(res, 'Serverfejl.'); } }); return; } // static let p = url.pathname === '/' ? '/index.html' : url.pathname; p = path.normalize(p).replace(/^([.][.][/\\])+/, ''); const file = path.join(PUBLIC, p); if (!file.startsWith(PUBLIC)) { res.writeHead(403); return res.end(); } fs.readFile(file, (e, buf) => { if (e) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('404'); } const ext = path.extname(file); const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.svg': 'image/svg+xml' }; res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' }); res.end(buf); }); }); server.listen(PORT, '0.0.0.0', () => { console.log(`Meyer server running on http://0.0.0.0:${PORT}`); });