Skip to content

About

六十分 (Sixty Points):线上 3 人 1v2 扑克牌 —— 一副 54 张,桥牌式叫牌定庄定将,双升式打牌与升级赛程。内置 16 个 MCP 工具,可以请 LLM 打牌。无需密码即可登录,Gitee 也可。Docker 一键部署,使用 SvelteKit 2 / SSE / SQLite 单实例服务端。

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

六十分(Sixty Points)线上版

三人 1v2 的顺子类牌戏:一副 54 张,桥牌式叫牌定庄定将,双升式打牌与升级赛程。线上实现为 SvelteKit + SSE 单实例应用,规则引擎是零依赖纯 TypeScript 包并有完整单测/属性测试。

CI License: Apache 2.0 Tests Last commit Node pnpm

TypeScript Svelte SvelteKit Tailwind CSS SQLite Docker image

目录

packages/engine     纯 TS 规则引擎(零依赖,可被服务端与客户端共用)
  src/              cards / order / validate / trick / auction / state / view / help
  test/             node:test 表驱动 + 属性测试(默认 60 个随机整局,GAME_SEEDS 可放大)
packages/bot        机器人策略(零依赖,只 import 引擎;纯函数「个人视图 → 动作」,见 ADR-0015)
  src/              policy(叫牌/埋底/出牌)/ sight(记牌与拆链)/ sim(机器重演:结算过的牌重打一遍)
  test/             表驱动单测 + 整局属性测试(三个机器人直驱引擎打完整场)+ 纯净守卫 + 重演自洽
packages/mcp        MCP 工具面(stdio 入口 + 与传输无关的工具定义,两条传输共用)
  src/              tools(工具表)/ server(挂到 SDK)/ http-api(stdio 取数)/ bootstrap(启动判定)/ wire / stdio
  test/             node:test:工具层单测 + 内存链对上的协议单测 + stdio 启动契约
  scripts/          mcp-check.ts(两条传输各打一整副 + 形状/在线/权威守卫)
apps/web            SvelteKit 2 + Svelte 5 + Tailwind 4 + adapter-node
  src/lib/server/   SQLite(node:sqlite)、身份凭据、Gitee 会话与管理面、牌桌服务、SSE hub、
                    MCP 进程内适配层、机器人调度、机器重演存档(replays,结算时同步算好,见 ADR-0016)
  src/routes/       大厅、牌桌页、新手引导(/learn)、文字教程(/rules)、管理面(/admin)、
                    REST 动作接口(含入座/离座/加机器人)、Gitee OAuth(/api/auth/gitee/*)、SSE 流、MCP(/api/mcp)
  src/lib/tutorial/ 概念示例数据(scenarios.ts,被引擎逐个核对)
  src/lib/guide/    新手引导的内核:课程结构(course.ts)、引导局客户端回放与强制交互、测验判分
  src/lib/guide/deals/  由 docs/guide 生成的引导局数据(勿手改,见 ADR-0025)
  scripts/          smoke.ts(三人 HTTP 端到端 + 机器重演存档逐副核对)、resume-check.ts(重启续局校验)
                    lobby-check.ts(开局入口回归)、ui-check.ts(版面与文案守卫)
                    spectate-check.ts(观战/离座/改名换身份的端到端回归)
                    bot-check.ts(1 人 + 2 机器人打两副 + 中途踢/补的端到端)
                    shot-auction.ts(版面**几何**验收:自拉 vite dev + 无头 Chromium,截图并实测关键几何后断言)
                    shot-gitee.ts(Gitee 登录 / 登出弹窗 / 管理面的验收截图:自拉 dev、往库里种一条 Gitee 会话,
                                   并把「Gitee 身份卡上没有『复制凭据』」「登出确认是覆盖全屏的 fixed 弹窗」
                                   「Gitee 入口是官方 SVG 小图标」钉成断言)
                    build-guide.ts(引导局 JSON 校验 + 生成页面数据)、guide-extract.ts(db 副本 → 候选真实牌局清单)
CONTEXT.md          领域词汇表(术语与已敲定的规则歧义)
docs/adr/           架构决策记录
docs/guide/         引导局:<slug>.json(真实牌局提取的事实 + 引导词;candidates/ 不提交)
tooling/            workspace-loader.mjs(`node --test` 的解析钩子:`@sixty/*` → `packages/*/src`)

规则实现要点

  • 一副牌无对子,多张出牌即「单张顺子」;副牌顺子跳过级牌,主牌顺子按全序 …A < 副级 < 主级 < 小王 < 大王 相邻,且至多含一张副级牌。
  • 打牌中三张副级牌完全相等(花色序只用于叫牌),平张先出为大。
  • 跟牌「结构优先」:所选 n 张的连续段分解必须字典序最大(领 4 顺持 3+2 必出 3 顺 + 任 1 张)。 实现见 packages/engine/src/order.ts 的 segments/bestProfile(按最长连续链贪心抽取)。
  • 结算:庄家抓分 ± 底牌分 × 末轮张数;打成按 40/60/70/80/90+ 升级表,打输则两名闲家各升 ceil(差/10) 级;两家达 2(+2) 或一家达 2(+3) 结束,总进度最高者为冠军。

细节与全部已敲定决策见 CONTEXT.md;架构理由见 docs/adr/。

本地运行

pnpm install          # 工作区依赖(esbuild 的构建脚本已显式关闭,见 ADR-0004)
pnpm test             # 规则引擎测试(node:test,零额外依赖)
pnpm test:bot         # 机器人策略测试:叫牌/埋底/出牌表驱动单测 + 整局属性测试(3 个机器人打完一场)
                      #                 + 纯净守卫(策略只许依赖引擎、无 IO/随机)
pnpm test:web         # 前端纯函数测试:扇形布局 / 邀请码解析 / 阶段说明 / 牌面映射 / 结算门槛 / 教程示例 /
                      #                   新手引导(课程结构·引导局回放·强制两类拒绝·测验判分·取数路径·生成物守卫)/
                      #                   说明标签 /
                      #                   角色与观战投影 + 在线态判定 + MCP 作弊面守卫 + 叫牌面板可达性(唯一滚动区、「不叫」钉底)
                      #                 + 机器人座位的进程内集成(延迟调零:打完一副、补位、权限与上限、
                      #                   踢出语义、身份认不出、重启自愈)+ 机器人作弊面守卫
pnpm test:mcp         # MCP 工具层单测:工具语义、参数校验、等待超时、协议注册一致性、stdio 启动契约
pnpm check            # 引擎 tsc + 机器人包 tsc + MCP 包 tsc + 应用 svelte-check
pnpm dev              # SvelteKit 开发服务器(默认 http://localhost:5173)
pnpm build            # 产出 apps/web/build(adapter-node)
pnpm start            # 跑构建产物(默认端口 3000,见下)
pnpm guide           # 把 docs/guide/*.json 校验后生成引导局页面数据(deals/<slug>.ts)
pnpm guide:check     # 只校验:仓库里的生成物是否等于现场重算(不改文件)
pnpm guide:extract   # 从 data/guide-source.db 副本提取候选真实牌局(三人非 bot、已结算)到 docs/guide/candidates/

改引导局内容的流程:pnpm guide:extract 列候选 → 把选中的候选挪成 docs/guide/<slug>.json 并补引导词(用户座位每个动作一句)与复盘要点,然后 pnpm guide(生成物提交进仓库,CI 与镜像构建不需要跑脚本)。 pnpm guide:check 会在「手改生成物」「事实与引擎重放不一致」或「用户动作缺引导词」时报红。

环境变量:PORT(默认 3000)、HOST、SIXTY_DB(默认 <cwd>/data/sixty.db)。

Gitee 登录与管理员(可选功能,见 ADR-0023 / ADR-0024)

不配这些变量时,整个功能隐身:大厅的 Gitee 入口不渲染,/api/auth/gitee/* 与 /admin 一律 404。

变量 作用
SIXTY_GITEE_CLIENT_ID Gitee 开放平台应用的 Client ID。留空 = 关闭 Gitee 登录
SIXTY_GITEE_CLIENT_SECRET 同一个应用的 Client Secret
SIXTY_GITEE_STATE_SECRET 可选:OAuth state 的 HMAC 密钥(不设则从 client secret 派生)
SIXTY_ADMIN_GITEE_IDS 管理员名单:逗号分隔的 Gitee 数字 id。留空 = 没有管理员

启用步骤:

  1. 到 Gitee「设置 → 数据管理 → 第三方应用」创建应用,回调地址填 https://<你的域名>/api/auth/gitee/callback(本地开发填 http://127.0.0.1:5173/api/auth/gitee/callback)。 它必须与页面实际访问的 origin 逐字一致 —— 反代部署请固定 ORIGIN(见下文的警示)。

  2. 把 Client ID / Secret 填进上面两个变量。

  3. 想用管理面板:先用 Gitee 登录一次,再在 /admin 里对着自己那行的 #id —— 那是站内 id,不是 Gitee id。Gitee id 是 /api/v5/user 里的数字 id, 也就是登录后 users.gitee_id 那一列的值:

    sqlite3 data/sixty.db 'SELECT id, name, gitee_id FROM users WHERE gitee_id IS NOT NULL'

    把 SIXTY_ADMIN_GITEE_IDS 填成那个 gitee_id,重启即生效。

Gitee 身份与本地身份是两种东西。 本地身份拿凭据串(可复制、跨设备迁移,ADR-0003); Gitee 身份拿会话(不可复制、可撤销,换设备再登一次)。所以 Gitee 身份没有「复制凭据」 这个动作,也不能驱动 MCP 工具面(MCP 只认凭据串)—— 想用 MCP 就另建一个本地身份。 管理员权限只挂在 Gitee 侧,这样权限不会随一串可复制的字符串流出去。

⚠️ 不要设置空的 ORIGIN。 留空(例如 Compose 里的 ORIGIN=${ORIGIN:-}、ORIGIN= 或 ORIGIN="")会让 adapter-node 直接拒绝启动: Error: Invalid ORIGIN: ''. ORIGIN must be a valid URL with http:// or https:// protocol. 不定义该变量时服务端会按请求头推导来源,反代部署正是想要这种行为;确实需要固定来源时,写成完整 URL (如 ORIGIN=https://game.example.com)。留空 ≠ 不设置,这是两回事。

端到端冒烟(需先启动服务端):

BASE=http://127.0.0.1:5178 pnpm --filter web smoke     # 3 个身份打完 N 副(DEALS=n),含 SSE 推送校验
BASE=http://127.0.0.1:5178 pnpm --filter web lobby     # 开局回归:未开局必须渲染「开始第一副」按钮 + 三个座位都列出玩家名
                                                       # 另含「未注册点邀请链接」:邀请码带进大厅、注册后回到原桌
BASE=http://127.0.0.1:5178 pnpm --filter web ui        # 版面/文案守卫:position 类不得混用、毡面是四行格(信息须 + 顶行两张对手卡
                                                       #   走正常文档流、不许自己定位),邀请码可点复制、常驻提示已清空、
                                                       # ? 按阶段给说明、无方位称谓、
                                                       # /rules 九个小节齐备且渲染真实牌面、
                                                       # 王牌面(名字只在角落索引、正中是 ☀/☾ 图案)、
                                                       # 出货样式表里不得再有牌角装饰点(.card.pt / .card.trump::after)、
                                                       # 叫牌面板是唯一滚动区、「不叫」在它之后、走正常文档流且不可 sticky
                                                       # 动作托盘住在操作条那一行中间(在流内、单行、宽度 max-content;
                                                       #   旧形状「floating 的一层 / 旧锚 bottom-full」会压住「我」的底栏)
                                                       # 收墩后的徽标只说「庄 +N 分 / 闲 +N 分」,信息须「回溯」格里那枚「上一轮」
                                                       #   按钮首轮是灰的(disabled)、收墩后可点(浮层默认关着,SSR 首帧里没有 data-trick-review)
                                                       # /learn 是新手引导课程(每步深链 ?s=<step-id>,服务端渲染出该步内容)、
                                                       #   大厅与 /rules 都指向它
BASE=http://127.0.0.1:5178 pnpm --filter web resume    # 重启服务端后再跑,校验 SQLite 续局
BASE=http://127.0.0.1:5178 pnpm --filter web spectate  # 观战/离座/改名换身份:满座第 4 人只看公共信息、
                                                       # 观战负载不含在座手牌、补位继承该座位的手牌与级别、
                                                       # 在座不能改名或换身份、改名后旧凭据失效
BASE=http://127.0.0.1:5178 pnpm mcp-check              # MCP 端到端:两条传输各打一整副(stdio 真 spawn + /api/mcp)
                                                       # 顺带核对 wire 形状、MCP 座位的在线态、以及非法出牌必被服务端拒绝
BASE=http://127.0.0.1:5178 pnpm bot-check              # 机器人端到端:1 人 + 2 机器人打两副;人类座位也由**同一份策略**出手,
                                                       # 所以顺带证明策略给出的动作真的被服务器接受;另含
                                                       # 未入座者加不了 / 第 3 个被上限挡住 / 踢出后本副停在空座 / 补位接着打完
pnpm shot                                              # 版面**几何**验收(自拉 vite dev + 无头 Chromium,不需要 BASE):
                                                       # 17 个场景逐个阶段截图并**量**关键几何后断言 —— 两行候选档位的同一列必须
                                                       #   对齐(360px 窄屏上是被 flex 压窄才错开的,390px 上量不出来)、
                                                       #   叫牌历史容量 ≥3 行、四行格互不相交、非座位层都在内容槽里、
                                                       #   出牌三簇互不相交、托盘整个落在操作条那一行里;
                                                       # 其中三个场景驱动**真 5 张顺子**(庄家领顺子、两家按引擎规则跟同样张数,
                                                       #   含一个 320×640 的最窄机型):
                                                       #   牌必须在自己那一点的预算盒内、间距不得超过紧凑上限(放得下也不摊开)、
                                                       #   两翼出牌点的宽度必须等于按张数算出来的那个值、中点必须落在内容槽中线上、
                                                       #   我那条底栏与顶部信息须**同宽**且居中(尺度 `--rail-w`:基准 256px / 桌面 288px)、
                                                       #   信息须那张表只许两行、每一格的内容都放得下一行(「分」不许掉到第二行)、
                                                       #   表头要有上边距、闹钟里三位数在 22px 圆里放得下
                                                       # 其中两个场景把一整副**打完**,长采样重建结算三拍的时间轴
                                                       #   (末墩 2.0s → 亮底牌 3.2s → 弹结算;亮底牌那一层与三个出牌点零相交;
                                                       #    点「跳过」提前看结算,而「下一副」仍锁到两拍之和 = 5.2s)
                                                       # 产物在 apps/web/.shots/

冒烟脚本用 data/smoke-run.json 保存凭据供续局校验使用。

受限沙箱(piped stdio 一律 EPERM,见 AGENTS 规则 5)里 mcp-check 要加 SPAWN=0: 它改用 SDK 的内存链对驱动同一套工具表,只是不起 stdio 子进程; stdio 那条进程级契约由 pnpm test:mcp 里的启动契约测试覆盖(同样需要能 spawn 子进程)。

bot-check 想跑快就给服务端把拟人延迟调零:SIXTY_BOT_DELAY_MIN_MS=0 SIXTY_BOT_DELAY_MAX_MS=0 (CI 就是这么跑的;默认 0.5–1.5 秒是给人看的节奏)。

界面约定

  • 说明只在一处:牌桌上的阶段玩法说明全部收在左下角的「?」弹层(内容与 /rules 教程同源, 见 packages/engine/src/help.ts);界面上不再有常驻提示文案,玩家不需要在三个角落各读一遍。
  • 三处讲解分工(见 CONTEXT.md):牌桌「?」弹层是打牌时的即时说明(按阶段给)、/learn 是新手引导 (交互课程:概念简述 + 测验 + 引导局,见 ADR-0025)、/rules 是逐段查证的长文。三者共用术语与被引擎核对过的示例, 不互相复制整段文本;/learn 是默认入口(大厅与 /rules 都指它),两边互链。
  • 玩家名而不是方位:文案里一律用玩家名或「你」(whoLabel),不出现「东/南/西家」—— 座位卡显示的就是名字,而座位在屏幕上没有锚点位置。
  • 毡面是四行格(ADR-0020 及其修订):[信息须][顶行:两张对手卡][内容槽][底行:「我」那条底栏]。 出牌区、埋底面板、叫牌面板、大厅面板全部住在中间那格(data-felt-row="slot")—— 它们的上下界由槽给出,自己一个偏移都不写。早先这些层各自绝对定位(top-[4.5rem] / top-[22%] max-h-[56%] / top-36…),每个都自己算偏移,于是手机短屏上状态条压住埋底槽位、 叫牌面板压住座位卡、动作托盘压住「我」那张卡。行格把这些变成结构上不可能: 非座位层出不了内容槽,而四道行边界互不相交。毡面的列模版钉成 grid-cols-1(minmax(0,1fr)): 内容再宽也只在自己那一格溢出,不会把轨道撑宽(否则埋底面板会把各行一起撑开,「我」那条底栏跟着右偏)。 这张版面源码守卫看不见几何,所以除源码/出货 HTML 守卫外还有 pnpm shot 的实测断言。
  • 信息须(ADR-0020 修订):毡面第一行是一小块类表格 —— 表头 回溯 / 定约 / 级牌 / 庄已抓, 值在下面一行(40♣ / 2 / 15 分 / 「上一轮」按钮)。采它是台面级信息,所以从内容槽里搬了出来。 它带着毡面一条宽度尺度(--rail-w:基准 256px、sm 以上 288px),与底部「我」那条底栏 同一个值 —— 两块同宽同居中才是一个台面框(早先它按内容撑到 229–238px,比底栏窄 18–27px)。 表头 11px、上边距 6px,表头与值格都 whitespace-nowrap:真机上「庄已抓」那格的「分」 曾因为列宽不够掉到第二行。「回溯」格里那枚「上一轮」按钮一直在: 收过墩才可点,首轮是一颗灰按钮(整格空着会让四列表格缺一角)。
  • 一墩的三个出牌点(ADR-0020 修订):「两翼 + 一条通栏」,两翼的宽度按这一侧要放几张牌算 (一颗 30%、五张 38%、八张以上 44% 封顶;fan-layout.ts 的 spotBox),两侧取同一个值, 内侧边缘恒在 48% / 52% —— 2 × 外缩 + 2 × 宽 + 4 = 100 这个加法就是「三点互不相交」的 结构性保证,而两翼中心因此对称地落在中线两侧,中点恰好在内容槽中线上(原先左 left-0 + 右 right-[20%] 让中点落在 40%,整组偏左 10%)。 盒里的出牌堆只收不放:放得下时按每张露出 60% 叠排(同门的 3–8 张是一个组合, 「牌宽 + 空隙」那种摊开排法会被读成 N 张独立的牌、通栏时 5 张就铺满大半行),放不下再按预算收紧。 CSS 半边与算法同档(已测量时 gap: 0,否则实际步距会多出 6px、牌堆越出预算盒并与邻簇互叠)。
  • 操作条那一行(ADR-0020 修订):左「?」、中间一格、右那枚闹钟。动作托盘(出牌 / 确认埋底 / 清空) 就住在中间那一格里,与状态句互斥地换内容(轮到我时状态句是 null),所以它在流内、 不可能再压住毡面最后一行。行高由 min-h-[2.6rem] 钉住 —— 零回流由行高保证, 早先那条 44px 的独立动作带因此撤掉,44px 还给牌区(内容槽实测 320px 上 215 → 263px、 桌面 417 → 461px)。托盘原先钉在操作条上沿时,那 40px 整根落在毡面的最后 40px 里 —— 手机 375px 上与自己的座位卡竖直重叠 32px、水平重叠 84px(截图 #4)。
  • 「距上一步」是一枚闹钟(ADR-0020 修订):22px 的圆 + 两只铃铛,秒数写在圆里 (0..99 正常字号 / 100..999 小一档 9px —— 三位数在那个字号下只占 16.7px,是量出来的档), 完整那句「距上一步 12 分 34 秒」挂在 title 与读屏标签上。 ≥1000 秒(约 16 分 40 秒)不再写数、改成一枚红色横杠 —— 那一刻它说的是「这张桌停住了」, 而不是「谁想了多久」。腾出的宽度正是托盘能搬进同一行的前提。
  • 「我」那条底栏与信息须同宽并居中(尺度 --rail-w:基准 256px、sm 以上 288px): 栏里只有庄冠 / 头像 / 名字 / 级别徽标,桌面端毡面 1080px 时整幅铺开既没必要也不好看; 桌面档比基准档宽一档,是因为值那一档数字在同一个断点上从 20px 长到 24px, 而定宽要容得下最宽的内容(100♣ + 100 分)。
  • 一副结束走三拍(ADR-0022):末墩收墩后先看清末墩 2.0 秒(fixed inset-0 的结算弹窗 不再一帧盖住毡面),再亮底牌 3.2 秒(那 3 张埋下的底牌摊在内容槽正中:末墩赢家决定 底牌倍数、底牌决定保底还是抠底,这两样得先看见才读得进弹窗里那句话),然后弹结算。 操作条行中间那一格里有「看底牌 »」(第二拍写「看结算 »」):点一下只前进一拍(本地、各人各看)。 而**「下一副 / 开新对局」两处都锁到 5.2 秒** —— 换副是全桌的动作,跳过是各人的, 少了这把锁,跳得快的人能在 ~0 秒把别人正在看的末墩与底牌切走。每副都走、观战者一样走、 刷新后也重走(一条路径);三拍是纯 UI 时序,服务端不知道它(时间不进引擎)。
  • 拟选(ADR-0021):在座玩家没轮到自己时也能点牌(选中态照旧上浮+金条),有人领出后 每个人都看得到「该跟哪一门」的蓝框。牌局推进(别人出牌、收墩、换轮次)不清掉你正在拟的牌; 只有「这些牌已经不在我手上」才清。合法性仍由托盘的 checkPlay 在你出手那一刻判。
  • 机器人 的座位与人类没有区别,身份标记只有头像里那枚内联 SVG(不用 emoji:各平台字体不同、 有的会渲染成彩色图片,在 36px 的圆里对不齐);名字前缀「机器人·」是服务端给的身份名。 加/请离它的入口在抽屉「牌桌」页 —— 它们是「改一张桌子」,与入座/离座同类; 叫牌/埋底/出牌/结算这些牌局动作仍一律留在桌面。
  • 点邀请码即复制链接:<origin>/table/<邀请码>;大厅的入座框既接受 6 位邀请码,也接受直接粘贴的完整链接 (parseInvite)。局域网 http 下浏览器没有 Clipboard API,会自动退化成可手动复制的链接输入框。 还没注册的人点开链接会被带到大厅,但邀请码随 URL 一起带过去(/?join=<邀请码>):注册或导入身份后 自动回到那张桌,不需要重新点一次链接。
  • 无重复信息:同一件事只说一遍(如「庄已抓 X / 需 Y」里的分母就是旁边的定约分,只保留一个)。
  • 「上一轮」回看:机器人出手只有 0.5–1.5 秒,收墩后赢家立刻领出下一轮,而出牌区只显示正在打的 这一墩 —— 上一墩三家出的牌一晃就过。所以状态条上常驻一枚「上一轮」入口(收过墩才出现,站在整条最左、 取代了原来的「第 N 轮」——轮次号在这里没有信息量,打到第几轮由毡面上那墩牌说明,而「你在看哪一轮」 只有浮层标题该说;删掉它同时把这一条从四项收到三项,窄屏不再折行),点开是一个只覆盖毡面的浮层: 按出牌顺序竖排三家这一墩的牌(每行写着是谁出的,赢家那行带 庄 / 闲 +N 分),表头是「上一轮 · 第 N 轮」。 它不占整屏(手机端抽屉是整屏,看回看时就看不到手牌了),手牌始终可见、可继续选牌; Esc / × / 点毡面背景都能关,默认关着。只看最近一墩 (整份 trickHistory 本来就在负载里,观战者也有;要往前翻只是加一个下标)。徽标文案一字不改 —— 「上一轮」二字只属于入口与表头。
  • 牌面:主牌靠浅金底色区分(早先用金边,但在缩略牌、重叠扇面与斜摆的墩里几乎认不出来); 四个角没有任何装饰点(点看着像另一张牌);分牌就是 5 / 10 / K,认点数即可。 大小王按真牌的布局:牌名只在两处角落索引里(「大/小 + 王」),两处镜像一致;正中是一枚图案(大王 ☀ / 小王 ☾)。 牌名在牌上只出现一次,不会角落与正中各写一遍(cardFace)。
  • 新手教程:/rules 复用牌桌同一套牌渲染组件(Card / HandFan / LevelBadge / TrickCluster), 含两道练手题(用与服务端同源的 checkPlay 即时判定)。依赖级牌的每个示例都标出将牌环境(trumpText), 升级表只列真实可达的分数(得分恒为 5 的倍数),叫牌一节讲清阻击叫的心理博弈。 教程里每个示例都由引擎函数在 test:web 中核对,规则改动导致示例失效会直接测试失败。
  • 观战与离座:满座(3 人)时用邀请链接进来即成为观战者,只看公共视图(手牌张数与已打出的牌, 不含任何手牌与结算前的底牌);在座者随时可点「离座」,座位空出、本副停在空座上等人补位, 补位者继承该座位的级别与手牌(若本副尚未结算,界面会明说「你补进了空座、接下这手 N 张牌」,免得拿着 陌生手牌发愣)。观战人数(实时连接数)在页头右上角,座位状态在右侧活页签抽屉的「牌桌」页里。 不在座位上时才能改名字或换身份 —— 改名会让凭据串重签,大厅与观战页都会把新串写回本机。 观战记录不会自动清除(只有入座才清),所以大厅「我的牌桌」会一直列出你在观战的那张桌(上限 20 条)。
  • 机器人(凑桌陪练):在座的任何人类都能在空座位上点「+ 机器人」凑桌,一桌至多 2 个 (至少留一个人类座位去按「开下一副」—— 机器人从不发起发牌)。进行中加入就是补位:它接手这个座位 现有的手牌与级别,这一副接着打完。机器人座位卡照旧显示名字与在线点,只多一枚「机器人」徽标和一个 「请离」入口(离座语义:本副停在空座等补位,界面会先说清)。它没有凭据串,所以没人能冒充或接管它; 它的动作走与人类完全同一条服务器权威路径,也只看得见个人视图(见 ADR-0015)。 屏幕上它出手有点慢(0.5–1.5 秒拟人延迟),那是给人看清局面用的。 牌风上是「稳」而不是「怂」:叫牌恒开 40、不跳叫,但门槛不高 —— 实测开叫率约五成、竞叫约三成, 全 pass 重发只剩 5%(初版是 36%),所以别指望它每次都把庄家位让给你;埋底默认不埋分, 只有主牌控制到「几乎必然保住末轮」时才埋不超过 10 分(约四分之一的副数), 于是「机器人做庄必然 0 分底」并不成立。
  • 机器重演(Bot replay,见 ADR-0016):每副牌结算那一刻,服务端用机器人策略把 整副牌(含叫牌)重打一遍并存档;战报的每副卡卡尾、结算弹窗的升级表下方各有一枚 「机器重演」按钮,点开就是「换三个机器人来打会怎样」的对照。它不是模拟 —— 没有搜索、 没有随机,同一副牌永远是同一个结果。定约与庄家可能与真实那副不同(重演连叫牌一起重走), 所以展开体头行写的是重演的定约;重演自己也可能三家 pass(约 5%),那时如实说 「机器人全 pass · 这副会被作废重发」,不靠重试凑一个定约出来。重演看的是「这副牌」 而不是「当初那桌」—— 手牌序没有保存,而策略的取舍与下标有关。升级到这一版之前结算的老副 没有记录,点开会说一句;升级表模式里没有这个入口(它说的是真实进度)。
  • 手机端页头与右侧活页签抽屉:页头只留 ← 大厅、邀请码,以及右上角的桌况簇 (观战人数含 0 常显;在座给「离座」、不在座给「改名」,有空座才多一枚「入座」—— 补位有时限, 不藏起来)。两次「页头爆满」堆进去的都是查阅入口,而这一簇是有界的一行,所以它留在页头。 连接状态不是常驻指示器:绿点已下线 —— 手机上没有 hover 能解释一枚圆点, 而它平时显示的恰恰是「什么都没发生」;只有 SSE 断开时才在页头下方出一条 连接中断,正在重连…画面可能停在上一帧。(role="status",重连成功即自动消失)。 其余收进右边缘常驻的四个活页签 战报 / 叫牌 / 底牌 / 牌桌:点一个从右侧 拉起该页,手机端整屏覆盖、桌面端 22rem 浮层;再点当前页签、点 ×、按 Esc(桌面还可点抽屉外)收起。 页签顺序不随阶段变,抽屉不自动打开、不自动换页;「战报」页里另有页内的两模式 (逐副 / 升级表),它不占页签。 边界只有一条:抽屉只承载查阅面,动作面留桌面 —— 叫牌候选按钮、确认埋底、出牌按钮、每副结束的 结算弹窗都在原来的位置,抽屉里放的是「查一下」的东西(战报与升级进程、叫牌记录、庄家埋下去的 3 张底牌、 座位与身份)。apps/web/test/table-chrome.test.ts 与 scripts/ui-check.ts 一起守着这些 (页头白名单 + 桌况簇三态 + 页签清单 + 动作面不许进抽屉 + 连接提示只许在断线时出现), 免得页头再一次被堆满。
  • 新手引导:/learn 是交互课程:概念简述 + 门槛式测验 + 引导局(用户坐进一副真实牌局的一个座位, 对手自动出牌、用户每个动作被引导词强制带着打,过程只见自己的手牌、打完全亮复盘,见 ADR-0025)。 每步深链 /learn?s=<step-id>,进度存 localStorage 可续读;概念示例复用 /rules 那份被引擎核对过的数据。 引导局数据取自「三人均非机器人」的真实牌局(guide:extract 从 db 副本提取候选),牌面事实烘焙进 docs/guide/<slug>.json,客户端用引擎 dealWith 从显式手牌确定性重放 —— 不依赖种子;生成物 (guide/deals/<slug>.ts)提交进仓库,pnpm guide:check 核对「生成物 == 现场重算」。

身份与凭据

两种来历,不要混用(见 CONTEXT.md 的 身份 / 凭据串 / 会话 / Gitee 登录):

  • 本地身份:无密码 —— 输入名字即注册并签发令牌,浏览器 localStorage 保存 base64url(名字:令牌) 的凭据串,一键复制到其它浏览器粘贴即可继续同一身份 (服务端同时下发 httpOnly cookie 供 SSE 鉴权)。名字全局唯一、可改;凭据串里内嵌名字, 所以改名后旧串立刻失效、界面会把新串写回本机。
  • Gitee 身份:在登出态点大厅里那枚 Gitee 图标,用 Gitee 账号一键建立。不需要也不发 凭据串 —— 令牌只存库、不进任何响应,浏览器只拿一条 30 天滚动的会话。 它的好处是换设备不用搬字符串(再登一次即可),代价是不能复制、不能迁移、不能驱动 MCP。 站内名从 Gitee 用户名派生(超 12 字截断、撞名加后缀),之后可照常改。

登出要看清:本地身份登出会同时删掉本机保存的凭据串,而这个身份无密码、无找回 —— 没在别处保存就永远回不来。所以在座时大厅会先弹一层确认,给你一条「离开房间并登出」: 它退掉所有座位(进行中的一副停在空座等补位),并把那些桌的邀请码复制到剪贴板, 之后用新身份凭邀请码补位回来(座位空着才补得进 —— 这就是它防的「幽灵占位」)。 Gitee 身份登出没有这一层:再登一次 Gitee 就回来了。

让 LLM 也来玩(MCP)

LLM 在这套系统里就是一个普通身份:服务器不区分人类与 LLM,座位上也没有「LLM 席位」这种东西。 仓库提供的是 MCP 工具面(工具定义只有一份,理由与取舍见 ADR-0010;投喂量怎么省见 ADR-0011); "会不会打牌"由接上来的宿主决定 —— LLM 宿主自带策略。 凭据是可选的:没配也能连上(无身份会话:只能读规则、或替人类建一个身份,见 ADR-0014)—— 所以「AI 一步步教人类把身份配起来」这条路上的第一步不需要先有一个身份。

Gitee 身份进不了 MCP(ADR-0023)。MCP 的鉴权面只有凭据串(Authorization: Bearer / SIXTY_CREDENTIAL),而 Gitee 身份的令牌永不外发、没有凭据串可配 —— 这是「不得复制凭据」 那条要求直接推出来的边界,不是遗漏。想用 MCP 就在浏览器里另建一个本地身份 (README 本来就把 MCP 客户端当「另一台设备」)。带着 Gitee 会话 cookie 的请求撞 /api/mcp 按无身份会话处理,绝不把会话 cookie 当成第二根凭据。

别把它和「机器人」搞混。 机器人是服务器自己代打的那种身份(无凭据串、由「+机器人」加进空座, 见 ADR-0015 与上面「机器人(凑桌陪练)」一节),跟 MCP 席位是两回事:MCP 对面是人还是 LLM, 服务器依旧分不出来。至于打得好不好:机器人那套基线启发式策略是独立小包 packages/bot(纯函数:个人视图 → 动作),接上来的宿主想拿它当参考策略或对照基准都可以直接复用。

1. 先给 agent 一个身份

浏览器里「创建身份」(例如 小六)→ 点复制凭据。凭据串是 base64url(名字:令牌), 和"复制到别的浏览器继续用"是同一个东西:MCP 客户端只是另一台设备。

一个座位一个凭据:同一个凭据同时被人和 agent 用,两边会互相抢着出牌(会看到「还没轮到你」)。

也可以反着来:宿主先不配凭据连上(见下),让 agent 用 claim 传一个名字建出身份, 把它返回的凭据串交给人类 —— 人类在同一个服务器的站点用大厅的「粘贴凭据串」导入即可。 这条路上 agent 只是代建:它拿到了那串东西就得交出去,自己的连接仍然是「没有身份」。

2. 接 stdio(本地进程;Claude Code / Claude Desktop 等)

{
  "mcpServers": {
    "sixty-points": {
      "command": "node",
      "args": ["/path/to/sixty-points-ol/packages/mcp/src/stdio.ts"],
      "env": {
        "SIXTY_BASE_URL": "https://game.example.com",
        "SIXTY_CREDENTIAL": "<第 1 步复制的凭据串>"
      }
    }
  }
}

启动时会先自检一次:地址写错、服务没起当场以非 0 退出并说明原因,而不是等到第一次工具调用才失败; 带了凭据时顺带验一次凭据。日志一律走 stderr(stdout 是 MCP 协议通道)。 SIXTY_CREDENTIAL 是可选的:不填就是无身份会话(只有 read_rules 与 claim 能用, 见上文「工具」一节),所以「先连上、再让 AI 教你怎么配」这条路不需要先有一个身份(ADR-0014)。 反过来,配好凭据之后就不该再催着配:instructions 里给模型一条明确的判断 —— 除 read_rules 与 claim 之外的调用只要没被「还没有身份」那句话拒绝,就说明凭据已经生效 (会话开始时是无身份、之后人类重连的情况,模型照着旧上下文很容易误判)。

3. 或者连服务端自带的 /api/mcp

不需要本地 Node:POST https://game.example.com/api/mcp,用 Authorization: Bearer <凭据串> 鉴权。 它是 Streamable HTTP 的 JSON 响应模式 + 无状态:不开 SSE 流、不发会话 id,所以反代 (Caddy / traefik / Coolify)不需要为它加任何白名单;没有会话,也就没有「谁的会话」这回事。 一个 POST 最长会挂 30 秒(动作自带等待:等你下一次能行动就返回),wait_for_turn 最长 60 秒; 反代只要没有更短的响应超时即可(Caddy / traefik 默认没有)—— 若部署侧的响应超时更短, 给动作传 wait: false、并把 wait_for_turn 的 timeout_seconds 调小即可避开。

工具(16 个)

工具 作用
get_state 读当前局面:只有你的手牌 + 公开信息,并附 turn(轮到谁、能不能动、该做什么)
wait_for_turn 等到能行动再返回(默认 30s、上限 60s;超时返回 timedOut,直接再调一次即可)
read_rules 读玩法说明;不传 key 返回全部 11 段(9 个阶段键 + trump-order/runs 两个参考键:主牌大小顺序与顺子规则);整局读一次就够;不需要身份
legal_bids 当前所有合法叫品(与牌桌叫牌面板同一份实现);叫牌阶段之外返回空表
check_play 出牌前的本地预判,一次可验多组候选(服务端始终是唯一裁判)
bid/bury/play/deal/new_game 动作,成功后自动等到下一次轮到你(wait,默认 true),返回那一刻的局面
list_my_tables/join_table/create_table 找到该坐哪张桌/用邀请码入座/自己开一张桌
leave_seat/take_seat 离座转观战(座位空出、本副停在空座上等人补位)/补位入座并继承该座位的级别与手牌
claim 还没有身份时新建一个:返回名字与凭据串(交给人类用);不需要身份,撞名一律失败

工具不接受座位号:座位一律由服务端按身份推导(ADR-0002)。所有读写都只经过个人视图, 所以 agent 看不到别人的手牌与底牌;进程内那条路另有静态守卫钉着(随 pnpm test:web 跑)。

没有身份也能连(ADR-0014):read_rules 与 claim 不需要凭据,其余工具会回一句 「还没有身份:……」并说清怎么配凭据、怎么自己建。工具清单不按凭据筛 —— 没带凭据的连接也 list 到全部 16 个,好让模型知道配好之后能拿到什么。反过来,带了却无效的凭据一律 401, 绝不静默降级成「没有身份」。离座之后要回座用 take_seat:重新 join_table 只会让你继续观战 (到达语义见 ADR-0007)。

牌面写成短码:"S14" = ♠A、"S10" = ♠10、"C5" = ♣5、"j0" = 小王。出参与入参同形 —— get_state 里 you.hand 的元素可以原样喂回 play/bury。轮到你时 turn.legalBids / turn.legalPlay 已经把「能怎么做」给出来了,不需要逐张试探。

出参默认只给此刻行动所需的,加上自你上次看到以来新发生的事(最近完成的一墩):跨副战报与 更早的墩不再每帧重发 —— 宿主每个请求都会重发整段会话,模型已经看过那些帧,重复投喂只是把成本 乘在回合数上。记牌因此属于牌手能力(有意为之,见 ADR-0019);对话被截断或换了会话时, 给 get_state 传 verbose: true 取回完整局面(座位名字、级别、全部牌史都在里面)。

一副牌要说多少话

工具面是每个回合都要重发一遍的(宿主每个请求都带着整段会话与工具面 schema), 所以这里花的不是一次性的钱。按座位 0 打完整一副实测(RNG 固定,见 ADR-0011/0019):

逐字负载 + 每回合两次调用 紧凑投影(ADR-0011) 行动投影(现状)
一副牌的实收负载 116,278 字符 35,636 18,453
模型累计读入(一副) 1,845,222 401,156 288,904
结算那一刻的单份局面 6,232 ~2,760 1,355
一整局的实收负载 — 396,503 139,207
一整局的累计读入 — 26,710,960 11,333,093
整局里最大的一份负载 — 4,402(随副数涨) 1,393(基本不动)
每副的调用数 38 21 21

最下面那两行最要紧:跨副不再累积 —— 整局里最大的一份负载曾经随副数线性上涨,现在与第几副无关。

真正的预算是会红的断言(packages/mcp/test/payload-budget.test.ts 卡单副与整局, pnpm mcp-check 再对真服务器卡一次调用数与字节数)。

界面上的那颗点对 agent 也有效:它没有 SSE,但每次请求都会刷新「最近活跃」, 因此它在打牌时显示在线,停手一分钟才转灰。状态变了要推出去才算数 —— join_table 入座、以及它从离线变在线的那一刻,都会广播给牌桌的连接,所以牌桌页上的座位卡、 ready 与「开始第一副」不用等谁先出一次牌(见 ADR-0012)。反过来,连接真的断了就立刻变灰: 关标签页、浏览器崩掉都会当场把那个 60 秒窗口作废,别人不用等一分钟(见 ADR-0013; 拔网线/休眠这种「TCP 还活着」的情形仍然发现不了,只有加客户端心跳才能覆盖)。

写路径(bid/bury/play/deal/new_game/create_table/join_table/leave_seat/ take_seat/claim)不会自动重试: 服务端没有幂等键,重发一次就可能把已经生效的动作变成两次、或者凭空多开一张桌。所以连接在提交后 断掉时,工具会如实说「无法确认这次请求是否已经生效」,并让你先用 get_state/list_my_tables 核对现状再决定 —— 读(get_state/list_my_tables)是幂等的,会自己重试一次。 claim 是唯一换了一句文案的:它没有局面可核对,所以改问「同名再 claim 一次会告诉你『这个名字已被使用』」。

一键部署

Docker Compose(单机 / VPS)

docker compose up -d --build                     # → http://<主机>:3000
DOMAIN=game.example.com docker compose --profile https up -d --build   # → https://game.example.com(Caddy 自动证书)
  • 镜像:node:24-bookworm-slim 两阶段构建(装依赖+构建 → 只带产物与运行期依赖),非 root 运行。
  • 数据:命名卷 sixty-data 挂到容器 /data(SIXTY_DB=/data/sixty.db)。牌局状态、身份、座位与观战记录、事件日志都在这里,容器重建/升级不丢;重启续局已实测。 这一行只对 docker compose 与 Coolify 的 Docker Compose 资源生效:Coolify 用 Docker Image 资源时不会自动带上它,必须手动加 Persistent Storage,见下文「Coolify」的 A 路径。
  • 环境变量全部带默认值,可用 ${X:-默认} 直接改:PORT(容器内端口,默认 3000)、PUBLIC_PORT(宿主映射,默认同 PORT)、SIXTY_DB、DOMAIN(仅 https profile);另有一组可选的 Gitee / 管理员变量(见上文「Gitee 登录与管理员」),留空即整个功能隐身。 ORIGIN 例外:不要用 ${ORIGIN:-} 这种写法(宿主未设置时会注入空串导致容器起不来),需要时就写完整 URL 或整行留空不定义,见上文「本地运行」的警示。
  • 健康检查:容器内 GET / 返回 200;docker compose ps 里看到 healthy 即就绪。
  • 升级:git pull && docker compose up -d --build。
  • 备份 / 恢复:
    docker run --rm -v sixty-points-ol_sixty-data:/data -v "$PWD":/backup alpine \
      tar czf /backup/sixty-backup.tgz -C /data .
    # 恢复:把 tar 解回同一个卷后再 up

预构建镜像(GHCR,GitHub Actions 自动构建)

.github/workflows/ci.yml 在 CI 上构建镜像并推到 ghcr.io/cup113/sixty-points-ol:先过 pnpm test / pnpm test:web / pnpm test:mcp / pnpm check, 再用构建产物起服务跑一遍 MCP 端到端(两条传输各打一整副),全绿才出镜像。

触发 产出的标签
push main latest、main、sha-<7位>(如 sha-af7ae45)
push v* 标签(如 v0.2.0) 0.2.0、0.2,外加分支规则命中时的标签
PR 只试构建,不推送
Actions 页面手动 Run workflow 按所在分支 / 标签出标签

镜像只覆盖 linux/amd64(ARM 主机需本地 build,或按 ADR-0006 加 QEMU 多架构);不需要任何 Secret,用仓库内置 GITHUB_TOKEN 授权。

不依赖仓库、直接拉预构建镜像运行(数据卷必须保留,否则每次重建都清空牌局):

docker pull ghcr.io/cup113/sixty-points-ol:latest
docker run -d --name sixty -p 3000:3000 -v sixty-data:/data \
  -e PORT=3000 ghcr.io/cup113/sixty-points-ol:latest

要可回滚就固定版本::sha-af7ae45 或 :0.2.0。

Coolify 用预构建镜像:新建资源 → Docker Image(不是 Docker Compose),镜像填 ghcr.io/cup113/sixty-points-ol:latest,Domain 端口仍填容器端口 3000,并手动加 Persistent Storage(步骤见下文「Coolify」的 A 路径)。注意 docker-compose.yml 本身仍是本地 build(compose 里 build: 优先于 image:),别指望现有 compose 资源自动改用 CI 镜像。

排查:

  • docker pull 报 denied / 404:到 GHCR 里把该包可见性设为 Public(公开仓库的包通常默认就是)。
  • CI 推镜像报 write_package / permission_denied:仓库 Settings → Actions → General → Workflow permissions 设为 Read and write。

Coolify

先选资源类型 —— 两条路的代价完全不同。

A. Docker Image 资源(推荐:镜像来自 GHCR,服务器不构建)

  1. 新建资源 → Docker Image,镜像填 ghcr.io/cup113/sixty-points-ol:latest;要可回滚就填固定 tag(:0.2.0、:sha-af7ae45)。
  2. Domain 填 <你的域名>:3000 —— Coolify 里的端口是容器端口,不是公网端口;写错会 502。容器监听 3000(PORT=3000)。
  3. Persistent Storage 必须手动加:Application → Persistent Storage → Add —— Name sixty-data、Destination Path /data(必须与 SIXTY_DB 的目录一致)、Source Path 留空即用 Docker 命名卷。 漏了这一步就会每次部署清空牌局:这类资源既不继承仓库 docker-compose.yml 里的卷,也不继承镜像里的 VOLUME ["/data"],于是每次重新部署都挂一个全新的匿名卷 —— 用户凭据、MCP 凭据、房间全部静默消失,而健康检查仍然是绿的。
  4. 验证:docker inspect -f '{{range .Mounts}}{{.Name}} -> {{.Destination}}{{"\n"}}{{end}}' <容器> 应显示 sixty-data -> /data,而不是一串 64 位十六进制名。
  5. 备份(Coolify 的命名卷就叫 Persistent Storage 里的 Name,不带项目前缀):
    docker run --rm -v sixty-data:/data -v "$PWD":/backup alpine \
      tar czf /backup/sixty-backup.tgz -C /data .
  6. 需要 Coolify 自动注入域名变量时,在该资源的 Environment Variables 里加 SERVICE_FQDN_APP_3000=<你的域名>。

B. Docker Compose 资源(指向本仓库,在服务器本机构建)

  1. 新建资源 → Docker Compose → 指向本仓库(compose 文件在根目录,无需改路径)。
  2. Domain 填 <你的域名>:3000(同上:端口是容器端口)。
  3. 数据卷由 compose 里的 - sixty-data:/data 自动带上(见上文「Docker Compose(单机 / VPS)」),别删那一行。
  4. 代价:镜像在服务器本机构建(git clone + docker compose build --pull),构建期内存可能吃到几个 GB;小 VPS 会被 OOM kill 或长时间卡住 —— 服务器余量不足就选 A。
  5. 需要 Coolify 自动注入域名变量时,在 app.environment 加一行 - SERVICE_FQDN_APP_3000=<你的域名>。

SSE(实时推送)踩坑说明

回合制对战靠服务器推送(/api/tables/<code>/stream,text/event-stream)。中间层一旦缓冲或 gzip,症状是页面正常但状态永远不刷新:

  • 应用侧已发 Content-Type: text/event-stream + Cache-Control: no-cache + X-Accel-Buffering: no。
  • docker-compose.yml 已带 traefik.http.middlewares.gzip.compress=false(Coolify 默认会注入 gzip 中间件,必须关掉)。
  • 用自带的 Caddy(--profile https)时,deploy/Caddyfile 对 SSE 路径单独设 flush_interval -1,其余路径正常压缩。

本机沙箱下的注意事项

pnpm dev / pnpm build / docker 需要 spawn 子进程与访问 Docker 命名管道,在受限沙箱里会 EPERM;在自己机器或 CI 上不受影响(镜像构建已在 .github/workflows/ci.yml 里跑,本地沙箱受限只影响本地 build/dev)。若镜像构建在 esbuild 步骤报错,把 pnpm-workspace.yaml 里的 allowBuilds: esbuild 改成 true 再构建(本仓库默认关掉它,是因为它只是可选的原生二进制补装,关掉后构建同样可跑)。

暂未实现(v1 范围外)

聊天/表情、计时器、观战者的全知/延迟视图、多实例水平扩展。

「机器人补位」已从这张单子移出 —— 机器人座位与基线启发式策略已落地(见 ADR-0015)。 仍未做的是更强的机器人与难度梯度:现在只有一档规则式启发,不搜索、不学牌谱, packages/bot 的纯函数形状留了加档位的口子,但第一版刻意不做。

许可

Apache License 2.0 © 2026 Jason Li

About

六十分 (Sixty Points):线上 3 人 1v2 扑克牌 —— 一副 54 张,桥牌式叫牌定庄定将,双升式打牌与升级赛程。内置 16 个 MCP 工具,可以请 LLM 打牌。无需密码即可登录,Gitee 也可。Docker 一键部署,使用 SvelteKit 2 / SSE / SQLite 单实例服务端。

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages