first commit

This commit is contained in:
Lucas Roschnowski
2026-08-16 20:50:31 +02:00
parent 69a0e7a9f7
commit c5de95fade
8 changed files with 1748 additions and 1 deletions
+551
View File
@@ -0,0 +1,551 @@
#!/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}`);
});