diff --git a/README.md b/README.md index f41f5c5..58cf405 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,65 @@ -# meyer +# 🎲 Meyer – drikke terningespil +Et komplett multiplayer-Meyer (drikke-terningespil) til browseren, bygget til selskaber +hvor alle allerede snakker sammen på voicechat. Ingen npm-pakker – ren Node.js. + +## Sådan kører man + +```bash +node server.js # lytter på port 3000 (PORT=… for anden port) +``` + +Åbn **http://localhost:3000** (eller http://:3000) i browseren – +en pr. spiller. Alle ser samme bord live. + +## Spillet + +1. Skriv dit navn → gå til lobbyen. +2. Se listen over borde, **sæt dig** ved ét, eller **opret dit eget** (opretter automatisk en + robot-spiller, så bordet ikke står tomt, mens folk samler sig). +3. Værten trykker **Start** (min. 2 spillere). +4. Din tur: **Rul** under dine to øller → klik begge øller, så løfter de sig og du + kigger → sig hvad du har (⭐) **eller bluf** og sig højere → øllerne sendes videre. +5. Næste spiller kan **trø på dig** eller **løfte øllerne**. + - Sandt → de drikker. Bluff → du drikker. +6. Stangen (sidste sagte slag) bliver liggende – spillet klatrer, indtil nogen bustes. +7. Slag er for lavt? **Rul igen** (uden at kigge) og send videre, eller **send videre** + uden at sige noget. +8. Taber = ét øje ned. Ved 0 øjne er man **drækket ud**. Sidste ved bordet vinder. 🏆 + +### Hierarchy (højest → lavest) +Meyer (1+2) > Lille-Meyer (1+3) > Par 6 > Par 5 > Par 4 > Par 3 > Par 2 > Par 1 > 6+5 > 6+4 > +63, 62, 54, 53, 52, 43, 42, 32 + +## Funktioner + +- **Live-sync** via Server-Sent Events (ingen websocket-afhængigheder) +- **Lobbyer** med opdateringsstatus (Ventende / Spiller / Fuld, max 4) +- **Privat terning**: kun aktive spiller, der har kigget, ser sit slag (serveren sender + `myDice` kun i dennes snapshot) +- **Robots** der ruller, bluffer, løfter øller og drikker – bordet kan køre, selv mens + folk finder sammen på voicechat +- **Chat** til bordet (fx "jeg bluffer ikke, edderma…") +- **Afkørsel**: fraværende spillere får turen passeret automatisk; genstart = "Spil igen" +- Værten kan starte nyt spil; alle øjne nulstilles +- Flere tabs samme spiller = samme session (SSE-multiplexing) + +## Filstruktur + +``` +meyer/ +├── server.js # alt: HTTP, SSE, rum, spilleregler, bots (uden afhængigheder) +├── public/ +│ ├── index.html # navne-skrærm, lobby, spil, regel-modal +│ ├── style.css # mørk brikbar-tema, øller, terninger, animationer +│ └── app.js # klient: SSE-sync, rendering, handlinger +└── flow.js / smoke.js / endgame.js # API-smoke-tests +``` + +## API (POST /api) + +`create {roomName, addBot}` · `join {roomId}` · `rejoin {roomId, uid}` · `leave` · +`start` · `restart` · `roll` · `peek` · `declare {rank}` · `reroll` · `sendForward` · +`challenge` · `pass` · `chat {text}` + +GET: `/api/rooms` · `/api/state?room=…&uid=…` · `/api/events?uid=…` (SSE) diff --git a/endgame.js b/endgame.js new file mode 100644 index 0000000..c54d0d7 --- /dev/null +++ b/endgame.js @@ -0,0 +1,80 @@ +/* Aggressive end-to-end: force a full game to completion (eliminations). */ +const BASE = "http://localhost:3000"; +const { request } = require("http"); + +function sse(uid) { + return new Promise((resolve) => { + const res = request({ host: "localhost", port: 3000, path: `/api/events?uid=${uid}`, method: "GET" }, (r) => { + let buf = ""; const states = []; + r.on("data", (c) => { + buf += c; + let idx; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const chunk = buf.slice(0, idx); buf = buf.slice(idx + 2); + for (const l of chunk.split("\n")) { + if (l.startsWith("data: ")) { try { const m = JSON.parse(l.slice(6)); if (m.type === "state") states.push(m.snap); } catch (e) {} } + } + } + }); + resolve({ states, res }); + }); + res.on("error", () => {}); + res.end(); + }); +} +const api = async (action, uid, name, extra = {}) => { + const r = await fetch(BASE + "/api", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, uid, name, ...extra }) }); + const d = await r.json(); + if (!r.ok) throw new Error(`${action}: ${d.error}`); + return d; +}; +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); +const COMBOS = [[1,2],[1,3],[6,6],[5,5],[4,4],[3,3],[2,2],[1,1],[6,5],[6,4]]; +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][0] === y && COMBOS[i][1] === x) return i; const i = DYN.findIndex(c => c[0] === y && c[1] === x); return i >= 0 ? 10 + i : -1; } + +(async () => { + const c1 = await api("create", null, "Alice", { roomName: "Finaltest", addBot: true }); + const c2 = await api("join", null, "Bob", { roomId: c1.room }); + const u1 = c1.uid, u2 = c2.uid; + const [s1, s2] = await Promise.all([sse(u1), sse(u2)]); + await api("start", u1, "Alice"); + + const t0 = Date.now(); + while (Date.now() - t0 < 300000) { + await sleep(1200); + const snap = s1.states[s1.states.length - 1]; + if (!snap) continue; + if (snap.phase === "waiting" && snap.gameOver) { + console.log("GAME OVER. Final:", JSON.stringify(snap.players.map(p => [p.name, p.score, p.eliminated]))); + console.log("Last log lines:", snap.log.slice(-4).map(l => l.t).join(" | ")); + // test restart works + await api("restart", u1, "Alice"); + await sleep(800); + const s2snap = s2.states[s2.states.length - 1]; + console.log("After restart: phase=", s2snap.phase, "scores=", JSON.stringify(s2snap.players.map(p => [p.name, p.score, p.eliminated]))); + process.exit(0); + } + const actor = snap.phase === "challenging" && snap.pending ? snap.pending.challenger : snap.turnUid; + for (const [uid, states, name] of [[u1, s1.states, "Alice"], [u2, s2.states, "Bob"]]) { + if (actor !== uid) continue; + if (snap.phase === "acting") await api("roll", uid, name); + else if (snap.phase === "peeking") { + await api("peek", uid, name, { revealed: true }); + await sleep(150); + const fresh = states[states.length - 1]; + if (!fresh.myDice) continue; + const truth = rankOf(fresh.myDice[0], fresh.myDice[1]); + const minR = fresh.lastClaim ? fresh.lastClaim.rank + 1 : 0; + // AGGRESSIVE: always bluff 3 above (capped), guarantees busts & eliminations + let r = Math.min(Math.max(minR, truth + 3), 17); + if (r < minR) await api("sendForward", uid, name); + else await api("declare", uid, name, { rank: r }); + } else if (snap.phase === "challenging" && snap.pending && snap.pending.challenger === uid) { + await api("challenge", uid, name); // always challenge -> busts everywhere + } + } + } + console.log("TIMEOUT - game did not finish in time"); + process.exit(1); +})().catch(e => { console.error("FAIL:", e); process.exit(1); }); diff --git a/flow.js b/flow.js new file mode 100644 index 0000000..c179aa0 --- /dev/null +++ b/flow.js @@ -0,0 +1,31 @@ +const B="http://localhost:3000"; +const api=async(a,u,n,x={})=>{const r=await fetch(B+"/api",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({action:a,uid:u,name:n,...x})});const d=await r.json();if(!r.ok)throw new Error(a+": "+d.error);return d}; +(async()=>{ + const c1=await api("create",null,"Ana",{roomName:"Flow",addBot:false}); + const c2=await api("join",null,"Bil",{roomId:c1.room}); + console.log("create OK:",c1.room,"players:",c1.snap.players.map(p=>p.name).join(",")); + // wrong turn should be rejected + try{await api("roll",c2.uid,"Bil");console.log("ERR: roll allowed out of turn!");}catch(e){console.log("turn guard OK:",e.message)} + const st=await api("start",c1.uid,"Ana"); + console.log("start OK. phase:",(await api("rejoin",c1.uid,"Ana",{roomId:c1.room})).snap.phase); + // Ana = starter. roll->peek->declare low (honest) + const r1=await api("roll",c1.uid,"Ana"); + const r2=await api("peek",c1.uid,"Ana",{revealed:true}); + const st2=(await api("rejoin",c1.uid,"Ana",{roomId:c1.room})).snap; + console.log("Ana dice (private):",JSON.stringify(st2.myDice),"turn:",st2.turnName); + const d1=st2.myDice[0],d2=st2.myDice[1]; + // rank mirror + const C=[[1,2],[1,3],[6,6],[5,5],[4,4],[3,3],[2,2],[1,1],[6,5],[6,4]],D=[[6,3],[6,2],[5,4],[5,3],[5,2],[4,3],[4,2],[3,2]]; + const rank=(a,b)=>{const x=Math.min(a,b),y=Math.max(a,b);for(let i=0;ic[0]===y&&c[1]===x);return i>=0?10+i:-1}; + const truth=rank(d1,d2); + const dec=await api("declare",c1.uid,"Ana",{rank:truth}); + const st3=(await api("rejoin",c2.uid,"Bil",{roomId:c1.room})).snap; + console.log("declare OK. now phase:",st3.phase,"pending challenger:",st3.pending&&st3.pending.challenger===c2.uid,"lastClaim:",st3.lastClaim&&st3.lastClaim.label); + // Bil trusts -> claim stands, Ana's turn again + const p=await api("pass",c2.uid,"Bil"); + const st4=(await api("rejoin",c1.uid,"Ana",{roomId:c1.room})).snap; + console.log("pass OK. turn now:",st4.turnName,"lastClaim persists:",st4.lastClaim&&st4.lastClaim.label); + // chat + await api("chat",c2.uid,"Bil",{text:"godt spil! 🍺"}); + console.log("FLOW COMPLETE ✅"); +})().catch(e=>{console.error("FLOW FAIL:",e.message);process.exit(1)}); diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..6db69e2 --- /dev/null +++ b/public/app.js @@ -0,0 +1,437 @@ +/* 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; } diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..48a6c86 --- /dev/null +++ b/public/index.html @@ -0,0 +1,160 @@ + + + + + +Meyer – drikke terningespil + + + + + + +
+
+
MYER
+

Drikke terningespil

+

Rul i skjul, bluf med dine øjne. Du og dine venner snakker over voicechat – her ses det, hvad I drikker. 🍺

+ +
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..220c352 --- /dev/null +++ b/public/style.css @@ -0,0 +1,299 @@ +:root { + --bg1: #17100a; + --bg2: #241811; + --wood: #3a2718; + --wood2: #4a3322; + --gold: #d9a441; + --gold-dim: #a87f2f; + --amber: #f2c069; + --paper: #f3e9d4; + --ink: #2a1c10; + --good: #6fcf7f; + --bad: #e4674a; + --cup-silver: #b9c2cc; + --cup-silver2: #7e8a97; + --cup-handle: #8a97a5; +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; + color: var(--paper); + background: + radial-gradient(1200px 700px at 20% -10%, #3d2a18 0%, transparent 60%), + radial-gradient(1000px 800px at 110% 10%, #33241a 0%, transparent 55%), + linear-gradient(160deg, var(--bg1), var(--bg2) 60%, #1c130c); + min-height: 100vh; +} +body::before { + content: ""; position: fixed; inset: 0; pointer-events: none; opacity: .05; + background-image: repeating-linear-gradient(0deg, transparent 0 26px, #000 26px 27px); +} +.hidden { display: none !important; } +h1, h2, h3 { font-family: Georgia, "Times New Roman", serif; } + +/* ---------- generic ---------- */ +.btn { + border: none; border-radius: 10px; padding: 11px 18px; font-size: 15px; + cursor: pointer; transition: transform .06s, filter .15s; font-weight: 600; +} +.btn:active { transform: translateY(1px); } +.btn.primary { background: linear-gradient(180deg, var(--amber), var(--gold)); color: #2a1a05; box-shadow: 0 3px 0 #8a651f, 0 6px 14px rgba(0,0,0,.4); } +.btn.primary:hover { filter: brightness(1.07); } +.btn.primary:disabled { filter: grayscale(.7) brightness(.8); cursor: default; box-shadow: none; } +.btn.ghost { background: rgba(255,255,255,.06); color: var(--paper); border: 1px solid rgba(255,255,255,.15); } +.btn.ghost:hover { background: rgba(255,255,255,.12); } +.btn.danger { background: linear-gradient(180deg, #e8795e, #c94f33); color: #2a0d05; } +.btn.sm { padding: 7px 12px; font-size: 13px; border-radius: 8px; } +.linklike { background: none; border: none; color: var(--amber); cursor: pointer; font-size: 14px; } +input { + background: rgba(0,0,0,.35); border: 1px solid rgba(255,255,255,.18); color: var(--paper); + border-radius: 10px; padding: 11px 14px; font-size: 15px; outline: none; min-width: 0; +} +input:focus { border-color: var(--gold); box-shadow: 0 0 0 3px rgba(217,164,65,.18); } + +/* ---------- name screen ---------- */ +#view-name { display: grid; place-items: center; min-height: 100vh; padding: 20px; } +.name-card { + width: min(440px, 94vw); text-align: center; padding: 38px 34px; + background: linear-gradient(170deg, rgba(255,255,255,.05), rgba(0,0,0,.25)); + border: 1px solid rgba(217,164,65,.28); border-radius: 22px; + box-shadow: 0 24px 60px rgba(0,0,0,.55); +} +.logo-dice { display: flex; gap: 8px; justify-content: center; margin-bottom: 18px; } +.logo-dice .d { + width: 44px; height: 44px; border-radius: 11px; display: grid; place-items: center; + background: linear-gradient(150deg, #fff8ea, #e9d9b8); color: #7a2d1c; + font-family: Georgia, serif; font-weight: 700; font-size: 22px; + box-shadow: 0 4px 0 #b9a684, 0 8px 16px rgba(0,0,0,.4); +} +.name-card h1 { margin: 6px 0 4px; font-size: 30px; color: var(--amber); letter-spacing: .5px; } +.name-card .sub { font-size: 14px; color: rgba(243,233,212,.75); line-height: 1.5; margin: 8px 0 22px; } +.name-card label { display: block; text-align: left; font-size: 13px; margin: 0 0 7px; color: var(--gold); text-transform: uppercase; letter-spacing: 1px; } +.name-row { display: flex; gap: 10px; margin-top: 12px; } +.name-row input { flex: 1; } + +/* ---------- topbar ---------- */ +.topbar { + display: flex; justify-content: space-between; align-items: center; gap: 12px; + padding: 14px 22px; border-bottom: 1px solid rgba(217,164,65,.2); + background: rgba(0,0,0,.28); flex-wrap: wrap; +} +.brand { font-family: Georgia, serif; font-size: 20px; color: var(--amber); } +.brand-sub { font-size: 13px; color: rgba(243,233,212,.55); font-family: "Segoe UI", sans-serif; } +.me-chip { font-size: 14px; color: rgba(243,233,212,.8); } +.topbar-right { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } + +/* ---------- lobby ---------- */ +.lobby-main { + display: grid; grid-template-columns: 1.5fr 1fr; gap: 20px; + max-width: 1050px; margin: 26px auto; padding: 0 20px; +} +@media (max-width: 860px) { .lobby-main { grid-template-columns: 1fr; } } +.lobby-card, .side-card { + background: rgba(0,0,0,.28); border: 1px solid rgba(255,255,255,.09); + border-radius: 18px; padding: 20px; +} +.card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; } +.card-head h2 { margin: 0; font-size: 22px; color: var(--amber); } +.room-list { display: flex; flex-direction: column; gap: 12px; min-height: 60px; } +.room-empty { color: rgba(243,233,212,.5); font-size: 14px; text-align: center; padding: 22px 0; } +.room { + display: flex; justify-content: space-between; align-items: center; gap: 12px; + padding: 14px 16px; border-radius: 14px; + background: linear-gradient(160deg, rgba(217,164,65,.07), rgba(0,0,0,.18)); + border: 1px solid rgba(255,255,255,.08); +} +.room.playing { border-color: rgba(111,207,127,.35); } +.room-info { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.room-name { font-size: 16px; font-weight: 600; } +.room-players { font-size: 13px; color: rgba(243,233,212,.65); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.room-state { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } +.chip { font-size: 12px; padding: 4px 10px; border-radius: 20px; font-weight: 600; } +.chip.waiting { background: rgba(217,164,65,.16); color: var(--amber); border: 1px solid rgba(217,164,65,.4); } +.chip.playing { background: rgba(111,207,127,.14); color: var(--good); border: 1px solid rgba(111,207,127,.4); } +.chip.full { background: rgba(228,103,74,.14); color: var(--bad); border: 1px solid rgba(228,103,74,.4); } +.create-row { display: flex; gap: 10px; margin-top: 16px; } +.create-row input { flex: 1; } +.side-card h3 { color: var(--amber); margin-top: 0; font-size: 18px; } +.rules-mini { padding-left: 18px; line-height: 1.65; font-size: 14px; margin: 0; } +.rules-mini li { margin-bottom: 6px; color: rgba(243,233,212,.85); } +.hint { font-size: 13px; color: rgba(243,233,212,.55); font-style: italic; } + +/* ---------- game layout ---------- */ +.game-main { + display: grid; grid-template-columns: 1.35fr 1fr; gap: 20px; + max-width: 1180px; margin: 22px auto; padding: 0 20px; align-items: start; +} +@media (max-width: 980px) { .game-main { grid-template-columns: 1fr; } } +.game-col { display: flex; flex-direction: column; gap: 16px; min-width: 0; } +.side-col { display: flex; flex-direction: column; gap: 16px; } + +/* turn banner */ +.turn-banner { + border-radius: 14px; padding: 14px 18px; font-size: 17px; text-align: center; + background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.1); min-height: 52px; + display: grid; place-items: center; font-family: Georgia, serif; +} +.turn-banner.mine { + background: linear-gradient(160deg, rgba(217,164,65,.22), rgba(0,0,0,.2)); + border-color: var(--gold); color: var(--amber); box-shadow: 0 0 26px rgba(217,164,65,.15); +} +.turn-banner.them { color: rgba(243,233,212,.85); } + +/* last claim */ +.last-claim { + border-radius: 14px; padding: 10px 16px; text-align: center; font-size: 15px; + background: rgba(0,0,0,.25); border: 1px dashed rgba(217,164,65,.45); color: var(--paper); +} +.last-claim b { color: var(--amber); } + +/* table rows */ +.table-row { display: flex; gap: 18px; justify-content: center; flex-wrap: wrap; min-height: 120px; } +.opp-seat { text-align: center; width: 150px; } +.opp-seat.active { outline: 2px solid var(--gold); outline-offset: 6px; border-radius: 18px; } +.seat-name { font-size: 14px; margin-bottom: 6px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 6px; } +.dot { width: 8px; height: 8px; border-radius: 50%; background: #555; display: inline-block; } +.dot.on { background: var(--good); box-shadow: 0 0 6px var(--good); } +.dot.off { background: #664; } +.opp-eye { font-size: 12px; color: rgba(243,233,212,.6); margin-top: 5px; } +.opp-eye.dead { color: var(--bad); } +.dead-seat { opacity: .45; } +.mini-cups { display: flex; gap: 6px; justify-content: center; } +.mini-cup { + width: 26px; height: 34px; border-radius: 4px 4px 12px 12px; + background: linear-gradient(90deg, var(--cup-silver2), var(--cup-silver) 45%, var(--cup-silver2)); + box-shadow: inset 0 -6px 10px rgba(0,0,0,.35), 0 4px 8px rgba(0,0,0,.4); +} +.actions-title { text-align: center; font-size: 14.5px; color: var(--paper); margin-bottom: 10px; } +.actions-title b { color: var(--amber); } +.phase-chip { font-size: 13px; padding: 5px 12px; border-radius: 20px; background: rgba(0,0,0,.35); border: 1px solid rgba(255,255,255,.15); color: rgba(243,233,212,.8); } + +/* ---------- cups ---------- */ +.cups { display: flex; justify-content: center; gap: 16px; padding: 6px 0 2px; } +.cup { position: relative; width: 96px; height: 112px; cursor: pointer; } +.cup-body { + position: absolute; inset: 0; + background: + linear-gradient(90deg, var(--cup-silver2) 0%, var(--cup-silver) 30%, #e3e9f0 46%, var(--cup-silver2) 78%, #5c6875 100%); + border-radius: 10px 10px 42% 42% / 10px 10px 18% 18%; + box-shadow: inset 0 -12px 22px rgba(0,0,0,.35), 0 10px 18px rgba(0,0,0,.45); + transition: transform .28s cubic-bezier(.5,-0.3,.4,1.5); + transform-origin: bottom center; +} +.cup-shine { + position: absolute; top: 12px; left: 22px; width: 10px; height: 55%; + background: linear-gradient(180deg, rgba(255,255,255,.75), rgba(255,255,255,0)); + border-radius: 8px; +} +.cup-base { + position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); + width: 34px; height: 12px; border-radius: 0 0 40% 40%; + background: linear-gradient(90deg, #5c6875, var(--cup-silver), #5c6875); +} +.cup::after { + content: ""; position: absolute; top: 18px; right: -16px; width: 18px; height: 44px; + border: 7px solid var(--cup-handle); border-left: none; border-radius: 0 12px 12px 0; +} +.cup.flipped .cup-body { transform: rotate(180deg) translateY(6px); } +.cup.flipped::after { transform: scaleY(-1); } +.cup.nope { animation: nope .3s; } +@keyframes nope { 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } } +.cup-label { text-align: center; font-size: 11px; color: rgba(243,233,212,.5); margin-top: 4px; letter-spacing: 1px; } + +/* ---------- dice ---------- */ +.dice-area { display: flex; justify-content: center; align-items: center; gap: 14px; min-height: 74px; padding: 8px 0; } +.die { + width: 58px; height: 58px; border-radius: 12px; position: relative; + background: linear-gradient(150deg, #fffaf0, #ead9b6); + box-shadow: inset 0 -4px 8px rgba(0,0,0,.15), 0 6px 12px rgba(0,0,0,.4); +} +.die.rolling { animation: roll .5s linear infinite; } +@keyframes roll { + 0% { transform: rotate(0) translateY(0); } 25% { transform: rotate(180deg) translateY(-10px); } + 50% { transform: rotate(360deg) translateY(0); } 75% { transform: rotate(540deg) translateY(-8px); } + 100% { transform: rotate(720deg) translateY(0); } +} +.pip { position: absolute; width: 11px; height: 11px; border-radius: 50%; background: #241505; box-shadow: inset 0 2px 3px rgba(0,0,0,.5); } +.pip.p1 { top: 24px; left: 24px; } +.pip.p2 { top: 11px; left: 11px; } .pip.p3 { top: 36px; right: 11px; } +.pip.p4 { top: 11px; left: 11px; } .pip.p5 { top: 36px; right: 11px; } .pip.p6 { top: 24px; left: 24px; } +.pip.p7 { top: 36px; left: 11px; } .pip.p8 { top: 11px; right: 11px; } .pip.p9 { top: 36px; left: 11px; } .pip.p10 { top: 24px; left: 24px; } .pip.p11 { top: 36px; right: 11px; } +.dice-msg { text-align: center; color: rgba(243,233,212,.55); font-size: 14px; font-style: italic; } + +/* ---------- actions ---------- */ +.actions { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; } +.actions .row { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } +.btn.big { padding: 14px 22px; font-size: 16px; } +.combo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; max-width: 520px; margin: 0 auto; } +.combo-btn { + text-align: left; padding: 8px 12px; border-radius: 10px; cursor: pointer; font-size: 13.5px; + background: rgba(255,255,255,.06); color: var(--paper); border: 1px solid rgba(255,255,255,.14); + transition: background .12s; +} +.combo-btn:hover { background: rgba(217,164,65,.2); border-color: var(--gold); } +.combo-btn .rank-label { font-weight: 700; color: var(--amber); } +.combo-btn .rank-sub { color: rgba(243,233,212,.6); font-size: 12px; } + +/* ---------- chat ---------- */ +.chat-box { background: rgba(0,0,0,.28); border: 1px solid rgba(255,255,255,.08); border-radius: 16px; padding: 14px; } +.chat-log { height: 240px; overflow-y: auto; display: flex; flex-direction: column; gap: 5px; padding-right: 4px; scroll-behavior: smooth; } +.chat-log::-webkit-scrollbar { width: 8px; } .chat-log::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 4px; } +.log-line { font-size: 13.5px; color: rgba(243,233,212,.8); line-height: 1.45; } +.log-line .who { color: var(--amber); font-weight: 600; } +.log-line .ts { color: rgba(243,233,212,.3); font-size: 11px; margin-right: 4px; } +.log-line.sys { color: rgba(243,233,212,.6); font-style: italic; } +.log-line.good { color: var(--good); } +.log-line.bad { color: var(--bad); } +.chat-input { display: flex; gap: 8px; margin-top: 10px; } +.chat-input input { flex: 1; padding: 9px 12px; } + +/* ---------- scoreboard ---------- */ +.board-card { background: rgba(0,0,0,.28); border: 1px solid rgba(255,255,255,.08); border-radius: 16px; padding: 16px 18px; } +.board-card h3 { margin: 0 0 12px; color: var(--amber); font-size: 17px; } +.score-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; margin-bottom: 6px; background: rgba(255,255,255,.03); } +.score-row.turn { background: rgba(217,164,65,.12); outline: 1px solid rgba(217,164,65,.5); } +.score-row.dead { opacity: .45; } +.score-name { flex: 1; font-weight: 600; font-size: 14.5px; display: flex; gap: 7px; align-items: center; } +.score-eye { font-size: 13px; color: rgba(243,233,212,.75); white-space: nowrap; } +.eyes { letter-spacing: 2px; color: var(--gold); } +.eyes.dead { color: var(--bad); letter-spacing: 0; } +.host-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; } +.host-actions .note { width: 100%; font-size: 12px; color: rgba(243,233,212,.45); } + +/* my board */ +.my-board { + background: + linear-gradient(180deg, rgba(217,164,65,.08), rgba(0,0,0,.3)); + border: 1px solid rgba(217,164,65,.3); border-radius: 18px; padding: 16px; text-align: center; +} +.my-nameplate { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } +.my-nameplate b { font-size: 16px; color: var(--amber); } +.turn-chip { background: var(--gold); color: #2a1a05; font-weight: 700; font-size: 12px; padding: 3px 10px; border-radius: 12px; animation: pulse 1.4s infinite; } +@keyframes pulse { 50% { opacity: .55; } } + +/* ---------- overlay ---------- */ +.overlay { position: fixed; inset: 0; background: rgba(10,6,3,.72); display: grid; place-items: center; z-index: 50; backdrop-filter: blur(4px); } +.overlay-card { + width: min(460px, 92vw); text-align: center; background: linear-gradient(170deg, #2c1e13, #1d130b); + border: 1px solid rgba(217,164,65,.4); border-radius: 20px; padding: 34px 30px; + box-shadow: 0 30px 80px rgba(0,0,0,.7); +} +#overlay-emoji { font-size: 58px; } +.overlay-card h2 { color: var(--amber); margin: 10px 0 6px; } +.overlay-card p { color: rgba(243,233,212,.8); font-size: 15px; } +#overlay-actions { display: flex; gap: 10px; justify-content: center; margin-top: 20px; flex-wrap: wrap; } + +/* ---------- modal ---------- */ +.modal { position: fixed; inset: 0; background: rgba(10,6,3,.75); display: grid; place-items: center; z-index: 60; padding: 16px; } +.modal-card { + width: min(620px, 95vw); max-height: 88vh; overflow-y: auto; + background: linear-gradient(170deg, #2c1e13, #1d130b); border: 1px solid rgba(217,164,65,.4); + border-radius: 18px; padding: 28px 30px; position: relative; +} +.modal-card h2 { color: var(--amber); margin-top: 0; } +.modal-card h3 { color: var(--gold); font-size: 15px; text-transform: uppercase; letter-spacing: 1px; margin: 18px 0 8px; } +.modal-card p, .modal-card li { font-size: 14px; line-height: 1.6; color: rgba(243,233,212,.88); } +.rules-list { padding-left: 22px; } +.modal-x { position: absolute; top: 14px; right: 14px; background: none; border: none; color: rgba(243,233,212,.6); font-size: 20px; cursor: pointer; } +.modal-x:hover { color: var(--paper); } diff --git a/server.js b/server.js new file mode 100644 index 0000000..0cc0a27 --- /dev/null +++ b/server.js @@ -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 +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}`); +}); diff --git a/smoke.js b/smoke.js new file mode 100644 index 0000000..ae2e11c --- /dev/null +++ b/smoke.js @@ -0,0 +1,126 @@ +/* Smoke test: create room w/ bot, 2 human clients via SSE, play until end. */ +const BASE = "http://localhost:3000"; + +function sse(uid) { + return new Promise((resolve) => { + const { request } = require("http"); + const res = request({ host: "localhost", port: 3000, path: `/api/events?uid=${uid}`, method: "GET" }, (r) => { + let buf = ""; + const states = []; + const chats = []; + r.on("data", (c) => { + buf += c; + let idx; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const chunk = buf.slice(0, idx); buf = buf.slice(idx + 2); + const lines = chunk.split("\n").filter(l => l.startsWith("data: ")); + for (const l of lines) { + const m = JSON.parse(l.slice(6)); + if (m.type === "state") states.push(m.snap); + else if (m.type === "chat") chats.push(m); + } + } + }); + resolve({ states, chats, res }); + }); + res.on("error", () => {}); + res.end(); + }); +} +const api = async (action, uid, name, extra = {}) => { + const r = await fetch(BASE + "/api", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, uid, name, ...extra }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(`${action}: ${d.error}`); + return d; +}; +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + +(async () => { + // create room (host + auto bot) + const c1 = await api("create", null, "Alice", { roomName: "Testbord", addBot: true }); + const uid1 = c1.uid; + // second player + const c2 = await api("join", null, "Bob", { roomId: c1.room }); + const uid2 = c2.uid; + const [s1, s2] = await Promise.all([sse(uid1), sse(uid2)]); + + // host starts + await api("start", uid1, "Alice"); + console.log("started. waiting for bot rounds..."); + + const startT = Date.now(); + let turns = 0; + while (Date.now() - startT < 240000) { + await sleep(1500); + const snap = s1.states[s1.states.length - 1]; + if (!snap) continue; + if (snap.phase === "waiting") { console.log("GAME ENDED:", snap.log.slice(-3).map(l => l.t)); break; } + // alice acts if it's her turn/actor + const actor = snap.phase === "challenging" && snap.pending ? snap.pending.challenger : snap.turnUid; + if (actor === uid1) { + if (snap.phase === "acting") { + await api("roll", uid1, "Alice"); + } else if (snap.phase === "peeking") { + await api("peek", uid1, "Alice", { revealed: true }); + await sleep(200); const fresh = s1.states[s1.states.length - 1]; + const myDice = fresh.myDice; + if (myDice) { + const truth = rankOf(myDice[0], myDice[1]); + const minR = fresh.lastClaim ? fresh.lastClaim.rank + 1 : 0; + let r = Math.max(minR, truth); + if (Math.random() < 0.3) r = Math.min(r + 1, 17); // occasional bluff + if (r < minR || r > 17) { await api("sendForward", uid1, "Alice"); } + else await api("declare", uid1, "Alice", { rank: r }); + } + } else if (snap.phase === "challenging" && snap.pending && snap.pending.challenger === uid1) { + // bob's claim is on the table... but alice is the actor? (challenger) - decide randomly + await (Math.random() < 0.4 ? api("challenge", uid1, "Alice") : api("pass", uid1, "Alice")); + } + turns++; + } + const actor2 = snap.phase === "challenging" && snap.pending ? snap.pending.challenger : snap.turnUid; + if (actor2 === uid2) { + if (snap.phase === "acting") await api("roll", uid2, "Bob"); + else if (snap.phase === "peeking") { + await api("peek", uid2, "Bob", { revealed: true }); + await sleep(200); const fresh = s2.states[s2.states.length - 1]; + const myDice = fresh.myDice; + if (myDice) { + const truth = rankOf(myDice[0], myDice[1]); + const minR = fresh.lastClaim ? fresh.lastClaim.rank + 1 : 0; + let r = Math.max(minR, truth); + if (Math.random() < 0.3) r = Math.min(r + 1, 17); + if (r < minR || r > 17) await api("sendForward", uid2, "Bob"); + else await api("declare", uid2, "Bob", { rank: r }); + } + } else if (snap.phase === "challenging" && snap.pending && snap.pending.challenger === uid2) { + await (Math.random() < 0.4 ? api("challenge", uid2, "Bob") : api("pass", uid2, "Bob")); + } + turns++; + } + // progress report + if (turns % 10 === 0) console.log(`...turns=${turns} phase=${snap.phase} scores=${JSON.stringify(snap.players.map(p => [p.name, p.score, p.eliminated]))}`); + } + + // also test chat + leave + await api("chat", uid1, "Alice", { text: "hej med jer alle sammen! 🍺" }); + await sleep(300); + console.log("chats received by bob:", s2.chats.length); + console.log("total turns driven:", turns); + const rooms = await (await fetch(BASE + "/api/rooms")).json(); + console.log("rooms after:", JSON.stringify(rooms.rooms.map(r => ({ id: r.id, phase: r.phase, n: r.players.length })))); + process.exit(0); +})().catch(e => { console.error("SMOKE FAIL:", e); process.exit(1); }); + +/* local mirror of ranking for the test driver */ +const COMBOS = [[1,2],[1,3],[6,6],[5,5],[4,4],[3,3],[2,2],[1,1],[6,5],[6,4]]; +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][0] === y && COMBOS[i][1] === x) return i; + const i = DYN.findIndex(c => c[0] === y && c[1] === x); + return i >= 0 ? 10 + i : -1; +}