/* Meyer – client. SSE state sync + actions. */
"use strict";
/* ---------- rank/combo mirror of the server ---------- */
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: "" },
];
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++) if (COMBOS[i].a === y && COMBOS[i].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 comboName(c) {
if (c.sub === "1 + 2") return "Meyer";
if (c.sub === "1 + 3") return "Lille-Meyer";
return c.label;
}
function eyeLabel(n) {
if (n <= 0) return "Ude 💀";
return n + (n === 1 ? " øje" : " øjne");
}
function pipLayout(n) {
const M = { 1: [6], 2: [1, 3], 3: [1, 6, 3], 4: [1, 3, 7, 9], 5: [1, 3, 7, 9, 6], 6: [1, 3, 7, 9, 4, 8] };
return (M[n] || []).map(p => ``).join("");
}
/* ---------- state ---------- */
const S = {
name: localStorage.getItem("meyer.name") || "",
uid: null, roomId: null, snap: null,
es: null, view: "name",
flippedL: false, flippedR: false,
showDice: false, rolling: false,
};
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
let flashMsg = null, flashUntil = 0;
function toast(msg) { flashMsg = msg; flashUntil = Date.now() + 3500; }
async function api(action, extra = {}) {
const res = await fetch("/api", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, uid: S.uid, name: S.name, ...extra }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Fejl");
return data;
}
function act(action, extra) { api(action, extra).catch(e => toast(e.message)); }
function setView(v) {
S.view = v;
for (const id of ["view-name", "view-lobby", "view-game"])
$("#" + id).classList.toggle("hidden", id !== "view-" + v);
}
/* ---------- name entry ---------- */
const nameInput = $("#name-input");
nameInput.value = S.name;
nameInput.addEventListener("input", () => { $("#btn-enter").disabled = nameInput.value.trim().length < 1; });
nameInput.addEventListener("keydown", e => { if (e.key === "Enter" && !$("#btn-enter").disabled) enterLobby(); });
$("#btn-enter").addEventListener("click", enterLobby);
$("#btn-change-name").addEventListener("click", () => {
const n = prompt("Dit navn:", S.name);
if (n && n.trim()) { S.name = n.trim().slice(0, 24); localStorage.setItem("meyer.name", S.name); $("#me-name").textContent = S.name; }
});
$("#btn-rules").addEventListener("click", () => $("#modal-rules").classList.remove("hidden"));
$("#btn-close-rules").addEventListener("click", () => $("#modal-rules").classList.add("hidden"));
$("#modal-rules").addEventListener("click", e => { if (e.target.id === "modal-rules") e.target.classList.add("hidden"); });
function enterLobby() {
S.name = nameInput.value.trim().slice(0, 24);
if (!S.name) return;
localStorage.setItem("meyer.name", S.name);
$("#me-name").textContent = S.name;
setView("lobby");
refreshRooms();
}
/* ---------- lobby ---------- */
function refreshRooms() {
fetch("/api/rooms").then(r => r.json()).then(d => renderRoomList(d.rooms)).catch(() => {});
}
$("#btn-refresh").addEventListener("click", refreshRooms);
$("#btn-create").addEventListener("click", async () => {
try {
const d = await api("create", { roomName: $("#new-room-name").value.trim(), addBot: true });
S.uid = d.uid; S.roomId = d.room; $("#new-room-name").value = "";
connectToRoom(d.snap);
} catch (e) { alert(e.message); }
});
function renderRoomList(rooms) {
const el = $("#room-list");
if (!rooms.length) { el.innerHTML = `
Ingen borde lige nu – opret det første! 🍺
`; return; }
el.innerHTML = rooms.map(r => {
const names = r.players.map(p => esc(p.name) + (p.isBot ? " 🤖" : "")).join(", ");
const full = r.players.length >= r.maxPlayers;
const chip = full ? `Fuld` :
r.phase === "waiting" ? `Ventende` : `Spiller`;
const join = full ? `🔒` : ``;
return `
🍻 ${esc(r.name)} #${r.id}
${names || "tomt"}
${chip}${join}
`;
}).join("");
$$("#room-list [data-join]").forEach(b => b.addEventListener("click", async () => {
try {
const d = await api("join", { roomId: b.dataset.join });
S.uid = d.uid; S.roomId = d.room;
connectToRoom(d.snap);
} catch (e) { alert(e.message); refreshRooms(); }
}));
}
setInterval(() => { if (S.view === "lobby") refreshRooms(); }, 5000);
/* ---------- room connection (SSE) ---------- */
function connectToRoom(snap) {
if (S.es) { S.es.close(); S.es = null; }
setView("game");
$("#chat-log").innerHTML = "";
S.flippedL = S.flippedR = false; S.showDice = false; S.rolling = false;
S.snap = snap; S.roomId = snap.id;
render();
const es = new EventSource(`/api/events?uid=${S.uid}`);
S.es = es;
es.onmessage = (ev) => {
let m; try { m = JSON.parse(ev.data); } catch (e) { return; }
if (m.type === "state") { S.snap = m.snap; }
else if (m.type === "chat") {
const line = document.createElement("div");
line.className = "log-line";
line.innerHTML = `${esc(m.from)}: ${esc(m.text)}`;
$("#chat-log").appendChild(line);
$("#chat-log").scrollTop = $("#chat-log").scrollHeight;
}
};
render();
}
$("#btn-leave").addEventListener("click", async () => {
try { if (S.uid) await api("leave"); } catch (e) {}
if (S.es) { S.es.close(); S.es = null; }
S.snap = null; S.uid = null; S.roomId = null;
setView("lobby"); refreshRooms();
});
/* ---------- render (idempotent, runs on a timer) ---------- */
let prevKey = "";
function me() { return S.snap ? S.snap.players.find(p => p.uid === S.uid) : null; }
function myTurn() { return S.snap && S.snap.turnUid === S.uid && S.snap.phase !== "waiting"; }
function actorUid() {
const s = S.snap; if (!s) return null;
if (s.phase === "challenging" && s.pending) return s.pending.challenger;
return s.turnUid;
}
function iAmActor() { return actorUid() !== null && actorUid() === S.uid; }
function render() {
if (S.view !== "game" || !S.snap) return;
const s = S.snap;
const iAmHost = s.hostUid === S.uid;
const mp = me();
$("#game-room-name").textContent = s.name;
$("#my-name-game").textContent = mp ? mp.name : S.name;
$("#my-turn-chip").classList.toggle("hidden", !iAmActor());
// phase chip
const ph = $("#game-phase");
ph.textContent =
s.phase === "waiting" ? (s.gameOver ? "🏆 Spillet er slut" : "⏳ Afventer start") :
s.phase === "acting" ? "🎲 Terningerne ruller" :
s.phase === "peeking" ? "👀 Under øllerne" : "🫙 Øllerne skal løftes";
// turn banner
const tb = $("#turn-banner");
if (s.phase === "waiting") {
tb.className = "turn-banner them";
tb.innerHTML = iAmHost
? `🍻 ${s.players.length} ved bordet. Start spillet, når I alle er klar.`
: `⏳ I venter på at bordet startes…`;
} else if (iAmActor()) {
tb.className = "turn-banner mine";
tb.innerHTML = {
acting: `🍻 Din tur! Rul terningerne under dine øller…`,
peeking: `🍻 Din tur! Kig under øllerne, så sig hvad du har (eller bluf)…`,
challenging: `🫙 ${esc(s.turnName)} sagde ${esc(s.lastClaim ? s.lastClaim.label : "…")} – løft deres øller, eller tro på dem!`,
}[s.phase] || "…";
} else {
tb.className = "turn-banner them";
tb.innerHTML = {
acting: `⏳ ${esc(s.turnName)} ruller under øllerne…`,
peeking: `👀 ${esc(s.turnName)} kigger under øllerne…`,
challenging: `🫙 ${esc(byName(s.pending.challenger))} skal nu løfte øllerne og tjekke ${esc(s.turnName)}!`,
}[s.phase] || "…";
}
// bar (persistent claim)
const lc = $("#last-claim");
if (s.lastClaim && s.phase !== "waiting") {
lc.classList.remove("hidden");
lc.innerHTML = `📏 ${esc(s.lastClaim.byName)} satte stangen til ${esc(s.lastClaim.label)} – næste slag skal være højere${s.lastClaim.by === S.uid ? " (det var dig!)" : ""}`;
} else lc.classList.add("hidden");
// opponents
const opp = s.players.filter(p => p.uid !== S.uid);
$("#opp-table").innerHTML = opp.length ? opp.map(p => `
${esc(p.name)}${p.isBot ? " 🤖" : ""}
${eyeLabel(p.score)}
`).join("") : `Alene ved bordet…
`;
// scoreboard
$("#score-list").innerHTML = [...s.players].sort((a, b) => b.score - a.score).map(p => `
${esc(p.name)}${p.isBot ? " 🤖" : ""}${p.uid === S.uid ? " (dig)" : ""}
${eyeLabel(p.score)}
`).join("");
// host actions
const ha = $("#host-actions");
if (iAmHost) {
if (s.phase === "waiting" && !s.gameOver) ha.innerHTML = ``;
else if (s.phase === "waiting" && s.gameOver) ha.innerHTML = ``;
else ha.innerHTML = ``;
const bs = $("#btn-start"); if (bs) bs.addEventListener("click", () => act("start"));
const br = $("#btn-restart"); if (br) br.addEventListener("click", () => { if (confirm("Starte et nyt spil? Alle øjne nulstilles.")) act("restart"); });
} else if (s.phase === "waiting") {
const host = s.players.find(p => p.uid === s.hostUid);
ha.innerHTML = `👑 ${esc(host ? host.name : "…")} skal starte spillet.
`;
} else ha.innerHTML = "";
// game log
syncGameLog(s);
// my board
renderMyBoard(s);
// overlay
renderOverlay(s, iAmHost);
// flash toast
if (flashMsg && flashUntil > Date.now()) {
let f = $("#flash");
if (!f) { f = document.createElement("div"); f.id = "flash"; document.body.appendChild(f); }
f.style.cssText = "position:fixed;top:16px;left:50%;transform:translateX(-50%);background:#c94f33;color:#fff;padding:10px 20px;border-radius:12px;z-index:99;font-weight:600;box-shadow:0 8px 20px rgba(0,0,0,.5)";
f.textContent = flashMsg;
} else if (flashMsg) { const f = $("#flash"); if (f) f.remove(); flashMsg = null; }
}
function byName(uid) { const p = S.snap && S.snap.players.find(x => x.uid === uid); return p ? p.name : "?"; }
function syncGameLog(s) {
const log = $("#chat-log");
const existing = log.querySelectorAll(".log-line.sys");
if (existing.length === s.log.length) return; // unchanged
// full re-render of system lines (chat lines preserved)
[...log.querySelectorAll(".log-line.sys")].forEach(n => n.remove());
const frag = document.createDocumentFragment();
for (const e of s.log) {
const line = document.createElement("div");
let cls = "log-line sys";
if (e.t.includes("💀")) cls = "log-line bad";
else if (e.t.includes("🏆")) cls = "log-line good";
else if (e.t.includes("🎲 Øllerne")) cls = "log-line good";
line.className = cls;
const t = new Date(e.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
line.innerHTML = `${t} ${esc(e.t)}`;
frag.appendChild(line);
}
log.appendChild(frag);
log.scrollTop = log.scrollHeight;
}
function renderMyBoard(s) {
const a = $("#my-actions");
const diceEl = $("#my-dice");
const cupsL = $("#cup-l"), cupsR = $("#cup-r");
// cups
const canPeek = myTurn() && s.phase === "peeking";
cupsL.classList.toggle("flipped", canPeek && S.flippedL);
cupsR.classList.toggle("flipped", canPeek && S.flippedR);
// dice area
if (S.rolling) {
diceEl.innerHTML = ``;
return;
}
if (myTurn() && s.phase === "peeking" && S.showDice && s.myDice) {
diceEl.innerHTML = `${pipLayout(s.myDice[0])}
${pipLayout(s.myDice[1])}
`;
} else if (myTurn() && s.phase === "acting") {
diceEl.innerHTML = `Dine øller er klar. Rul når du er parat. 🍺
`;
} else if (myTurn() && s.phase === "peeking") {
diceEl.innerHTML = `Terningerne er under dine øller – løft dem efter tur 👀
`;
} else if (s.hasDice && s.phase !== "waiting") {
diceEl.innerHTML = `🫙 ${esc(s.turnName)} har øllerne mellem hænderne…
`;
} else {
diceEl.innerHTML = `—
`;
}
// actions
a.innerHTML = "";
if (s.phase === "waiting") return;
if (iAmActor()) {
if (s.phase === "acting") {
a.innerHTML = ``;
$("#btn-roll").addEventListener("click", () => { S.rolling = true; act("roll"); setTimeout(() => { S.rolling = false; render(); }, 1000); });
} else if (s.phase === "peeking") {
const truth = (S.showDice && s.myDice) ? rankOf(s.myDice[0], s.myDice[1]) : -1;
const minR = s.lastClaim ? s.lastClaim.rank + 1 : 0;
let grid = "";
for (let r = Math.max(minR, 0); r <= 17; r++) {
const c = comboOf(r);
const hint = r === truth ? "⭐ dit virkelige slag" : "🎭 bluff";
grid += ``;
}
a.innerHTML = `
🗣️ Sig hvad du har – eller bluf og sig højere:
${grid}
`;
$$("#my-actions .combo-btn").forEach(b => b.addEventListener("click", () => { S.showDice = false; S.flippedL = S.flippedR = false; act("declare", { rank: +b.dataset.rank }); }));
$("#btn-reroll").addEventListener("click", () => { S.showDice = false; S.flippedL = S.flippedR = false; act("reroll"); });
$("#btn-fwd").addEventListener("click", () => { S.showDice = false; S.flippedL = S.flippedR = false; act("sendForward"); });
} else if (s.phase === "challenging" && s.pending && s.pending.challenger === S.uid) {
a.innerHTML = `
🫙 ${esc(s.turnName)} sagde ${esc(s.lastClaim ? s.lastClaim.label : "…")}. Tror du på dem?
`;
$("#btn-ch").addEventListener("click", () => act("challenge"));
$("#btn-pass").addEventListener("click", () => act("pass"));
}
} else {
a.innerHTML = `Hænder i skød – ${esc(s.turnName)} er i gang…
`;
}
}
function renderOverlay(s, iAmHost) {
const ov = $("#overlay");
if (s.gameOver) {
ov.classList.remove("hidden");
const alive = s.players.filter(p => !p.eliminated).sort((a, b) => b.score - a.score);
const winner = alive.length ? alive[0] : null;
$("#overlay-emoji").textContent = "🏆";
$("#overlay-title").textContent = winner ? `${winner.name} vandt!` : "Spillet er slut!";
const stand = [...s.players].sort((a, b) => b.score - a.score)
.map((p, i) => `${i + 1}. ${esc(p.name)} – ${p.eliminated ? "ude 💀" : p.score + " øjne"}`).join(" · ");
$("#overlay-text").innerHTML = `${stand}
Sidste mand ved bordet vinder.`;
$("#overlay-actions").innerHTML = iAmHost
? ``
: ``;
const rs = $("#ov-restart"); if (rs) rs.addEventListener("click", () => act("restart"));
const cl2 = $("#ov-close2"); if (cl2) cl2.addEventListener("click", () => ov.classList.add("hidden"));
return;
}
const started = s.players.some(p => p.score < 6);
const anyAlive = s.players.filter(p => !p.eliminated);
if (s.phase === "waiting" && !started && anyAlive.length >= 1) {
ov.classList.remove("hidden");
$("#overlay-emoji").textContent = "🍻";
$("#overlay-title").textContent = s.name;
const names = s.players.map(p => p.name).join(", ");
const canStart = iAmHost && s.players.length >= 2;
$("#overlay-text").innerHTML =
`Ved bordet: ${esc(names)}
Max. ${s.maxPlayers} spillere. ${canStart ? "" : "
" + (iAmHost ? "Husk mindst 2 spillere – opret flere borde eller spørg nogen om at sætte sig." : "Vent på at værten starter…")}`;
$("#overlay-actions").innerHTML = canStart
? ``
: ``;
const st = $("#ov-start"); if (st) st.addEventListener("click", () => act("start"));
const cl = $("#ov-close"); if (cl) cl.addEventListener("click", () => ov.classList.add("hidden"));
return;
}
ov.classList.add("hidden");
}
/* ---------- cup interactions ---------- */
function tryPeek(which) {
const s = S.snap;
if (!s || !myTurn() || s.phase !== "peeking") return;
if (which === "l") S.flippedL = !S.flippedL; else S.flippedR = !S.flippedR;
if (S.flippedL && S.flippedR) {
S.showDice = true;
act("peek", { revealed: true });
}
render();
}
$("#cup-l").addEventListener("click", () => {
if (S.snap && myTurn() && S.snap.phase === "peeking") tryPeek("l");
else { $("#cup-l").classList.add("nope"); setTimeout(() => $("#cup-l").classList.remove("nope"), 350); }
});
$("#cup-r").addEventListener("click", () => {
if (S.snap && myTurn() && S.snap.phase === "peeking") tryPeek("r");
else { $("#cup-r").classList.add("nope"); setTimeout(() => $("#cup-r").classList.remove("nope"), 350); }
});
/* ---------- chat ---------- */
function sendChat() {
const inp = $("#chat-input");
const t = inp.value.trim();
if (!t) return;
inp.value = "";
act("chat", { text: t });
}
$("#btn-chat").addEventListener("click", sendChat);
$("#chat-input").addEventListener("keydown", e => { if (e.key === "Enter") sendChat(); });
/* ---------- main loop ---------- */
setInterval(() => { if (S.view === "game" && S.snap) render(); }, 350);
setInterval(() => { if (flashMsg && flashUntil < Date.now()) { flashMsg = null; const f = $("#flash"); if (f) f.remove(); } }, 800);
/* boot */
if (S.name) { nameInput.value = S.name; $("#btn-enter").disabled = false; }