三人 1v2 的顺子类牌戏:一副 54 张,桥牌式叫牌定庄定将,双升式打牌与升级赛程。线上实现为 SvelteKit + SSE 单实例应用,规则引擎是零依赖纯 TypeScript 包并有完整单测/属性测试。
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 入口不渲染,/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。留空 = 没有管理员 |
启用步骤:
-
到 Gitee「设置 → 数据管理 → 第三方应用」创建应用,回调地址填
https://<你的域名>/api/auth/gitee/callback(本地开发填http://127.0.0.1:5173/api/auth/gitee/callback)。 它必须与页面实际访问的 origin 逐字一致 —— 反代部署请固定ORIGIN(见下文的警示)。 -
把 Client ID / Secret 填进上面两个变量。
-
想用管理面板:先用 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 在这套系统里就是一个普通身份:服务器不区分人类与 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(纯函数:个人视图 → 动作),接上来的宿主想拿它当参考策略或对照基准都可以直接复用。
浏览器里「创建身份」(例如 小六)→ 点复制凭据。凭据串是 base64url(名字:令牌),
和"复制到别的浏览器继续用"是同一个东西:MCP 客户端只是另一台设备。
一个座位一个凭据:同一个凭据同时被人和 agent 用,两边会互相抢着出牌(会看到「还没轮到你」)。
也可以反着来:宿主先不配凭据连上(见下),让 agent 用 claim 传一个名字建出身份,
把它返回的凭据串交给人类 —— 人类在同一个服务器的站点用大厅的「粘贴凭据串」导入即可。
这条路上 agent 只是代建:它拿到了那串东西就得交出去,自己的连接仍然是「没有身份」。
{
"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 之外的调用只要没被「还没有身份」那句话拒绝,就说明凭据已经生效
(会话开始时是无身份、之后人类重连的情况,模型照着旧上下文很容易误判)。
不需要本地 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 调小即可避开。
| 工具 | 作用 |
|---|---|
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 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
.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。
先选资源类型 —— 两条路的代价完全不同。
A. Docker Image 资源(推荐:镜像来自 GHCR,服务器不构建)
- 新建资源 → Docker Image,镜像填
ghcr.io/cup113/sixty-points-ol:latest;要可回滚就填固定 tag(:0.2.0、:sha-af7ae45)。 - Domain 填
<你的域名>:3000—— Coolify 里的端口是容器端口,不是公网端口;写错会 502。容器监听 3000(PORT=3000)。 - Persistent Storage 必须手动加:Application → Persistent Storage → Add —— Name
sixty-data、Destination Path/data(必须与SIXTY_DB的目录一致)、Source Path 留空即用 Docker 命名卷。 漏了这一步就会每次部署清空牌局:这类资源既不继承仓库docker-compose.yml里的卷,也不继承镜像里的VOLUME ["/data"],于是每次重新部署都挂一个全新的匿名卷 —— 用户凭据、MCP 凭据、房间全部静默消失,而健康检查仍然是绿的。 - 验证:
docker inspect -f '{{range .Mounts}}{{.Name}} -> {{.Destination}}{{"\n"}}{{end}}' <容器>应显示sixty-data -> /data,而不是一串 64 位十六进制名。 - 备份(Coolify 的命名卷就叫 Persistent Storage 里的 Name,不带项目前缀):
docker run --rm -v sixty-data:/data -v "$PWD":/backup alpine \ tar czf /backup/sixty-backup.tgz -C /data .
- 需要 Coolify 自动注入域名变量时,在该资源的 Environment Variables 里加
SERVICE_FQDN_APP_3000=<你的域名>。
B. Docker Compose 资源(指向本仓库,在服务器本机构建)
- 新建资源 → Docker Compose → 指向本仓库(compose 文件在根目录,无需改路径)。
- Domain 填
<你的域名>:3000(同上:端口是容器端口)。 - 数据卷由 compose 里的
- sixty-data:/data自动带上(见上文「Docker Compose(单机 / VPS)」),别删那一行。 - 代价:镜像在服务器本机构建(
git clone+docker compose build --pull),构建期内存可能吃到几个 GB;小 VPS 会被 OOM kill 或长时间卡住 —— 服务器余量不足就选 A。 - 需要 Coolify 自动注入域名变量时,在
app.environment加一行- SERVICE_FQDN_APP_3000=<你的域名>。
回合制对战靠服务器推送(/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 再构建(本仓库默认关掉它,是因为它只是可选的原生二进制补装,关掉后构建同样可跑)。
聊天/表情、计时器、观战者的全知/延迟视图、多实例水平扩展。
「机器人补位」已从这张单子移出 —— 机器人座位与基线启发式策略已落地(见 ADR-0015)。
仍未做的是更强的机器人与难度梯度:现在只有一档规则式启发,不搜索、不学牌谱,
packages/bot 的纯函数形状留了加档位的口子,但第一版刻意不做。
Apache License 2.0 © 2026 Jason Li