Files
2026-08-16 20:50:31 +02:00

552 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<SSE res>
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}`);
});