dsh-chess-board
在 DSH 里下国际象棋。 右侧边栏里的一个棋盘标签页——和文件树、终端平级,还能拖出来变成浮窗。
| 能做什么 | |
|---|---|
| 完整规则 | 王车易位(含"不能穿过被攻击格""被将时不能易位")、吃过路兵、兵升变、将死/逼和、五十步和棋;perft 与公开参考值逐项一致 |
| 八档内置对手 | 档位编号与 lichess 一致(Level 1…8),由「搜索深度 + 可接受着法带宽 + 失误率」共同决定,不是只调深度 |
| lichess 云端评估 | 每个局面自动查 cloud-eval:优势条 + 一句话结论 + 带回合数的线路。被限流时改用本地引擎兜底,并标注来源 |
| 复盘与战术题 | 导入自己的对局或任意 PGN 逐步复盘、从任意局面接着下(Practice)、每日/随机战术题 |
| 和 lichess 引擎下棋 | 贴一个 token 即可开一局;引擎应手由服务器推送回来(不是轮询),可认输/求和/中止 |
| 手感 | 点两步或直接拖棋子;落子有滑行动画;中英双语 + 设置页 |
快速开始
dsh plugin add dsh-chess-board
安装后重启 DSH(bundle 列表只在宿主启动时组装一次),然后在右侧边栏标签条的「添加」控件里选 Chess。
不登录也能用:云端评估、残局库、PGN 回放、战术题都不需要账号。 只有「与 lichess 引擎对局」需要 token——账号页会给一个已勾好所需权限的建 token 链接。
界面位置
棋盘不是一个浮层,而是右侧边栏(编辑区旁边那一列)的一个标签页,与文件树、终端等平级:
标签类型通过
ctx.sidebarRightTabs.register({ id, kind, priority: 'extension', title, guide })注册, 正文与 chip 标题分别注册到sidebar.right.pane.tab与sidebar.right.pane.tab.title(用类型的id作 key)。打开方式:
- 边栏标签条上的"添加"控件 → 引导页里的 Chess 卡片;
- 会话里文件/链接旁的侧栏入口。
右下角角标只在一种情况下出现:棋盘开着、但没显示出来(典型是收起右侧栏)。 它是召回按钮,不是入口——没有棋盘开着时它不占位置,因为标签条自己就能开一个。
状态 正文 角标 没开过 / 标签页已关闭 未挂载 不渲染 标签页开着、面板可见 挂载且可见 不渲染 侧栏收起(开着但不可见) 仍然挂载 显示 启动器是固定浮层条目,看不到侧栏的标签状态,所以由标签页正文自己上报,上报两个轴: 是否挂载(= 标签页是否开着)与是否在屏幕上。
"开着但不可见"这种状态是实测出来的,不是推断的:DSH 侧栏自己的注释写着 docked content "stays mounted while collapsed, translated off the frame's right edge", 我也在真机上验证过——收起侧栏后正文并没有卸载。所以判定用
IntersectionObserver观测正文容器是否真的与视口相交(被移出视口 ⇒ 不相交)。点击角标走的是侧栏的打开标签页路径,其文档写明会 "claim it, place it, reveal the column", 因此它会展开收起的侧栏并聚焦已存在的 Chess 标签页。
存的是实例列表而不是计数器:分栏里可以同时有两个 Chess 标签页, 而"一个被隐藏、另一个还开着"这种不成对的增减会把计数永久带偏。
harness.mjs里加了一个可控的假IntersectionObserver与最小 ref 支持, 因此上表三个状态都是被断言的(含"隐藏时角标内联定位为 fixed / 右下 16px", 以及点击确实发出揭示请求)。
拖拽
拖拽能力来自 DSH 的停靠系统(dockkit),标签页类型注册好就自动具备,插件不需要也不能自己实现:
| 操作 | 结果 |
|---|---|
| 拖标签胶囊左右移动 | 在同栏内换位 |
| 拖到分栏边缘 | 与该格并排分栏(最多两格) |
| 把标签拖出边栏 | 变成浮窗,可拖到屏幕上任意位置、可改尺寸 |
| 浮窗右键菜单 → 收回到侧边栏 | 重新停靠 |
标签页正文的 tabActions 只暴露 {bindCommands, openResource, openTab, close}——浮出与移动是停靠条的手势,
所以正文里没有、也不该有拖拽代码;反过来说,正文只要不吞指针事件,拖拽就不会被破坏。
正文确实注册了
pointerdown了(拖动棋子),但只在棋盘上:停靠手势的抓取点是标签条上的胶囊, 那里没有任何本插件的处理器。棋盘自己的touch-action:none/user-select:none也只作用于棋盘那一块。
功能
对局(本地)
- 两种走子手势:点一下棋子再点目标格,或者直接把棋子拖过去。
- 完整规则:合法着法判定、将军/将死/逼和、王车易位(含"不能穿过被攻击格""被将时不能易位")、吃过路兵、兵升变(可选后/车/象/马)、五十步和棋。
- 对手:内置 alpha-beta 搜索 + 子力位置表,八档难度
Level 1…Level 8(与 lichess 同一套编号);同一档位也是创建 lichess 对局时发给它的level。 - 操作:点选棋子 → 点目标格;可选执白/执黑;悔棋(一次退回一整个回合,见下);翻转棋盘;SAN 着法记录。
悔棋语义:内置对手会应手,所以"最后一步"其实包含两着(你的 + 引擎的)。 只退一着等于把棋交回给引擎,它会立刻再应一手——用户永远收不回自己的棋。 现在悔棋会一直退到"上一次轮到你走"的局面,把引擎的应手和你自己的着法一起收回; 执黑时同理(引擎先走的那一着不算在你的回合里)。
界面取舍
边栏很窄、面板要在竖向空间里放下棋盘 + 分析 + 着法,所以有几处刻意的决定:
| 决定 | 理由 |
|---|---|
棋盘用熟悉的棕色木色(#f0d9b5 / #b58863) |
一眼就认得出是棋盘。这是插件里唯一的字面色:它和棋子字形一样属于"美术",浅色/深色模式下都一样,因为木色棋盘就是木色 |
| 坐标画在格子内部,取对面格的底色 | 这样坐标在深浅两种格子上都清晰,并且归属它标注的那一格 |
| 上一步与选中是淡绿色水洗,不是描边环 | 玩家熟悉的约定;水洗块不会盖住棋子轮廓,也不会和选中态抢视觉 |
| 可走点是半透明墨绿圆点,吃子是圆环 | 同上,可走点与吃子目标必须一眼可分 |
| 被将的国王是红色径向光晕(画在棋子下面) | 光晕不会压住国王字形 |
| 白子/黑子用同一枚实心字形 + 对比描边做双色 | 空心字形无法被 color 填充,之前color 对两侧相同,木色棋盘上白黑几乎分不出。现在是白底深描、深底浅描 |
棋格是尺寸容器,棋子填满格(width/height:100%) |
矢量棋子任意尺寸都清晰;cqw 留给可走点的圆环用 |
| 落子有滑行动画(190ms) | 见下面"棋子怎么滑过去" |
棋盘 max-width:min(100%,520px) 且居中 |
宽栏里一个 600px 的方块会迫使先滚动才能看到别的东西 |
| 棋盘上下各一条玩家栏(对手在上、你在下) | 一局棋的读法;复盘时显示 PGN 里的双方名字 |
控件按带标签的组排布(Play as、Level),操作按钮单独一行 |
一排光秃秃的 White / Black、Easy / Normal / Hard 要用户猜它属于什么;用弹簧把 Flip、Undo 推到最远右侧,会让控件离开它所作用的棋局 |
难度用 lichess 的 1–8 编号(Level 1…Level 8),且同一个数字也发给 lichess |
一处设定只该有一个含义:本地对手和 lichess 引擎用同一档,不必在两套刻度之间换算。中文里显示"3 级":档位名走 levelLabel(),因为下拉框的选项文字没法事后替换 |
| 八档用下拉框而不是分段控件 | 48px 标签列旁边塞 8 个按钮在窄栏里挤成一团;lichess 自己也是把档位收在滑杆后面 |
| 档位由 深度 + 可接受着法带宽 + 失误率共同决定,不是只调深度 | 1 层搜索但永远走最优解,下起来仍然像机器;blunder 才是"像初学者"的那一半 |
分析里的主变显示 SAN(e4 e5 Nf3),不是 lichess 发来的 UCI(e2e4 e7e5 g1f3) |
UCI 是机器记法,没人这样读棋;把着法序列在棋盘上重放一遍就得到带消歧义与将军标记的 SAN |
| 评估给的是优势条 + 一句话结论,线路带回合数、备选标出差距 | 见下面"引擎分析这一块怎么读" |
去掉行话:cloud depth 75 · cached → lichess cloud · depth 75;no stored line → "lichess has no stored evaluation for this position" |
界面上的字应该是不懂引擎的人也能读的 |
| 每步只在一处说"该谁走" | 状态行已经说了,玩家栏再说一次就是噪音 |
| 面板内四个视图用分段控件,不是第二条标签条 | 边栏标签页上方已经有一条标签条,再来一排胶囊是要用户解码的多余 chrome |
| 标签页形态下不画标题行 | 停靠条已经画了这个标签的胶囊和关闭键,正文里再写一次"♞ Chess"是重复 |
| 升变选择器浮在棋盘上 | 升变是棋盘上的动作;整窗遮罩在边栏里还会把会话一起遮黑 |
| 棋盘以外的一切(按钮、分段控件、边框)走宿主主题 token | 插件是宿主界面的一部分;只有棋盘是作品 |
拖动棋子
按下棋子就立刻选中它(合法目标的圆点马上出现),再拖到目标格松手即走子。要点:
| 决定 | 为什么 |
|---|---|
| 手势挂在棋盘上,不是格子上;并捕获指针 | 挂在格子上会在跨过间隙时丢事件;不捕获则指针移出棋盘后松手,拖拽会卡住不结束 |
目标格用 data-square 反查,不量像素 |
捕获指针后 event.target 永远是棋盘,所以必须自己命中测试;document.elementFromPoint 只查那个本来就有的属性,不做任何布局测量(测试里也就能用一个 DOM 桩驱动整个拖拽) |
| 位移小于 4px 就当点击 | 保持点击走子原样可用:手按下时抖一下,不该变成"从原格拖到原格"的拖拽 |
| 松手在原格也走点击路径 | 那条路径才知道选中/取消选中/升变该怎么处理 |
| 松手落到非法格 → 什么都不做,棋子回位 | 玩家熟悉的取消语义;不改变原有选中状态 |
拖动中的棋子用像素偏移(translate) |
棋盘上唯一一处不是"以格为单位"的量——它跟的是指针,不是格子 |
| 目标格给绿框 / 深色框反馈 | 拖拽里唯一的"会落在哪"的信号 |
棋盘 touch-action:none |
否则在触屏上拖棋子会变成滚面板 |
| 与停靠拖拽不冲突 | 停靠手势的抓取点是标签条胶囊,棋盘上没有本插件的处理器(见上面"拖拽"一节) |
棋盘是一格一个元素,所以"e2 到 e4"在 DOM 里其实是"这里少一个棋子、那里多一个棋子"。动画就是把 到达那一格的棋子先摆回它来的位置,再让它滑回原位:
--dshFromX / --dshFromY ← 起点偏移,单位是"它自己那一格"(一格 = 100%)
@keyframes dshChessSlide ← from: translate(起点) → to: transform:none
| 决定 | 为什么 |
|---|---|
| 偏移用自身格子的百分比,不用像素 | 棋子填满格子,所以"横走一格"就正好是 100%。从不需要测量,任何面板宽度、任何缩放、任何棋盘尺寸下数字都成立 |
起点由 move.castle 补齐第二个棋子 |
王车易位的车也在动。只动王的话,车就是瞬移的 |
到达格整体抬到 z-index:2 |
格子是 container-type:size,也就是布局包含 = 独立层叠上下文——抬棋子本身抬不出它自己的格子,滑行途中会被经过的格子盖住。要抬的是格子 |
给棋子一个跟着着法变化的 key |
React 会复用目标格的元素(吃子只是换内容),而 CSS 动画不会因为"类名本来就在"而重播;换 key 才会重新挂载 |
| "这个着法要动画"的判断粘住,不是一次性的 | 一次性判断的后果不是"少播一次":下一次渲染(350ms 后的云端评估)会同时丢掉元素的 key,把滑到一半的棋子重新挂载、动画当场截断 |
面板刚出现时不播(mounted 标志) |
打开标签页看到的是"上一步"的残留,不该假装那一步刚刚发生 |
| 设置里可以关(默认开),并遵循系统"减少动画" | @media (prefers-reduced-motion:reduce) 直接关掉;没必要让人因为一个棋盘而晕 |
harness.mjs没有 CSS 引擎,所以两侧分工:动画本身(keyframes、起点变量、reduce-motion)在verify-bundle.mjs里对落地样式表断言;哪个棋子在动、动的偏移是多少在 harness 里断言—— 包括 e2e4 是0%/200%、翻面后变成0%/-200%、王车易位是两个棋子(-200%/0%与200%/0%)、 以及关掉设置后一个都不动。
面板顶部那块评估曾经是"一个数字 + 三行光秃秃的着法",读起来像谜题。现在它回答三个问题:
引擎分析 ← 这一块是什么:lichess 云端 · 深度 75 · 已缓存
[优势条] +0.27 · 白方略优 ← 这个数字对谁有利、有多大
最佳 1. c4 dxc4 2. Nf3 Nf6 3. e3 c5
备选 1. Nf3 e6 2. c4 Nf6 3. Nc3 Bb4 −0.11
备选 1. e3 Nf6 2. c4 e6 2. Nf3 Be7 −0.18
| 改动 | 为什么 |
|---|---|
| 加了标题("引擎分析")与来源/深度 | 三行着法不会自己说它是谁给的、有多可信 |
| 优势画成条 + 一句话 | +0.27 本身不是答案;条回答"偏向谁、偏多少",句子回答"算大吗" |
着法带回合数(1. c4 dxc4 2. Nf3) |
光秃秃一串着法读不出轮到谁、也没有谱的节奏;带编号就读得像棋谱 |
备选行显示与最佳线的差距(−0.11) |
三个绝对分数要心算才能比较;差距直接说明"这条差多少",这才是选择 |
| 每一行的自身分数放进 tooltip | 需要时能拿到,但不再有第二个数字抢注意力 |
| 崩溃/无评估/查询中各自有独立文案 | "没有存档评估"和"连不上 lichess"是两件事 |
| 刷新失败不清空优势条 | 见下面"限流与优势条" |
限流与优势条
真机上出现过"优势条显示不出来",界面上只有一句 无法连接 lichess: too many requests to lichess。
根因不是渲染,而是被 lichess 限流(429),并且当时有两处把它们叠加成了"永远看不到条":
| 问题 | 修法 |
|---|---|
| 宿主对 429 没有退避:每换一个局面立刻再打一次 → 又被拒 → 限流一直续着 | 收到 429 就进入冷却(优先用 Retry-After,否则 2s → 5s → 15s → 30s 递增),冷却期内直接本地拒绝、不发请求;任何一次成功都清零 |
| 冷却会把用户主动的操作(走子、发起对局)也挡住 | 冷却只作用于可选的查询(cloudEval / tablebase),走子等照常发出 |
| 面板出错时把整块分析(含优势条)换成一行警告 | 同局面的刷新失败不再清空已有评估:条留着,原因写在下面一行(title 里保留原始信息) |
请求开始时 setCloud({loading:true}) 会覆盖上一次的好结果,于是失败时已经没东西可留 |
同局面的重载保留旧结果,只是标记 loading;只有"什么都没有"时才显示"正在查询" |
| 每次局面变化都请求(350ms 防抖) | 防抖提到 600ms;另外被拒过的局面在宿主缓存里 5 分钟内不会再问 |
| 云端完全没结果时(新开的棋盘 + 限流)没有条可留 | 本地引擎兜底:用内置浅搜(深度 3)给出分数与一手建议,并明确标注来源是"本地引擎"而不是 lichess |
限流的文案也单独一条(analysis.rateLimited):"限制频率,稍后自动恢复"和"连不上 lichess"是两回事,
不该共用一句话。
兜底为什么标注来源:本地浅搜不是参考评估(它就是对手那套搜索)。把它的分数挂在 lichess 名下是
说谎,而"没有条"又让功能看起来坏了。所以条照画,标题写 本地引擎 · 深度 3,下面仍然是失败原因。
本地搜索只给一手建议(没有主变),所以那一行是 最佳 1. e4 这样的单着,而不是三条线路。
顺带:本地引擎用
MATE(10 万)表示将杀,若直接当 cp 显示会变成+1000.00。scoreText现在把 ≥90000 的 cp 显示成#+/#-,与 lichess 送mate时的显示一致。
设置页与中文
第四个页签是设置,只放"不是每一局都要改"的偏好;难度与执棋颜色留在对局页签上,因为它们属于下棋而不是配置。
| 设置 | 它是真的开关 |
|---|---|
| 语言 English / 中文 | 整个面板的文字,包括不在设置页上的(对局页签、按钮、提示) |
| 云端分析 开 / 关 | 关掉后一个请求都不发(不是只把结果藏起来)——测试断言请求数不变 |
| 残局库 开 / 关 | 同样是真的不发请求 |
| 棋盘坐标 开 / 关 | 关掉后格子里的字母数字真的不再渲染 |
语言:文案集中在 MESSAGES.en / MESSAGES.zh 两张表里,t(key, params) 取词。en 是兜底表——
中文表缺某个键时回落到英文,所以漏翻的字符串会显示英文原文,永远不会显示成 analysis.meta 这样的键名。
默认语言取 navigator.language(中文系统就是中文),改动会持久化:
桌面版页面来自 dsh-app://app/ 这个固定 origin,所以 localStorage 能跨重启保留
(GUI 的 http 端口每次启动都变,如果按它作 origin 就存不住——这一点是查过才敢用的)。
为什么状态文案不再是一张英文表:
STATUS_TEXT这样的单语表在有第二种语言后只会和语言表各自漂移, 所以它的内容被搬进了status.*消息键,原表删掉了。
一个错字是看不出来的:账号页那句曾写成"由実主发起请求"——実(U+5B9F)是日文新字体,
中文是"宿主"(U+5BBF);两者只差一个码点,扫一眼都像对的词。所以 verify-bundle.mjs 现在会
把落地表里的转义解码后检查一遍日文专用字形(実 変 図 経 対 発 戦 単 帰 済 児 竜 巻 検 訳 険 収),
并检查占位符是否与英文表一致(否则中文句子里会露出裸的 {side})。
验证方式:把
宿主改回実主,检查立刻报got "実" want ""。
授权边界
没有拷贝 lichess 的任何素材。 棋盘棕色是公开的棋类约定(很多站点用相近配色),品牌图形一律没有引入。 lichess 的棋子套件(cburnett 等)带 GPL/CC 授权——那是别人的作品,不该搬进别的产品。
棋子用的是明确声明为公共领域的现成套件:Sashité 的斯汤顿棋子
(https://sashite.dev/assets/chess/,页面原文 "Free, public domain SVG assets for Chess.")。
取用方式是可复现的:tools/fetch-pieces.mjs 下载 12 个 SVG 并生成 tools/piece-art.js,
构建再把这张表内联进 client.js;原始文件逐字保留在 pieces/。没有手敲几何,也没有改图,
只换了容器(每子一个 .svg → 一张表)。
授权细节、以及站点页脚那句笼统的 "All rights reserved" 与素材页公共领域声明之间的张力,
都写在 NOTICE.md 里;那里也记了一个更严格的替代来源(Openclipart,整站公共领域)。
verify-bundle.mjs 会断言素材出处文字确实随包发布,因此这段渊源不会在后续改动中丢失。
为什么 lichess 不能嵌进面板
lichess 的所有页面都带 X-Frame-Options: DENY(实测 /、/training、/analysis 均为该值):
https://lichess.org/ -> 200 x-frame-options: DENY
https://lichess.org/training -> 200 x-frame-options: DENY
https://lichess.org/analysis -> 200 x-frame-options: DENY
这是 lichess 自己设的、由浏览器强制执行的头,<iframe> 一律被拒;插件无法绕过。
Electron 的 <webview> 也不是出路:DSH 主进程显式拦掉了它
(lib/main.js 的 will-attach-webview → preventDefault())。
能做到的是"送到真站",而且 DSH 宿主本来就为此铺好了路(lib/main.js:3482):
window.webContents.setWindowOpenHandler(({ url }) => { openExternal(url); return { action: "deny" } });
window.webContents.on("will-navigate", (event, url) => {
if (!url.startsWith("dsh-app://...")) { event.preventDefault(); openExternal(url); }
});
所以面板里的 <a target="_blank" rel="noopener noreferrer"> 会被交给系统浏览器,
并且不可能把 DSH 自己导航走。据此第一视图以 lichess 入口开头,且是深链:
| 链接 | 指向 |
|---|---|
| Open this position on lichess | https://lichess.org/analysis/standard/<当前 FEN,空格换下划线>(实测 200) |
| Open this game on lichess | https://lichess.org/<gameId>(仅在复盘 lichess 对局时出现) |
| Play on lichess.org / Puzzles | 真人对局与战术题只能在真站上进行 |
界面取舍:棋子
早期版本用 Unicode 字形 + -webkit-text-stroke 画棋子,效果是错的:描边会同时描外轮廓和
内部细节线,画出来又粗又糊,而且描边居中绘制、吃掉一半填充。字形也根本无法做成"白身细黑边"。
现在棋子是内联 SVG,每条 path 带自己的 fill,CSS 不参与其上色——素材按原样使用。
lichess 接入
云端评估(分析):棋盘上每一步自动查询 lichess 的 cloud-eval(
lichess.org/api/cloud-eval),显示深度与最多 3 条主变。查不到云端线路时明确显示"no stored line",不报错。匿名可用,无需登录。残局库(tablebase):七子以内的局面换用精确解而不是估值——显示"Black wins · mate in 9"以及最优着法。端点在
tablebase.lichess.ovh/standard(注意:lichess.org 上的/api/tablebase/...返回 404)。匿名可用。两个必须记住的接口事实(都实测确认,也都曾让我写出错误结果):
- 超过七子时它不报 404,而是返回
200+category:"unknown"+ 一串着法。 把它当成结论就会得到"开局是双方最优走法和棋"这种荒谬读数。现在宿主先数子力,超过七子直接found:false,连请求都不发。 category/dtz/dtm都是从「该走的一方」看的,不是从白方看的:8/8/8/8/8/4k3/4p3/4K3 b(黑方多一兵)→category:"win"。所以界面上的句子必须点名"该走的一方", 且dtm单位是半回合,要除以 2 才是棋手数的"几步杀"。
- 超过七子时它不报 404,而是返回
用户资料:输入用户名同时拉取对局列表与资料卡(头衔、各时限等级分、是否在线、是否正在对局)。
导入我的对局:输入 lichess 用户名拉取最近对局(
/api/games/user/<name>,NDJSON),点击任意一局即用game/export取 PGN 并在棋盘上复盘,支持|< < > >|步进;Practice可从任意历史局面接着下。PGN 复盘:直接粘贴 PGN(带头部、注释
{}、变着()、NAG$12、结果标记均可),逐着在棋盘上回放。每日/随机战术题:
/api/puzzle/next|daily,按解法在棋盘上走子,走错提示重试,走完显示 Solved。登录 lichess:贴一个 Personal API token(
challenge:write/board:play),即可创建与 lichess 引擎的对局,并在面板里把它下完。面板内与 lichess 引擎对局:创建对局后,Play 页签的棋盘就接管这盘棋;你走一步 → 发给 lichess → 引擎的应手由服务器推送回来(
boardStream,不是轮询)。另有 Refresh / Resign / Draw / Abort / Close。
说明:lichess 的 Board API 只允许 BOT 账号 通过
challenge:write+board:play自动走子;真人账号不能用这条路与真人对局。
登录是怎么走的
登录只有一件事容易做错:在 lichess 上建 token 时没勾权限。之后的表现和"登录坏了"一模一样, 但问题其实发生在另一个网页上。所以账号页把它拆成两步,并让每一步都指向正确的地方:
| 步骤 | 界面 | 为什么这样做 |
|---|---|---|
| 1 建 token | 一个真链接指向 /account/oauth/token/create?scopes[]=board:play&scopes[]=challenge:write&description=DSH+Chess |
点开就是勾好权限的建 token 页面,不用在 lichess 的设置里找。同时把两个权限名写在链接下面——万一 lichess 不再认 query 里的权限(未验证),用户仍然知道要勾什么 |
| 2 粘贴 | 输入框 + 显示/隐藏 + 使用 | token 不是用户已知的密码,看不见就没法发现"粘漏了半截";默认仍掩码 |
| 校验中 | 按钮变成"校验中…"并禁用 | 连点两次会发两次请求,而且第二次看起来像第一次没反应 |
| 失败 | 错误显示在输入框正下方 | 原来只有面板最底下那行提示,离出错的地方很远 |
| 粘贴带空白 | 提交前去掉所有空白 | 从浏览器复制 token 常带换行/空格,这是最经典的一次失败 |
| 明显不是 token | 本地直接拒,不发请求 | 太短(<10 字符)时没必要花一次往返去问 lichess |
另外,lichess 页签在未登录时原来只有一句"请先在账号页登录"(死胡同),现在旁边有一个"去登录"按钮,点它直接切到账号页。
链接里的
scopes[]预选未经验证:那需要登录态的 lichess 才能看到页面内容。所以文案里同时写了权限名, 功能不依赖预选成功。可验证的部分(路径存在、链接是<a target="_blank">、两个权限名都在 href 里)都有断言。
对局同步是怎么保证不漂移的
面板不把本地着法当作真相。规则是:lichess 的着法列表是唯一真相。
- 你点的一步先乐观地画在棋盘上(否则点了没反应),同时用
move发给 lichess; - 引擎的应手由服务器推送回来(见下节),不需要轮询;
- 事件到手后丢掉本地画的那步,用 lichess 的完整着法列表从头重放(
replayUci)重建棋盘——所以两边的局面不可能不一致; - 如果
move被拒(例如不是你的回合、或游戏已结束),立刻无条件重同步一次,把棋盘拉回真实局面; - 对局期间内置引擎被强制关闭(
aiTurn排除 bot 模式),否则会出现第二个、与真实对局无关的对手; - 对局期间本地的
New game/Undo隐藏——它们会作用在一块"真相来自 lichess"的棋盘上,而 lichess 没有撤销着法的接口。
真推送:boardStream
boardStream 是唯一不走信封的 op:GET /api/chess.lichess?op=boardStream&game=<id> 返回一个
长期存活的 NDJSON 响应体,lichess 每产生一个事件就转发一行(gameFull 一次,之后是 gameState),
空闲时每 15 秒发一行 : keep-alive 注释防止中间层掐连接。
为什么这样能通(这条是查过 DSH 源码才敢写的):Connection 的 HTTP 桥是逐块转发的——
lib/index.js 里 for await (const chunk of response.body) { res.write(chunk) … },带背压,
并在浏览器断开时 abort 请求。所以路由返回的 ReadableStream 会被逐块送到页面,
而页面断开时 request.signal 触发,宿主的 boardStream 顺势 reader.cancel() 掉 lichess 那条上游连接。
客户端用 lichessStream(gameId, onEvent, signal) 订阅:response.body.getReader() → 按 \n 切行 →
JSON.parse → 交给面板。跨块的断行会被缓冲到下一个块(这一点有测试钉住)。
一行
: …是心跳注释,不是事件,客户端会跳过它。
lichess API 参考
宿主在 /api/chess.lichess 上注册了一个路由(POST,或 GET 带查询串),客户端半边用 lichessCall(op, params) 调它。
信封固定:成功 {ok: true, value: …},失败 {ok: false, error: {code, message}}。
| op | 参数 | 成功返回(value) |
需要 token |
|---|---|---|---|
status |
— | {token, account} |
否 |
setToken |
token |
{authenticated} |
否 |
clearToken |
— | {authenticated:false} |
否 |
cloudEval |
fen, 可选 multiPv(1..5) |
{found, fen, depth, knodes, cached, score, lines:[{moves, uci, score, cp?, mate?}]};无线路时 {found:false, fen} |
否 |
tablebase |
fen |
{found, fen, checkmate, stalemate, category, dtz, dtm, dtw, moves:[{uci, san, category, dtz, dtm}]};子数过多时 {found:false} |
否 |
user |
username |
{id, username, title, patron, online, playing, ratings:{blitz:{rating,games,provisional}, …}} |
否 |
currentGame |
username |
{gameId, url, white, black, moves, status, rated, speed} 或 null |
否 |
games |
username, 可选 max rated perfType |
{username, games:[…]} |
否 |
pgn |
game(id 或网址) |
{id, pgn} |
否 |
puzzle |
可选 daily:true |
{id, rating, plays, themes, initialPly, solution, pgn, clock, perf, players} |
否 |
challenge |
level(1..8), limitSeconds, incrementSeconds, rated, color |
{id, url, status} |
是 |
move |
game, move(UCI) |
{ok} |
是 |
gameAction |
game, action(resign|draw|abort) |
{ok} |
是 |
boardState |
game |
{gameId, moves[], ply, status, winner, wtime, btime} |
是 |
错误码与 HTTP 状态的对应:unauthorized→401、forbidden→403、not-found→404、rate-limited→429、invalid-request→400、network / lichess-error / bad-response→502、其他→500。
给其它插件用
宿主半边把同一个客户端发布为服务 chessLichess(ctx.provide('chessLichess', …)),方法名与上表的 op 一一对应,参数是原始值而不是信封:
export const inject = ['chessLichess']
export function apply(ctx) {
ctx.effect(() => {
const stop = ctx.chessLichess.tablebase('8/8/8/8/8/4k3/4p3/4K3 w - - 0 1')
.then((answer) => console.log(answer.category, answer.dtm))
return () => { /* 无副作用可回收 */ }
}, 'demo: ask the tablebase')
}
因为面板与这个服务共用同一个客户端实例,在界面上设置的 token 对服务同样有效(反之亦然)。
外观
棋盘本身是木色作品(#f0d9b5 / #b58863,浅深主题一致,因为木色棋盘就是木色),棋子是内联的公共领域 SVG;
棋盘以外的一切(按钮、分段控件、边框、文字)走宿主主题 token(--dsw-alias-* / --dsw-static-*),跟随浅色/深色主题。
标签页正文是普通文档流(height:100% 填满分栏),随边栏宽度自适应;启动器按钮固定在右下角。
想在不重启 DSH 的情况下看棋子长什么样:
node tools/pieces-preview.mjs # 生成 pieces-preview.html
架构
Browser (client.js) Host process (index.js)
┌───────────────────────────┐ ┌──────────────────────────────────┐
│ rules engine (inlined) │ │ lichess route │
│ opponent search │ POST │ /api/chess.lichess │
│ board view / tabs │ ───────► │ {op, ...} → {ok, value|error} │
│ lichess wire layer │ │ │
└───────────────────────────┘ │ host/lichess.mjs │
│ → lichess.org (token 只在此处) │
└──────────────────────────────────┘
- 页面从不直接访问 lichess:所有请求经宿主同源路由
POST /api/chess.lichess发出,因此 access token 不进浏览器,也不受第三方 CORS 影响。 - 路由由
ctx.connection.fetch.register({path, methods, requestBody, fetch})注册在 Connection 的鉴权栅栏内(与 DSH 自带的 file-upload / deliverables 同一机制)。 - Host 半边声明
export const inject = ['connection']:没有 Connection 服务的 profile 里插件保持不激活,而不是抛错。
结构
| 文件 | 作用 |
|---|---|
package.json |
bundle 清单:dsh.bundle.patch + dsh.client + exports["./client"] |
cordis.patch.yml |
挂载声明(插入 Host 行 dsh-chess-board,行名必须与包名一致) |
index.js |
Host 半边:lichess 路由与 {op} 分发 |
host/lichess.mjs |
纯 JS lichess 客户端(cloud-eval / games / PGN / puzzle / challenge / move),可独立测试 |
client.js |
生成物:经典脚本客户端 bundle |
tools/engine.mjs |
规则引擎权威实现(// ENGINE-BEGIN..END 标记内联进 bundle) |
tools/ui.js |
对手搜索、辅助、CSS、插件注册;其开头(到 // UI-BEGIN)是视图前导 |
tools/lichess-view.js |
客户端侧 lichess 通讯层 + PGN/SAN 解析 |
tools/panel.js |
React 棋盘视图(三个标签页) |
tools/build-client.mjs |
由上面四个源文件生成 client.js,并做经典脚本语法检查 |
tools/perft.mjs |
perft 正确性测试(对公开参考值) |
tools/perft-brute.mjs |
独立穷举着法生成器,交叉验证 perft |
tools/game.mjs |
整局终局测试(将死/逼和/易位/吃过路兵/升变) |
tools/host-tests.mjs |
host/lichess.mjs 单元测试(假 fetch) |
tools/host-routes.mjs |
Host 路由与 {op} 分发测试(含 HTTP 状态映射) |
tools/lichess-view-tests.mjs |
客户端 lichess 层测试(PGN 解析、SAN 解析、信封) |
tools/harness.mjs |
客户端渲染冒烟测试(最小 React hooks + DOM 桩),含 lichess 标签页与复盘 |
tools/verify-bundle.mjs |
bundle 预检:清单、补丁、路由注册、五段内联源码与各自源文件不漂移 |
tools/pack-check.mjs |
发布前预检:按 files 复制出「将要发布的文件」,在其中整套跑一遍 |
tools/live-lichess.mjs |
需要网络:对真实 lichess 的冒烟测试(校验真实接口形状) |
local/install.mjs |
仅本机:调用 DSH 官方安装操作并打印结构化结果(含本机安装路径,不进发布包) |
安装
用 DSH 自己的包操作,不要手工改 profile 文件:
dsh plugin add dsh-chess-board
或在 DSH 的插件页里填包名安装(GUI 里点一下即可)。
DSH 的插件管理器也接受 git 与 tarball 形态:
dsh plugin add github:<user>/<repo> # 直接装一个仓库
dsh plugin add ./dsh-chess-board-0.1.0.tgz # 装一个本地 tarball
dsh plugin add "<本目录绝对路径>" # 开发时:link 安装当前目录
安装会把包链接进 profile 的 node_modules,并把包名追加到
<DSH_HOME>/profiles/<profile>/package.json 的 dsh.profile.bundles。
需要重启 DSH 才能生效:desktop profile 未开启 HMR,bundle 列表只在宿主启动时组装一次。
开发
npm run build # 重新生成 client.js(含语法检查)
npm test # 全套:规则 / 对局 / 宿主 / 渲染 / bundle 预检
npm run pack:check # 按 files 复制出「将要发布的文件」,在其中整套再跑一遍
npm run test:live # 打真实 lichess(需要网络,不在 npm test 内)
想单独跑某个 suite(tools/*.mjs 都是可直接执行的脚本):
node tools/perft.mjs # 规则正确性:perft 到深度 4/5 + 记谱用例
node tools/perft-brute.mjs # 独立实现交叉验证 perft
node tools/game.mjs # 整局终局用例
node tools/host-tests.mjs # lichess 宿主客户端
node tools/host-routes.mjs # lichess 宿主路由
node tools/lichess-view-tests.mjs
node tools/harness.mjs # 客户端渲染冒烟测试
node tools/verify-bundle.mjs
改规则改 tools/engine.mjs,改界面改 tools/panel.js,改 lichess 逻辑改 host/lichess.mjs
或 tools/lichess-view.js,然后重新 build。
手边只有 DSH、没装独立 Node 时,可以用 Electron 的 node 模式跑同一批脚本:
$env:ELECTRON_RUN_AS_NODE=1; & "<DSH 安装目录>\DSH NEXT.exe" tools/build-client.mjs(DSH 基于 Electron,ELECTRON_RUN_AS_NODE会让它按 plain node 执行这个脚本。)
发布流程见 PUBLISHING.md。
验证状态
- 规则:起始局面 perft 深度 5 = 4,865,609;Kiwipete 深度 4 = 4,085,603; Position 3 深度 5 = 674,624;Position 4 深度 4 = 422,333;Position 5 深度 4 = 2,103,487, 全部与公开参考值一致。愚人将杀 / 学者将杀 / 逼和 / 易位 / 吃过路兵 / 升变 / 记谱消歧全部通过。
- 交叉验证:
perft-brute.mjs用独立的穷举生成器复核 perft,两者计数一致。 - 宿主:
host-tests.mjs(缓存 TTL、NDJSON 容错、状态码映射、token 门禁)、host-routes.mjs({op}分发、信封形状、400/401/404/429/500 映射、GET/POST)全部通过。 - 限流退避:429 被记为
rate-limited并带"约 N 秒";冷却期内的另一个局面不再发请求(断言请求数不变); 用户主动的请求不被冷却挡住(请求数照增);等过冷却后查询恢复;连续被拒时退避递增(2/5/15)。 - 优势条存活:同局面刷新失败后条仍在、分数仍可读、下面多一行本地化的限流说明、
原始原因留在
title里;云端完全没有结果时由本地引擎给出条,标题写local engine而不是lichess cloud,并附一手带回合数的建议着法。 - 本地兜底评估(
levels.mjs):开局接近均势、视角是白方(黑方大优为负、镜像为正)、 建议着法确实是 20 个开局着法之一、将杀局面无建议着法且分数极端、 逼和局面分数为 0。(写这组时先写错了两个 FEN:Qf7+Kg6 vs Kh8看着像将杀其实是逼和, 另一个根本有子可动——测试因此"证明"了错的东西。) - 客户端 lichess 层:PGN 解析、SAN 解析(含
a8Q松散写法与升变带将)、 对局摘要、信封错误处理全部通过。 - 渲染:64 格、双方 16 子、点击走子、AI 轮次封锁、定时器触发后 AI 走子、 云端评估上板、导入对局并步进复盘、复盘时不接受走子、Practice 接管全部通过。
- 悔棋:退一整个回合(引擎应手 + 你的着法一起收回)、回到开局、轮到你走、 且引擎不会立刻重走(走完整段 effect 循环后再断言)。
- 真推送:
boardStream的事件原样转发、完整行到达即转发、跨块断行被拼接、 流在上游结束时结束、消费者中止会连带取消上游;harness 里断言应手经推送到达 且boardState零次调用。 - 分析块:编号棋谱(
1. e4 e5)、备选线被标记且显示差距、标题存在、UCI 不泄漏。 - 落子动画:只有刚动的那一个棋子带偏移(e2e4 =
0%/200%)、棋子离开的格子不再有棋子、 翻面后同一着法变成0%/-200%(方向跟着棋盘朝向)、王车易位有两个棋子在滑 (王-200%/0%、车200%/0%)、设置关掉后一个都不滑且局面不受影响、 以及判断跨渲染存活(否则 350ms 后的评估渲染会把滑到一半的棋子截断—— 这条断言当场抓到过一次性判定)。 - 用户交互:设置里四个开关与语言切换(见下)。
- 右下角角标:三种状态都断言——没开棋盘时不渲染、开着且可见时不渲染、
开着但隐藏时渲染出按钮(内联
fixed/ 右下16px),且点击发出的是openTabFromTarget('chess', …)(侧栏会据此"reveal the column")。 这一组靠 harness 里新加的一个可控假IntersectionObserver:桩会按测试要求 派发isIntersecting,否则"挂载但不可见"这个状态根本没法在测试里造出来。 - 拖动棋子:按下即选中、按住后棋子按指针位移跟随(
translate(4px,-70px)精确断言)、 指针所在格被标为合法落点、松手在该格真的走子、不位移的按下仍然走点击路径 (走子后手里不留棋子)、拖不起来对手的棋子、对方思考中按下不开始拖拽、 松手到非法格不落子且标成拒绝落点。 这一组靠 harness 里加的一个可控document.elementFromPoint桩:pointAt('e4')就让下一次命中测试落在 e4,于是整个拖拽手势在无布局的测试环境里也能跑通。 - 设置与语言:设置页开关存在;关掉云端分析后请求数不变、评估块消失,
重新打开后请求与块都回来;坐标开关真的移除/恢复坐标;切到中文后页签、
设置项、开关值、以及不在设置页上的对局页签文字都变中文,切回英文恢复。
另外有一个逐页签全量扫描:中文下四个页签都不许再出现英文界面词
(
New game、Level、best、tablebase、Promote to…)——逐一断言字符串 抓不到"没人想到要断言的那一条",而这条扫描当场抓到了难度下拉框漏翻的Level 3。 (验证方式:把下拉框改回英文标签,扫描立刻报got "Level " want ""。) - 登录:账号页两步流程、建 token 链接(href 里带两个权限、
target=_blank、权限名同时写在页面上)、 未登录的 lichess 页签有可点的"去登录"、输入框默认掩码且可显示、 太短的粘贴不发请求、带空白的粘贴被去掉空白后再发、 校验中按钮变"校验中…"且disabled、被拒的 token 在输入框下方报错且保留已粘贴内容、 退出登录后表单回来。 - bundle:清单、补丁、路由注册、五段内联源码与源文件一致、经典脚本可解析。
- 真实接口:
live-lichess.mjs对真实 lichess 通过(cloud-eval / games / PGN / daily puzzle)。 这一步发现并修正了一个真实缺陷:/api/game/export/{id}返回 404,正确端点是/game/export/{id}。 - 安装:本仓库在开发时以 link 方式装进 desktop profile 的
node_modules,包名写入dsh.profile.bundles。 包名从dsh-chess改为dsh-chess-board后需要重新装一次(profile 里记的是包名)。
尚未验证:插件在运行中的应用里的实际外观与交互。desktop profile 未开启 HMR, bundle 列表只在宿主启动时组装,所以必须重启 DSH 才能看到棋盘。
一条被测试放过的真 bug(已修)
症状:真人对局里走出第一步后,内置对手永远不动,状态行一直停在 Thinking...。
本地对局与 lichess 对局都受影响(凡是走 aiTurn 的地方)。
根因:effect 依赖了自己写入的状态。
var aiTurn = … && !thinking && … // ← 读了 thinking
React.useEffect(function () {
if (!aiTurn) return undefined
setThinking(true) // ← 写 thinking,于是 aiTurn 变 false
var timer = setTimeout(playTheMove, 60)
return function () { cancelled = true; clearTimeout(timer) }
}, [aiTurn, pos, aiLevel, commit]) // ← 依赖含 aiTurn
setThinking(true) 让 aiTurn 翻转 → React 重跑该 effect → 清理函数取消了自己刚排的定时器。
thinking 现在只用于挡住输入,不再参与 aiTurn。
为什么测试没抓到:测试桩的 useEffect 忽略依赖数组,而且驱动顺序是"先 flush 定时器、再跑 effect",
而真实 React 是每次提交后都跑 effect。两者叠加,正好把"自己取消自己"这一步跳过去了。
怎么补的(这是比修 bug 更重要的部分):
- 桩现在按依赖数组决定是否重跑,并按 React 的语义在重跑前调用清理函数;
- 在对手该走子的地方显式走一遍 effect 循环(
__runEffects(); renderNow(); …),模拟真实提交; - 验证方式是把 bug 放回去:补完之后再插入
!thinking,套件立刻报the board is no longer thinking与white is on move again两条失败;去掉后恢复全绿。
教训:一个"忽略依赖数组"的测试替身,会系统性地放过一整类 bug——effect 依赖自己写入的状态。 这类 bug 在单元测试里全绿,只在真实渲染器里卡死。
排查
运行中的应用出问题时,先看日志——它会直接点名插件和报错:
<DSH_HOME>/logs/desktop-next.log
关心的行:
| 日志 | 含义 |
|---|---|
warning: N entries did not activate |
有插件行挂载失败,紧跟着就是原因 |
<行名> (<包名>): Error: ... |
宿主半边抛错,该插件整行未激活 |
slot entry crashed in '<slot>': <错误> |
客户端组件渲染时崩了(DSH 会记录并可让该条目降级) |
本项目实际踩过并修掉的三个坑(都记在上面这个日志里):
slot entry crashed in 'shell.overlay': ReferenceError: useDarkMode is not defined—— 生成client.js时截取的"视图前导"切片错误,面板依赖的辅助函数没被内联。现在verify-bundle.mjs会断言视图依赖的每个跨块符号都在它之前定义。dsh-chess: Error: cannot set property "chessLichess" without provide—— 宿主半边往 Cordis 的ctx上写了一个未通过provide声明的属性。Cordis 的 ctx 是 Proxy,写未声明名字会直接抛错并使整个插件不激活。已删除该行;测试桩现在也用 禁止未声明写入的 Proxy,所以这类错误不会再溜过去。- 改成标签页后"看起来没变" ——
shell.overlay里的启动器复用了同一个面板组件,而 面板的hidden初值是false,于是启动器一渲染就把整块棋盘浮在会话上面,等于旧浮层 原样还在。现在 overlay 位置的hidden初值为true(只显示骑士按钮),标签页位置 永远显示;harness.mjs断言的是角标在"开着但隐藏"时只渲染出一个按钮。 - 骑士按钮跑到窗口左上角 —— 启动器那条分支提前
return,没有渲染<style>元素, 而.dshChessLauncher{position:fixed;right:16px;bottom:16px}就在那段样式表里。按钮因此 拿不到定位,退化成普通 inline 元素落在容器左上角。现在启动器的定位内联写在元素上 (样式表丢了也不会错位),并额外用一层display:contents包裹来照常挂载样式表;harness.mjs断言该按钮的内联position/right/bottom(在"开着但隐藏"这个状态下—— 角标的可见条件收紧后,这条断言也必须在正确的状态下跑)。
这条的普遍教训:组件里任何提前
return的分支都要自带它需要的样式。把渲染样式表放在 某个分支里、又有别的分支提前返回,就等于给那个分支"卸妆"。
测试桩的一条经验
harness.mjs 是手写的 React 替身,它有一条容易踩的约束:每次渲染要把组件树展开一次,
之后只对展开后的普通元素树做断言。早期版本在每次检查时重新调用组件,于是"第一次读到的
状态"和"第二次读到的状态"不一致(诊断信息说 e2 已选中,紧邻的断言说没选中)。
展开一次既更快,也消除了这类读取副作用。
排查"改了却没变化"
先确认插件还在 profile 里,再怀疑代码。本项目真实发生过一次:插件被插件页的「卸载」移除了
(dsh.profile.bundles 与 dependencies 里都没有它),于是宿主行不存在、客户端半边一行都不渲染,
看起来就像"改动没生效"。检查方法:
(Get-Content "$env:DSH_PROFILE_DIR\package.json" -Raw | ConvertFrom-Json).dsh.profile.bundles
里面有 dsh-chess-board 才算装着。注意:插件页的开关是临时停用,卸载会真的移除包——
想临时关掉请用开关。
已知限制
- 对手是普通搜索 + 失误率(最高档 4 层),不是强引擎;八档编号与 lichess 一致,但引擎不是 Stockfish。
- 真人实时对局未实现:lichess 的 Board API 只允许 BOT 账号自动走子。与引擎的对局可以在面板里下完; 与真人对局只能点开 lichess 网页去打。
- 对局状态已是推送,但一条连接只跟一盘棋:
boardStream每个订阅各开一条 lichess 流; 页面关闭 / 切走时靠request.signal连带取消上游。文档页签不订阅(只有存在 lichess 对局时才订阅)。 boardState仍在,但只用于"拉一次":建局后的首次同步与手动 Refresh 用它;持续更新走流。- lichess 的开局数据库(
explorer.lichess.ovh)在本机被一个需要鉴权的代理拦下(401), 所以没有接入开局统计;残局库(tablebase.lichess.ovh)、云端评估与对局导入都不受影响。 - token 只在宿主内存中,DSH 重启后需要重新贴一次。
- 无重复局面/子力不足自动判和(只实现了五十步)。
- 插件不向 agent 暴露工具:棋盘状态在页面内,不由会话日志派生。
- 只有中英两种语言,且语言表在客户端 bundle 里;第三种语言要重新构建 bundle(目前 240 余条文案)。
- 设置存在页面的
localStorage里,所以它是"这个界面"的偏好,不随 DSH 的 profile 同步到别的机器。
许可
MIT
No comments yet. Be the first to write one.