DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

tomandpotato /

tomandpotato/dsh-chess-board

Verified

在 DSH(DeepSeek Harness)右侧边栏里下国际象棋:完整规则、八档内置对手、lichess 云端评估 / 复盘 / 战术题,中英双语。· A chess tab for DeepSeek Harness: full rules, built-in opponent, lichess eval, review and puzzles.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHubProject homepage
READMESource: main@235bfb06

dsh-chess-board

npm License: MIT

在 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)。

  • 打开方式:

    1. 边栏标签条上的"添加"控件 → 引导页里的 Chess 卡片;
    2. 会话里文件/链接旁的侧栏入口。
  • 右下角角标只在一种情况下出现:棋盘开着、但没显示出来(典型是收起右侧栏)。 它是召回按钮,不是入口——没有棋盘开着时它不占位置,因为标签条自己就能开一个。

    状态 正文 角标
    没开过 / 标签页已关闭 未挂载 不渲染
    标签页开着、面板可见 挂载且可见 不渲染
    侧栏收起(开着但不可见) 仍然挂载 显示

    启动器是固定浮层条目,看不到侧栏的标签状态,所以由标签页正文自己上报,上报两个轴: 是否挂载(= 标签页是否开着)与是否在屏幕上。

    "开着但不可见"这种状态是实测出来的,不是推断的: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)。匿名可用。

    两个必须记住的接口事实(都实测确认,也都曾让我写出错误结果):

    1. 超过七子时它不报 404,而是返回 200 + category:"unknown" + 一串着法。 把它当成结论就会得到"开局是双方最优走法和棋"这种荒谬读数。现在宿主先数子力,超过七子直接 found:false,连请求都不发。
    2. category / dtz / dtm 都是从「该走的一方」看的,不是从白方看的: 8/8/8/8/8/4k3/4p3/4K3 b(黑方多一兵)→ category:"win"。所以界面上的句子必须点名"该走的一方", 且 dtm 单位是半回合,要除以 2 才是棋手数的"几步杀"。
  • 用户资料:输入用户名同时拉取对局列表与资料卡(头衔、各时限等级分、是否在线、是否正在对局)。

  • 导入我的对局:输入 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 的着法列表是唯一真相。

  1. 你点的一步先乐观地画在棋盘上(否则点了没反应),同时用 move 发给 lichess;
  2. 引擎的应手由服务器推送回来(见下节),不需要轮询;
  3. 事件到手后丢掉本地画的那步,用 lichess 的完整着法列表从头重放(replayUci)重建棋盘——所以两边的局面不可能不一致;
  4. 如果 move 被拒(例如不是你的回合、或游戏已结束),立刻无条件重同步一次,把棋盘拉回真实局面;
  5. 对局期间内置引擎被强制关闭(aiTurn 排除 bot 模式),否则会出现第二个、与真实对局无关的对手;
  6. 对局期间本地的 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 更重要的部分):

  1. 桩现在按依赖数组决定是否重跑,并按 React 的语义在重跑前调用清理函数;
  2. 在对手该走子的地方显式走一遍 effect 循环(__runEffects(); renderNow(); …),模拟真实提交;
  3. 验证方式是把 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 会记录并可让该条目降级)

本项目实际踩过并修掉的三个坑(都记在上面这个日志里):

  1. slot entry crashed in 'shell.overlay': ReferenceError: useDarkMode is not defined —— 生成 client.js 时截取的"视图前导"切片错误,面板依赖的辅助函数没被内联。现在 verify-bundle.mjs 会断言视图依赖的每个跨块符号都在它之前定义。
  2. dsh-chess: Error: cannot set property "chessLichess" without provide —— 宿主半边往 Cordis 的 ctx 上写了一个未通过 provide 声明的属性。Cordis 的 ctx 是 Proxy,写未声明名字会直接抛错并使整个插件不激活。已删除该行;测试桩现在也用 禁止未声明写入的 Proxy,所以这类错误不会再溜过去。
  3. 改成标签页后"看起来没变" —— shell.overlay 里的启动器复用了同一个面板组件,而 面板的 hidden 初值是 false,于是启动器一渲染就把整块棋盘浮在会话上面,等于旧浮层 原样还在。现在 overlay 位置的 hidden 初值为 true(只显示骑士按钮),标签页位置 永远显示;harness.mjs 断言的是角标在"开着但隐藏"时只渲染出一个按钮。
  4. 骑士按钮跑到窗口左上角 —— 启动器那条分支提前 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

—/ 5

No ratings yet

Verified DSH bundle

Commit 235bfb06a622

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout