/* 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; }