commit 96a6a9996324e5b0edbab5858187c441db5a7321 Author: maths81 Date: Thu Jun 11 22:13:55 2026 +0800 Initial commit 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]); +});