From 96a6a9996324e5b0edbab5858187c441db5a7321 Mon Sep 17 00:00:00 2001 From: maths81 Date: Thu, 11 Jun 2026 22:13:55 +0800 Subject: [PATCH] Initial commit --- .dockerignore | 7 + .gitignore | 3 + Dockerfile | 19 +++ README.md | 81 ++++++++++ compose.local.yml | 9 ++ compose.prod.yml | 22 +++ package.json | 15 ++ public/app.js | 260 +++++++++++++++++++++++++++++++ public/index.html | 86 +++++++++++ public/styles.css | 219 ++++++++++++++++++++++++++ src/game.js | 385 ++++++++++++++++++++++++++++++++++++++++++++++ src/server.js | 125 +++++++++++++++ test/game.test.js | 127 +++++++++++++++ 13 files changed, 1358 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.local.yml create mode 100644 compose.prod.yml create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100644 src/game.js create mode 100644 src/server.js create mode 100644 test/game.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ea619db --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.agents +.codex +node_modules +npm-debug.log +compose*.yml +README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7a2810 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +npm-debug.log* +MateriaMedica.tar.gz diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..38761dd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev && npm cache clean --force + +COPY public ./public +COPY src ./src +COPY test ./test + +ENV NODE_ENV=production +ENV PORT=3000 + +EXPOSE 3000 + +USER node + +CMD ["node", "src/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8e8737 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# 本草牌局 + +三人实时浏览器牌局。服务端负责发牌、回合、规则验证和信息隔离,客户端通过 +Socket.IO 使用同源连接。 + +## 本地部署测试 + +本地版本绑定 `127.0.0.1:17979`,不会暴露到外部网卡: + +```bash +docker compose -f compose.local.yml up --build +``` + +浏览器打开 。使用三个浏览器配置文件或三个无痕窗口加入同一 +房间,即可测试完整流程。 + +运行自动测试: + +```bash +docker compose -f compose.local.yml run --rm game npm test +``` + +停止本地版本: + +```bash +docker compose -f compose.local.yml down +``` + +## 实际部署 + +生产版本同样只绑定宿主机回环地址,由反向代理提供公网访问: + +```bash +docker compose -f compose.prod.yml up -d --build +``` + +反向代理目标为: + +```text +http://127.0.0.1:17979 +``` + +以 Nginx 为例,WebSocket 所需配置如下: + +```nginx +server { + server_name materiamedica.maths79.site; + + location / { + proxy_pass http://127.0.0.1:17979; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +浏览器使用 `https://materiamedica.maths79.site`,不需要填写端口。 + +## 当前规则约定 + +- 牌组包含2张数字2、3张数字3、4张数字4和5张数字5。 +- 随机隐藏2张,其余12张平均发给3名玩家。 +- 公开记录会显示“取一张”或“取同号全部”请求及被询问的两个数字,并显示实际转移数量; + 最终交出的是哪个数字只向转移双方私下显示。 +- 同一玩家不能在连续两个自己的回合请求相同的两种牌;数字顺序、目标玩家和请求类型不同 + 也视为同一组。中间选择猜隐藏牌后不受上一组请求限制。 +- 首位玩家提出猜测后,其余玩家按座位顺序选择认同某一份公开猜测、另猜或放弃。 +- 另猜不会覆盖已有猜测;后续玩家可以选择认同任意一位猜牌者。 +- 两位后续玩家完成操作后,如果恰好一人放弃,该玩家必须再次选择认同或另猜,不能继续 + 放弃。 +- 如果两位后续玩家都放弃且首位玩家猜错,只淘汰首位猜牌者,其余玩家继续游戏。 +- 猜对者加3分,认同正确猜测者加1分,猜错者减1分。出现正确猜测后本局结束并公开隐藏牌。 + +## 数据说明 + +房间和牌局保存在服务内存中。容器重启会清空所有房间,适合当前的简单部署版本。 diff --git a/compose.local.yml b/compose.local.yml new file mode 100644 index 0000000..5450e3f --- /dev/null +++ b/compose.local.yml @@ -0,0 +1,9 @@ +services: + game: + build: + context: . + environment: + NODE_ENV: development + PORT: 3000 + ports: + - "127.0.0.1:17979:3000" diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 0000000..45b8f40 --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,22 @@ +services: + game: + build: + context: . + environment: + NODE_ENV: production + PORT: 3000 + ports: + - "127.0.0.1:17979:3000" + restart: unless-stopped + healthcheck: + test: + - CMD + - wget + - --quiet + - --tries=1 + - --spider + - http://127.0.0.1:3000/health + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s diff --git a/package.json b/package.json new file mode 100644 index 0000000..5e2600d --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "materia-medica", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node --watch src/server.js", + "start": "node src/server.js", + "test": "node --test" + }, + "dependencies": { + "express": "5.1.0", + "socket.io": "4.8.1" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..d8e5831 --- /dev/null +++ b/public/app.js @@ -0,0 +1,260 @@ +const socket = io(); +const $ = (selector) => document.querySelector(selector); +const sessionKey = "materia-medica-session"; +const cardValues = [2, 3, 4, 5]; +let state = null; +let actionMode = "one"; +let toastTimer; + +const showToast = (message) => { + const toast = $("#toast"); + toast.textContent = message; + toast.classList.add("show"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toast.classList.remove("show"), 2600); +}; + +const emit = (event, payload = {}) => socket.emit(event, payload); +const options = (values = cardValues) => values.map((value) => ``).join(""); +const player = (id) => state?.players.find((item) => item.id === id); + +socket.on("connect", () => { + $("#connection").textContent = "已连接"; + $("#connection").classList.add("online"); + const saved = JSON.parse(localStorage.getItem(sessionKey) || "null"); + if (saved) emit("session:resume", saved); +}); + +socket.on("disconnect", () => { + $("#connection").textContent = "正在重连"; + $("#connection").classList.remove("online"); +}); + +socket.on("session", (session) => { + localStorage.setItem(sessionKey, JSON.stringify(session)); +}); + +socket.on("room:state", (room) => { + state = room; + $("#entry").classList.add("hidden"); + $("#game").classList.remove("hidden"); + render(); +}); + +socket.on("error:message", showToast); +socket.on("private:message", showToast); + +$("#create").addEventListener("click", () => emit("room:create", { name: $("#name").value })); +$("#join").addEventListener("click", () => + emit("room:join", { name: $("#name").value, roomId: $("#room-code").value.trim().toUpperCase() }) +); +$("#start").addEventListener("click", () => emit("game:start")); +$("#copy-room").addEventListener("click", async () => { + await navigator.clipboard?.writeText(state.id); + showToast("房间码已复制"); +}); + +function render() { + const self = player(state.selfId); + const current = player(state.currentPlayerId); + $("#copy-room").textContent = state.id; + $("#start").classList.toggle("hidden", state.phase !== "waiting" || state.hostId !== state.selfId); + + const titles = { + waiting: state.players.length < 3 ? `等待玩家 ${state.players.length}/3` : "三人到齐,可以开始", + playing: current ? `${current.name} 的回合` : "牌局进行中", + finished: state.winnerId ? `${player(state.winnerId)?.name ?? "玩家"} 获胜` : "游戏结束" + }; + $("#status-title").textContent = titles[state.phase]; + + $("#players").innerHTML = state.players.map((item) => ` +
+
+ ${escapeHtml(item.name)}${item.id === state.selfId ? "(你)" : ""} + ${item.score} 分 +
+
+ ${item.eliminated ? "已淘汰" : `${item.cardCount} 张牌`} + ${item.connected ? "在线" : "离线"} +
+
+ `).join(""); + + const hand = self?.hand ?? []; + $("#hand-total").textContent = `${hand.length} 张`; + $("#hand").innerHTML = hand.length + ? hand.map((value) => `
${value}
`).join("") + : `${state.phase === "waiting" ? "游戏开始后发牌" : "没有手牌"}`; + + $("#logs").innerHTML = [...state.logs].reverse().map((log) => ` +
  • ${escapeHtml(log.message)}
  • + `).join(""); + + renderActions(); +} + +function renderActions() { + const area = $("#actions"); + const self = player(state.selfId); + const myTurn = state.currentPlayerId === state.selfId; + $("#turn-note").textContent = state.phase === "playing" ? (myTurn ? "轮到你行动" : "等待其他玩家") : ""; + + if (state.phase === "waiting") { + area.innerHTML = `

    ${state.players.length === 3 ? "由房主开始游戏" : "分享房间码,邀请另外两名玩家"}

    `; + return; + } + if (state.phase === "finished") { + area.innerHTML = `

    隐藏牌是 ${state.hidden?.join("、")}

    `; + return; + } + if (self.eliminated) { + area.innerHTML = `

    你已被淘汰,可以继续观看牌局。

    `; + return; + } + if (state.pending?.type === "request") { + renderRequestResponse(area); + return; + } + if (state.pending?.type === "guess") { + renderGuessResponse(area); + return; + } + if (!myTurn) { + area.innerHTML = `

    观察各玩家手牌数量的变化,等待你的回合。

    `; + return; + } + + const targets = state.players.filter((item) => item.id !== state.selfId && !item.eliminated); + area.innerHTML = ` +
    + + + +
    + ${actionMode === "guess" ? guessForm() : ` +
    + + + + +
    + `} + `; + area.querySelectorAll("[data-mode]").forEach((button) => { + button.addEventListener("click", () => { + actionMode = button.dataset.mode; + renderActions(); + }); + }); + $("#request-form")?.addEventListener("submit", (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + emit("game:request", { + targetId: form.get("targetId"), + values: [Number(form.get("first")), Number(form.get("second"))], + mode: actionMode + }); + }); + $("#guess-form")?.addEventListener("submit", sendGuess); +} + +function renderRequestResponse(area) { + const pending = state.pending; + if (pending.targetId !== state.selfId) { + area.innerHTML = `

    等待 ${escapeHtml(player(pending.targetId)?.name ?? "目标玩家")} 回应要牌请求。

    `; + return; + } + area.innerHTML = ` +
    +

    ${escapeHtml(player(pending.requesterId)?.name)} 请求数字 ${pending.values.join(" 或 ")} 的${pending.mode === "one" ? "一张牌" : "其中一种全部牌"}。

    +
    + ${pending.values.map((value) => ``).join("")} + +
    +
    + `; + area.querySelectorAll("[data-give]").forEach((button) => + button.addEventListener("click", () => emit("game:request-answer", { value: Number(button.dataset.give) })) + ); + area.querySelector("[data-decline]").addEventListener("click", () => emit("game:request-answer", { value: null })); +} + +function renderGuessResponse(area) { + const pending = state.pending; + const expectedPlayerId = pending.forcedPlayerId ?? pending.responderIds[pending.responseIndex]; + const guessList = pending.guesses.map((guess) => ` +
    + ${escapeHtml(player(guess.playerId)?.name)}:${guess.values.join("、")} + ${expectedPlayerId === state.selfId && guess.playerId !== state.selfId + ? `` + : ""} +
    + `).join(""); + + if (expectedPlayerId !== state.selfId) { + const waitingFor = player(expectedPlayerId)?.name ?? "其他玩家"; + area.innerHTML = ` +
    +
    ${guessList}
    +

    等待 ${escapeHtml(waitingFor)} 回应。

    +
    + `; + return; + } + + const forced = pending.forcedPlayerId === state.selfId; + area.innerHTML = ` +
    +
    ${guessList}
    + ${forced ? `

    你是本轮唯一放弃者,必须选择认同一项猜测或提出自己的猜测。

    ` : ""} +
    + + ${forced ? "" : ``} +
    + +
    + `; + area.querySelectorAll("[data-agree]").forEach((button) => + button.addEventListener("click", () => + emit("game:guess-answer", { choice: "agree", guesserId: button.dataset.agree }) + ) + ); + area.querySelectorAll("[data-choice]").forEach((button) => + button.addEventListener("click", () => emit("game:guess-answer", { choice: button.dataset.choice })) + ); + area.querySelector("[data-counter]").addEventListener("click", () => $("#counter-form").classList.remove("hidden")); + $("#counter-form").addEventListener("submit", (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + emit("game:guess-answer", { + choice: "counter", + values: [Number(form.get("first")), Number(form.get("second"))] + }); + }); +} + +function guessForm() { + return ` +
    + + + +
    + `; +} + +function sendGuess(event) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + emit("game:guess", { values: [Number(form.get("first")), Number(form.get("second"))] }); +} + +function escapeHtml(value = "") { + return String(value).replace(/[&<>"']/g, (character) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'" + })[character]); +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..7b2d53d --- /dev/null +++ b/public/index.html @@ -0,0 +1,86 @@ + + + + + + + 本草牌局 + + + +
    +
    +
    本草牌局
    +
    Materia Medica
    +
    +
    连接中
    +
    + +
    +
    +
    +

    三人推理牌局

    +

    藏起两张牌,读懂每一次交换。

    +

    牌号越大,数量越多。观察手牌数量的变化,推断桌面下的答案。

    +
    +
    + +
    + +
    + + +
    +
    +
    +
    + + +
    + +
    + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..1631849 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,219 @@ +:root { + color-scheme: light; + --ink: #17201d; + --muted: #66706c; + --paper: #f4f3ed; + --surface: #ffffff; + --line: #d8dcd7; + --green: #23664d; + --green-dark: #174735; + --red: #a14335; + --amber: #c49035; + --blue: #356e95; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + background: var(--paper); + color: var(--ink); +} + +button, input, select { font: inherit; } + +button { + min-height: 44px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); + color: var(--ink); + cursor: pointer; + font-weight: 700; +} + +button:hover:not(:disabled) { border-color: var(--green); } +button:disabled { cursor: not-allowed; opacity: .5; } + +button.primary { + border-color: var(--green); + background: var(--green); + color: white; +} + +button.danger { border-color: #d7aaa3; color: var(--red); } + +input, select { + width: 100%; + min-height: 44px; + border: 1px solid #cbd1cc; + border-radius: 6px; + background: white; + padding: 0 12px; + color: var(--ink); +} + +label { display: grid; gap: 7px; color: var(--muted); font-size: 14px; font-weight: 650; } +h1, h2, p { margin: 0; } +h1 { font-size: 42px; line-height: 1.12; } +h2 { font-size: 17px; } +.hidden { display: none !important; } + +.topbar { + height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 max(18px, env(safe-area-inset-left)); + background: var(--ink); + color: white; +} + +.brand { font-family: Georgia, serif; font-size: 21px; font-weight: 700; } +.subtitle { margin-top: 1px; color: #b9c4bf; font-size: 10px; text-transform: uppercase; } +.connection { font-size: 13px; color: #b9c4bf; } +.connection.online { color: #94d4b8; } + +main { width: min(1180px, 100%); margin: 0 auto; padding: 24px 18px 48px; } +.eyebrow { color: var(--green); font-size: 12px; font-weight: 800; text-transform: uppercase; } + +.entry-panel { + min-height: calc(100vh - 116px); + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(300px, .8fr); + align-items: center; + gap: 9vw; +} + +.entry-copy { display: grid; gap: 18px; max-width: 650px; } +.entry-copy > p:last-child { max-width: 520px; color: var(--muted); font-size: 17px; line-height: 1.7; } +.entry-form { display: grid; gap: 22px; padding: 28px; border: 1px solid var(--line); background: var(--surface); } +.entry-actions { display: grid; gap: 12px; } +.join-row { display: grid; grid-template-columns: 1fr 92px; gap: 8px; } + +.room-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 18px; + padding-bottom: 22px; +} + +.room-heading h1 { margin-top: 7px; font-size: 29px; } +.text-button { min-height: auto; padding: 0; border: 0; color: var(--green); background: transparent; } +.game-layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; } +.board-column { min-width: 0; display: grid; align-content: start; gap: 18px; } + +.players { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.player { + min-width: 0; + padding: 14px; + border: 1px solid var(--line); + border-left: 4px solid transparent; + background: var(--surface); +} +.player.current { border-left-color: var(--amber); } +.player.self { background: #f7fbf8; } +.player.eliminated { opacity: .52; } +.player-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; } +.player-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 800; } +.player-score { flex: 0 0 auto; color: var(--green); font-size: 13px; font-weight: 800; } +.player-meta { display: flex; justify-content: space-between; margin-top: 12px; color: var(--muted); font-size: 12px; } +.dot { width: 7px; height: 7px; display: inline-block; border-radius: 50%; background: #aeb5b1; } +.dot.online { background: #3b986d; } + +.hand-section, .action-panel, .log-panel { + border: 1px solid var(--line); + background: var(--surface); + padding: 16px; +} + +.section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.section-title span { color: var(--muted); font-size: 12px; } +.hand { display: flex; min-height: 94px; align-items: center; gap: 8px; overflow-x: auto; padding-top: 14px; } +.card { + width: 58px; + height: 78px; + flex: 0 0 58px; + display: grid; + place-items: center; + border: 1px solid #c9cec9; + border-radius: 6px; + background: #fffef9; + box-shadow: 0 3px 0 #dfe2dc; + font-family: Georgia, serif; + font-size: 29px; + font-weight: 700; +} +.empty { color: var(--muted); font-size: 14px; } + +.action-tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 14px; } +.action-tabs button { padding: 0 7px; font-size: 13px; } +.action-tabs button.active { border-color: var(--green); background: #eaf4ef; color: var(--green-dark); } +.action-form { display: grid; grid-template-columns: 1.2fr 1fr 1fr auto; gap: 8px; margin-top: 12px; align-items: end; } +.action-form label { min-width: 0; } +.action-form button { padding: 0 18px; } +.response-box { display: grid; gap: 12px; margin-top: 14px; } +.response-copy { color: var(--muted); line-height: 1.55; } +.response-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.response-actions button { padding: 0 16px; } +.guess-list { display: grid; gap: 8px; } +.guess-option { + min-height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid var(--line); + padding: 7px 8px 7px 12px; +} +.guess-option button { flex: 0 0 auto; min-height: 36px; padding: 0 14px; } +.guess-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; max-width: 270px; } + +.log-panel { min-height: 420px; align-self: stretch; } +.logs { display: grid; gap: 0; margin: 12px 0 0; padding: 0; list-style: none; } +.logs li { padding: 11px 0; border-top: 1px solid #eceeeb; color: #4d5853; font-size: 13px; line-height: 1.45; } +.logs time { display: block; margin-top: 3px; color: #939b97; font-size: 10px; } + +.toast { + position: fixed; + z-index: 10; + left: 50%; + bottom: calc(22px + env(safe-area-inset-bottom)); + max-width: calc(100vw - 32px); + transform: translate(-50%, 20px); + border-radius: 6px; + background: var(--ink); + color: white; + padding: 11px 16px; + opacity: 0; + pointer-events: none; + transition: .2s ease; +} +.toast.show { transform: translate(-50%, 0); opacity: 1; } + +@media (max-width: 800px) { + .entry-panel { grid-template-columns: 1fr; align-content: center; gap: 36px; } + .game-layout { grid-template-columns: 1fr; } + .log-panel { min-height: 0; } +} + +@media (max-width: 560px) { + main { padding: 18px 12px 36px; } + .topbar { height: 60px; } + .entry-panel { min-height: calc(100vh - 96px); } + .entry-copy h1 { font-size: 30px; } + .entry-form { padding: 18px; } + .room-heading { align-items: center; } + .room-heading h1 { font-size: 23px; } + .players { grid-template-columns: 1fr; } + .player { padding: 11px 12px; } + .player-meta { margin-top: 8px; } + .hand-section, .action-panel, .log-panel { padding: 14px; } + .action-form { grid-template-columns: 1fr 1fr; } + .action-form label:first-child { grid-column: 1 / -1; } + .action-form button { grid-column: 1 / -1; } + .action-tabs button { min-height: 48px; } +} diff --git a/src/game.js b/src/game.js new file mode 100644 index 0000000..ba90135 --- /dev/null +++ b/src/game.js @@ -0,0 +1,385 @@ +import crypto from "node:crypto"; + +export const CARD_VALUES = [2, 3, 4, 5]; +export const PLAYER_COUNT = 3; + +const makeId = (length = 6) => + crypto.randomBytes(length).toString("base64url").slice(0, length).toUpperCase(); + +const shuffle = (cards, random = Math.random) => { + const result = [...cards]; + for (let index = result.length - 1; index > 0; index -= 1) { + const target = Math.floor(random() * (index + 1)); + [result[index], result[target]] = [result[target], result[index]]; + } + return result; +}; + +const countCards = (cards) => + CARD_VALUES.reduce((counts, value) => { + counts[value] = cards.filter((card) => card === value).length; + return counts; + }, {}); + +const normalizePair = (values) => { + if (!Array.isArray(values) || values.length !== 2) { + throw new Error("必须选择两种牌"); + } + const pair = values.map(Number); + if (pair.some((value) => !CARD_VALUES.includes(value)) || pair[0] === pair[1]) { + throw new Error("请选择两种不同的有效牌"); + } + return pair.sort((a, b) => a - b); +}; + +const normalizeGuess = (values) => { + if (!Array.isArray(values) || values.length !== 2) { + throw new Error("必须猜两张牌"); + } + const guess = values.map(Number).sort((a, b) => a - b); + if (guess.some((value) => !CARD_VALUES.includes(value))) { + throw new Error("猜测中包含无效牌"); + } + return guess; +}; + +const activePlayers = (room) => room.players.filter((player) => !player.eliminated); +const playerById = (room, playerId) => room?.players?.find((player) => player.id === playerId); + +const assertTurn = (room, playerId) => { + if (room.phase !== "playing") throw new Error("现在不能执行回合行动"); + if (room.pending) throw new Error("请先完成当前待处理行动"); + if (activePlayers(room)[room.turnIndex]?.id !== playerId) throw new Error("还没有轮到你"); +}; + +const addLog = (room, message) => { + room.logs.push({ id: makeId(10), message, at: new Date().toISOString() }); + room.logs = room.logs.slice(-80); +}; + +const advanceTurn = (room, fromPlayerId) => { + const players = activePlayers(room); + if (players.length < 2) { + room.phase = "finished"; + room.winnerId = players[0]?.id ?? null; + return; + } + const seatIndex = room.players.findIndex((player) => player.id === fromPlayerId); + let nextPlayer = null; + for (let offset = 1; offset <= room.players.length; offset += 1) { + const candidate = room.players[(seatIndex + offset) % room.players.length]; + if (!candidate.eliminated) { + nextPlayer = candidate; + break; + } + } + room.turnIndex = players.findIndex((player) => player.id === nextPlayer?.id); +}; + +export class GameStore { + constructor({ random = Math.random } = {}) { + this.rooms = new Map(); + this.random = random; + } + + createRoom(name) { + const room = { + id: this.uniqueRoomId(), + phase: "waiting", + players: [], + hidden: [], + turnIndex: 0, + pending: null, + logs: [], + winnerId: null + }; + this.rooms.set(room.id, room); + const session = this.joinRoom(room.id, name); + return { ...session, room }; + } + + uniqueRoomId() { + let id = makeId(6); + while (this.rooms.has(id)) id = makeId(6); + return id; + } + + joinRoom(roomId, rawName) { + const room = this.rooms.get(String(roomId).trim().toUpperCase()); + if (!room) throw new Error("房间不存在"); + if (room.phase !== "waiting") throw new Error("游戏已经开始"); + if (room.players.length >= PLAYER_COUNT) throw new Error("房间已满"); + const name = String(rawName ?? "").trim().slice(0, 18); + if (!name) throw new Error("请输入玩家名称"); + if (room.players.some((player) => player.name === name)) throw new Error("玩家名称已被使用"); + + const player = { + id: makeId(10), + token: crypto.randomBytes(24).toString("base64url"), + name, + hand: [], + score: 0, + eliminated: false, + connected: true, + lastAction: null + }; + room.players.push(player); + addLog(room, `${name} 加入了房间`); + return { room, playerId: player.id, token: player.token }; + } + + resume(roomId, token) { + const room = this.rooms.get(String(roomId).trim().toUpperCase()); + const player = room?.players.find((item) => item.token === token); + if (!room || !player) throw new Error("会话已失效"); + player.connected = true; + return { room, playerId: player.id, token: player.token }; + } + + disconnect(roomId, playerId) { + const player = playerById(this.rooms.get(roomId) ?? {}, playerId); + if (player) player.connected = false; + } + + start(roomId, playerId) { + const room = this.rooms.get(roomId); + if (!room) throw new Error("房间不存在"); + if (room.players[0]?.id !== playerId) throw new Error("只有房主可以开始"); + if (room.players.length !== PLAYER_COUNT) throw new Error("需要3名玩家"); + if (room.phase !== "waiting") throw new Error("游戏已经开始"); + + const deck = CARD_VALUES.flatMap((value) => Array(value).fill(value)); + const cards = shuffle(deck, this.random); + room.hidden = cards.slice(0, 2).sort((a, b) => a - b); + const dealt = cards.slice(2); + room.players.forEach((player, index) => { + player.hand = dealt.slice(index * 4, index * 4 + 4).sort((a, b) => a - b); + player.lastAction = null; + }); + room.phase = "playing"; + room.turnIndex = 0; + addLog(room, "游戏开始,每位玩家获得4张牌"); + return room; + } + + requestCards(roomId, playerId, { targetId, values, mode }) { + const room = this.rooms.get(roomId); + if (!room) throw new Error("房间不存在"); + assertTurn(room, playerId); + if (!["one", "all"].includes(mode)) throw new Error("无效行动"); + const target = playerById(room, targetId); + if (!target || target.id === playerId || target.eliminated) throw new Error("目标玩家无效"); + const pair = normalizePair(values); + const requester = playerById(room, playerId); + if ( + requester.lastAction?.type === "request" && + requester.lastAction.values[0] === pair[0] && + requester.lastAction.values[1] === pair[1] + ) { + throw new Error("不能连续两个回合请求相同的两种牌"); + } + requester.lastAction = { type: "request", values: pair }; + room.pending = { type: "request", requesterId: playerId, targetId, values: pair, mode }; + const requestName = mode === "one" ? "取一张" : "取同号全部"; + addLog( + room, + `${requester.name} 向 ${target.name} 提出了“${requestName}”请求:数字 ${pair.join(" 或 ")}` + ); + return room; + } + + answerRequest(roomId, playerId, { value }) { + const room = this.rooms.get(roomId); + const pending = room?.pending; + if (!room || pending?.type !== "request") throw new Error("没有待回应的要牌请求"); + if (pending.targetId !== playerId) throw new Error("这不是给你的请求"); + const requester = playerById(room, pending.requesterId); + const target = playerById(room, pending.targetId); + const counts = countCards(target.hand); + const hasFirst = counts[pending.values[0]] > 0; + const hasSecond = counts[pending.values[1]] > 0; + const mayDecline = pending.mode === "one" ? !hasFirst && !hasSecond : !hasFirst || !hasSecond; + + if (value === null || value === undefined) { + if (!mayDecline) throw new Error("你持有符合条件的牌,不能拒绝"); + room.pending = null; + addLog(room, `${target.name} 没有交出牌`); + advanceTurn(room, pending.requesterId); + return { room, privateEvents: [] }; + } + + const selected = Number(value); + if (!pending.values.includes(selected)) throw new Error("只能选择被请求的牌"); + if (counts[selected] < 1) throw new Error("你没有这张牌"); + if (pending.mode === "all" && (!hasFirst || !hasSecond)) { + throw new Error("未同时持有两种牌时只能拒绝"); + } + + const amount = pending.mode === "one" ? 1 : counts[selected]; + let remaining = amount; + target.hand = target.hand.filter((card) => { + if (card === selected && remaining > 0) { + remaining -= 1; + return false; + } + return true; + }); + requester.hand.push(...Array(amount).fill(selected)); + requester.hand.sort((a, b) => a - b); + room.pending = null; + addLog(room, `${target.name} 向 ${requester.name} 交出了 ${amount} 张牌`); + advanceTurn(room, pending.requesterId); + return { + room, + privateEvents: [ + { playerId: target.id, message: `你交出了 ${amount} 张数字 ${selected}` }, + { playerId: requester.id, message: `你获得了 ${amount} 张数字 ${selected}` } + ] + }; + } + + proposeGuess(roomId, playerId, values) { + const room = this.rooms.get(roomId); + if (!room) throw new Error("房间不存在"); + assertTurn(room, playerId); + const players = activePlayers(room); + const proposerIndex = players.findIndex((player) => player.id === playerId); + const responderIds = players + .slice(proposerIndex + 1) + .concat(players.slice(0, proposerIndex)) + .map((player) => player.id); + const guess = normalizeGuess(values); + playerById(room, playerId).lastAction = { type: "guess" }; + room.pending = { + type: "guess", + initiatorId: playerId, + guesses: [{ playerId, values: guess }], + responderIds, + responseIndex: 0, + responses: {}, + forcedPlayerId: null + }; + addLog(room, `${playerById(room, playerId).name} 猜隐藏牌是 ${guess.join("、")}`); + return room; + } + + answerGuess(roomId, playerId, { choice, values, guesserId }) { + const room = this.rooms.get(roomId); + const pending = room?.pending; + if (!room || pending?.type !== "guess") throw new Error("当前没有猜牌提案"); + const player = playerById(room, playerId); + if (!player || player.eliminated) throw new Error("玩家无效"); + const expectedPlayerId = pending.forcedPlayerId ?? pending.responderIds[pending.responseIndex]; + if (expectedPlayerId !== playerId) throw new Error("还没有轮到你回应"); + if (!["agree", "abandon", "counter"].includes(choice)) throw new Error("无效回应"); + if (pending.forcedPlayerId && choice === "abandon") throw new Error("本次必须选择认同或另猜"); + + if (choice === "counter") { + const guess = normalizeGuess(values); + pending.guesses.push({ playerId, values: guess }); + pending.responses[playerId] = { choice: "counter", guesserId: playerId }; + addLog(room, `${player.name} 另猜隐藏牌是 ${guess.join("、")}`); + } else if (choice === "agree") { + const selectedGuess = pending.guesses.find((guess) => guess.playerId === guesserId); + if (!selectedGuess || selectedGuess.playerId === playerId) throw new Error("请选择要认同的猜测"); + pending.responses[playerId] = { choice: "agree", guesserId: selectedGuess.playerId }; + addLog(room, `${player.name} 认同 ${playerById(room, selectedGuess.playerId).name} 的猜测`); + } else { + pending.responses[playerId] = { choice: "abandon" }; + addLog(room, `${player.name} 选择了放弃`); + } + + if (pending.forcedPlayerId) { + pending.forcedPlayerId = null; + return this.resolveGuesses(room, pending); + } + + pending.responseIndex += 1; + if (pending.responseIndex < pending.responderIds.length) return { room, resolved: false }; + + const abandonedIds = pending.responderIds.filter( + (responderId) => pending.responses[responderId]?.choice === "abandon" + ); + if (abandonedIds.length === 1) { + pending.forcedPlayerId = abandonedIds[0]; + delete pending.responses[abandonedIds[0]]; + addLog(room, `${playerById(room, abandonedIds[0]).name} 必须重新选择认同或另猜`); + return { room, resolved: false }; + } + + return this.resolveGuesses(room, pending); + } + + resolveGuesses(room, pending) { + const results = pending.guesses.map((guess) => ({ + ...guess, + correct: guess.values[0] === room.hidden[0] && guess.values[1] === room.hidden[1] + })); + const correctGuesses = results.filter((guess) => guess.correct); + const allAbandoned = pending.responderIds.every( + (playerId) => pending.responses[playerId]?.choice === "abandon" + ); + + results.forEach((guess) => { + playerById(room, guess.playerId).score += guess.correct ? 3 : -1; + }); + Object.entries(pending.responses).forEach(([playerId, response]) => { + if (response.choice === "agree" && correctGuesses.some((guess) => guess.playerId === response.guesserId)) { + playerById(room, playerId).score += 1; + } + }); + room.pending = null; + + if (correctGuesses.length > 0) { + room.phase = "finished"; + room.winnerId = correctGuesses[0].playerId; + addLog(room, `${playerById(room, correctGuesses[0].playerId).name} 猜对了隐藏牌,游戏结束`); + return { room, resolved: true, correct: true }; + } + + if (allAbandoned) { + const initiator = playerById(room, pending.initiatorId); + initiator.eliminated = true; + addLog(room, `${initiator.name} 猜测错误并被淘汰`); + } else { + addLog(room, "本轮所有猜测均错误,游戏继续"); + } + advanceTurn(room, pending.initiatorId); + return { room, resolved: true, correct: false }; + } + + view(room, viewerId) { + const viewer = playerById(room, viewerId); + if (!viewer) throw new Error("玩家不存在"); + const current = activePlayers(room)[room.turnIndex] ?? null; + const pending = room.pending + ? { + ...room.pending, + values: + room.pending.type === "guess" || room.pending.targetId === viewerId || room.pending.requesterId === viewerId + ? room.pending.values + : undefined + } + : null; + return { + id: room.id, + phase: room.phase, + selfId: viewerId, + hostId: room.players[0]?.id, + currentPlayerId: current?.id ?? null, + pending, + hidden: room.phase === "finished" ? room.hidden : null, + winnerId: room.winnerId, + players: room.players.map((player) => ({ + id: player.id, + name: player.name, + score: player.score, + eliminated: player.eliminated, + connected: player.connected, + cardCount: player.hand.length, + hand: player.id === viewerId ? player.hand : undefined + })), + logs: room.logs + }; + } +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..1504714 --- /dev/null +++ b/src/server.js @@ -0,0 +1,125 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import express from "express"; +import { createServer } from "node:http"; +import { Server } from "socket.io"; +import { GameStore } from "./game.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const app = express(); +const httpServer = createServer(app); +const io = new Server(httpServer, { + cors: { origin: process.env.NODE_ENV === "production" ? false : true } +}); +const store = new GameStore(); +const port = Number(process.env.PORT || 3000); + +app.disable("x-powered-by"); +app.get("/health", (_request, response) => response.json({ ok: true })); +app.use(express.static(path.join(__dirname, "..", "public"))); +app.use((_request, response) => response.sendFile(path.join(__dirname, "..", "public", "index.html"))); + +const emitRoom = (room) => { + room.players.forEach((player) => { + io.to(`${room.id}:${player.id}`).emit("room:state", store.view(room, player.id)); + }); +}; + +const handle = (socket, callback) => { + try { + callback(); + } catch (error) { + socket.emit("error:message", error.message || "操作失败"); + } +}; + +const authenticate = (socket) => { + const { roomId, playerId } = socket.data; + const room = store.rooms.get(roomId); + if (!room || !room.players.some((player) => player.id === playerId)) throw new Error("请先加入房间"); + return { room, roomId, playerId }; +}; + +io.on("connection", (socket) => { + socket.on("room:create", ({ name } = {}) => + handle(socket, () => { + const session = store.createRoom(name); + socket.data = { roomId: session.room.id, playerId: session.playerId }; + socket.join(`${session.room.id}:${session.playerId}`); + socket.emit("session", { roomId: session.room.id, playerId: session.playerId, token: session.token }); + emitRoom(session.room); + }) + ); + + socket.on("room:join", ({ roomId, name } = {}) => + handle(socket, () => { + const session = store.joinRoom(roomId, name); + socket.data = { roomId: session.room.id, playerId: session.playerId }; + socket.join(`${session.room.id}:${session.playerId}`); + socket.emit("session", { roomId: session.room.id, playerId: session.playerId, token: session.token }); + emitRoom(session.room); + }) + ); + + socket.on("session:resume", ({ roomId, token } = {}) => + handle(socket, () => { + const session = store.resume(roomId, token); + socket.data = { roomId: session.room.id, playerId: session.playerId }; + socket.join(`${session.room.id}:${session.playerId}`); + socket.emit("session", { roomId: session.room.id, playerId: session.playerId, token: session.token }); + emitRoom(session.room); + }) + ); + + socket.on("game:start", () => + handle(socket, () => { + const { roomId, playerId } = authenticate(socket); + emitRoom(store.start(roomId, playerId)); + }) + ); + + socket.on("game:request", (payload) => + handle(socket, () => { + const { roomId, playerId } = authenticate(socket); + emitRoom(store.requestCards(roomId, playerId, payload)); + }) + ); + + socket.on("game:request-answer", (payload) => + handle(socket, () => { + const { roomId, playerId } = authenticate(socket); + const result = store.answerRequest(roomId, playerId, payload); + result.privateEvents.forEach((event) => { + io.to(`${roomId}:${event.playerId}`).emit("private:message", event.message); + }); + emitRoom(result.room); + }) + ); + + socket.on("game:guess", ({ values } = {}) => + handle(socket, () => { + const { roomId, playerId } = authenticate(socket); + emitRoom(store.proposeGuess(roomId, playerId, values)); + }) + ); + + socket.on("game:guess-answer", (payload) => + handle(socket, () => { + const { roomId, playerId } = authenticate(socket); + emitRoom(store.answerGuess(roomId, playerId, payload).room); + }) + ); + + socket.on("disconnect", () => { + const { roomId, playerId } = socket.data; + if (roomId && playerId) { + store.disconnect(roomId, playerId); + const room = store.rooms.get(roomId); + if (room) emitRoom(room); + } + }); +}); + +httpServer.listen(port, "0.0.0.0", () => { + console.log(`Materia Medica listening on ${port}`); +}); diff --git a/test/game.test.js b/test/game.test.js new file mode 100644 index 0000000..e67b65f --- /dev/null +++ b/test/game.test.js @@ -0,0 +1,127 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { GameStore } from "../src/game.js"; + +const setup = () => { + const store = new GameStore({ random: () => 0.42 }); + const first = store.createRoom("甲"); + const second = store.joinRoom(first.room.id, "乙"); + const third = store.joinRoom(first.room.id, "丙"); + store.start(first.room.id, first.playerId); + return { store, room: first.room, ids: [first.playerId, second.playerId, third.playerId] }; +}; + +test("deals two hidden cards and four cards to every player", () => { + const { room } = setup(); + assert.equal(room.hidden.length, 2); + assert.deepEqual(room.players.map((player) => player.hand.length), [4, 4, 4]); + assert.equal([...room.hidden, ...room.players.flatMap((player) => player.hand)].length, 14); +}); + +test("a room view never reveals another player's hand or hidden cards", () => { + const { store, room, ids } = setup(); + const view = store.view(room, ids[0]); + assert.equal(view.hidden, null); + assert.ok(Array.isArray(view.players[0].hand)); + assert.equal(view.players[1].hand, undefined); + assert.equal(view.players[2].hand, undefined); +}); + +test("one-card request logs both requested values but hides the transferred value", () => { + const { store, room, ids } = setup(); + room.players[1].hand = [2, 3, 4, 5]; + store.requestCards(room.id, ids[0], { targetId: ids[1], values: [2, 3], mode: "one" }); + assert.match(room.logs.at(-1).message, /“取一张”请求/); + assert.match(room.logs.at(-1).message, /数字 2 或 3/); + const result = store.answerRequest(room.id, ids[1], { value: 3 }); + assert.equal(room.players[0].hand.at(-1) >= 2, true); + assert.equal(room.players[1].hand.includes(3), false); + assert.match(room.logs.at(-1).message, /交出了 1 张牌/); + assert.doesNotMatch(room.logs.at(-1).message, /数字 3/); + assert.equal(result.privateEvents.length, 2); +}); + +test("all-card request can be declined unless target holds both values", () => { + const { store, room, ids } = setup(); + room.players[1].hand = [2, 2, 4, 5]; + store.requestCards(room.id, ids[0], { targetId: ids[1], values: [2, 3], mode: "all" }); + assert.match(room.logs.at(-1).message, /“取同号全部”请求/); + assert.match(room.logs.at(-1).message, /数字 2 或 3/); + assert.doesNotThrow(() => store.answerRequest(room.id, ids[1], { value: null })); +}); + +test("a player cannot request the same unordered pair on consecutive personal turns", () => { + const { store, room, ids } = setup(); + room.players.forEach((player) => { + player.hand = [4, 4, 5, 5]; + }); + + store.requestCards(room.id, ids[0], { targetId: ids[1], values: [2, 3], mode: "one" }); + store.answerRequest(room.id, ids[1], { value: null }); + store.requestCards(room.id, ids[1], { targetId: ids[2], values: [2, 4], mode: "one" }); + store.answerRequest(room.id, ids[2], { value: 4 }); + store.requestCards(room.id, ids[2], { targetId: ids[0], values: [3, 5], mode: "one" }); + store.answerRequest(room.id, ids[0], { value: 5 }); + + assert.throws( + () => store.requestCards(room.id, ids[0], { targetId: ids[2], values: [3, 2], mode: "all" }), + /不能连续两个回合请求相同的两种牌/ + ); + assert.doesNotThrow(() => + store.requestCards(room.id, ids[0], { targetId: ids[2], values: [2, 4], mode: "all" }) + ); +}); + +test("correct guess scores and finishes game", () => { + const { store, room, ids } = setup(); + store.proposeGuess(room.id, ids[0], room.hidden); + store.answerGuess(room.id, ids[1], { choice: "agree", guesserId: ids[0] }); + const result = store.answerGuess(room.id, ids[2], { choice: "agree", guesserId: ids[0] }); + assert.equal(result.correct, true); + assert.equal(room.phase, "finished"); + assert.equal(room.players[0].score, 3); + assert.equal(room.players[1].score, 1); + assert.equal(room.players[2].score, 1); +}); + +test("a later player can agree with a counter guess", () => { + const { store, room, ids } = setup(); + const wrongGuess = room.hidden[0] === 2 && room.hidden[1] === 2 ? [2, 3] : [2, 2]; + store.proposeGuess(room.id, ids[0], wrongGuess); + store.answerGuess(room.id, ids[1], { choice: "counter", values: room.hidden }); + const result = store.answerGuess(room.id, ids[2], { choice: "agree", guesserId: ids[1] }); + assert.equal(result.correct, true); + assert.equal(room.players[0].score, -1); + assert.equal(room.players[1].score, 3); + assert.equal(room.players[2].score, 1); +}); + +test("the only abandoning player must agree or counter on a second action", () => { + const { store, room, ids } = setup(); + store.proposeGuess(room.id, ids[0], room.hidden); + store.answerGuess(room.id, ids[1], { choice: "abandon" }); + const firstRound = store.answerGuess(room.id, ids[2], { choice: "agree", guesserId: ids[0] }); + assert.equal(firstRound.resolved, false); + assert.equal(room.pending.forcedPlayerId, ids[1]); + assert.throws( + () => store.answerGuess(room.id, ids[1], { choice: "abandon" }), + /必须选择认同或另猜/ + ); + const result = store.answerGuess(room.id, ids[1], { choice: "agree", guesserId: ids[0] }); + assert.equal(result.correct, true); + assert.equal(room.players[1].score, 1); +}); + +test("if both later players abandon, a wrong initiator is the only player eliminated", () => { + const { store, room, ids } = setup(); + const wrongGuess = room.hidden[0] === 2 && room.hidden[1] === 2 ? [2, 3] : [2, 2]; + store.proposeGuess(room.id, ids[0], wrongGuess); + store.answerGuess(room.id, ids[1], { choice: "abandon" }); + const result = store.answerGuess(room.id, ids[2], { choice: "abandon" }); + assert.equal(result.correct, false); + assert.equal(room.players[0].eliminated, true); + assert.equal(room.players[1].eliminated, false); + assert.equal(room.players[2].eliminated, false); + assert.equal(room.currentPlayerId, undefined); + assert.equal(store.view(room, ids[1]).currentPlayerId, ids[1]); +});