552 lines
22 KiB
JavaScript
552 lines
22 KiB
JavaScript
#!/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}`);
|
||
});
|