chore: 文档库全量重新入库(2026-10-20)
build / build-and-scan (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-20 15:05:55 +08:00
commit 5ec99ce227
668 files changed
+164448

No files matched your search

@@ -0,0 +1,77 @@
# 最小可跑示例 · IM 插件(含跨进程反向回调)
> 目标:**插件作者有一个能直接跑的起点** —— 不用先啃完契约、不用先连真平台。
> 反向通道(平台 → 插件)已于 **2026-09-26 落地并上 47**;本目录是它的**唯一示例落点**。
## 一键跑
```bash
cd poc/business-plugins-im/minimal-callbacks
node self-check.mjs # 25 例;退出码 0 = 全绿
```
不需要 `npm install`(**零依赖**)、不需要平台、不需要环境变量 —— 自检自带一个
`mock-platform.mjs`(模拟登记 / 长轮询 / 回投 / 出向四个端点),把完整协议跑一遍。
## 文件
| 文件 | 作用 |
|---|---|
| `index.mjs` | **主体**:声明面 `manifest` + 拼装 `start()`;两条回调用 `onSpeakRule()` / `onEvent()` 注册 |
| `mock-platform.mjs` | 极简「平台侧」模拟(⚠️ 只对齐协议形状,不替代真平台) |
| `self-check.mjs` | 自检 25 例:登记 / 规则拒与放行 / 事件送达 / 未接线不变 |
| `package.json` | `type: module`;`npm run self-check` 同义 |
## 覆盖的两条反向回调(跨进程)
| 回调 | 语义 | 本示例怎么用 |
|---|---|---|
| `host.onSpeakRule()` → `speakRules.check` | **同步**:平台**写之前**问插件,带 deadline | **回合制**:没轮到的人发言 ⇒ `{ok:false, detail}` ⇒ 拒发 |
| `host.onEvent()` → `events.onEvent` | **异步**:不阻塞消息流,尽力送达 | 有人发言 ⇒ 广播一帧(`host.frame()`) |
## 🔴 三个必须记住的点
1. **回合制 / 限速型必须显式声明 `onTimeout: 'deny'`**
—— 缺省是 `'allow'`(为了与平台既有口径「无插件 / 插件故障 ⇒ 放行」逐字一致)。
对回合制来说"放行"就等于**规则失效**:你的实例不在线(重启 / 崩溃 / 部署窗口)时,
非当前回合者的消息会照常写入。声明位在 `manifest.speakRules[].onTimeout`。
2. **`manifest` 里不许塞函数**(`check` / `validate` / `onEvent` / `render` / `onAction`)——
跨不了进程,SDK 会自动剔除。函数侧一律走 `onSpeakRule()` / `onEvent()` 注册;
自检里有一条专门断言"送上平台的 manifest 确实没有函数"。
3. **一条实例只该有一条常驻长轮询**(`ready()` 登记后自动启,幂等)——它直接占平台连接额度,
⛔ 别自己再写第二条。收尾用 `await host.close()`(会掐断在途长轮询,否则进程退不出去)。
## ⛔ 不做什么
- **不开端口**:所有调用都是插件**拨出去**的(HTTP 到平台);平台不打进实例、实例不需可寻址。
- **不新增凭据**:复用实例凭据(`DSH_PLATFORM_IM_URL` / `DSH_IM_INSTANCE_TOKEN`,平台起实例时注入)。
- **不把事件当可靠消息流**:事件队列**有界**(满则丢最旧并计数)⇒ 要可靠增量请用
`host.subscribe()`(游标补齐,不会丢)。
- **判不了就拒(fail-closed)要靠"返回 `{ok:false}`",⛔ 不是靠抛错** ——
你的 `check` 抛错时,平台按"插件故障 ⇒ 放行"处理(与同进程语义逐字一致)。
## 接真平台
1. 把 `sdk/im-plugin-host/index.mjs` 拷进你自己的包(单文件、零依赖、可 vendoring),
并把 `index.mjs` 顶部的 import 改成你包内的路径。
2. 保证 `pluginId`(SDK 参数 = **包名**)与 `manifest.packageName` / `manifest.pluginId` **自洽**:
`@dsh-local/im-plugin-demo` → 内部 id `im_plugin_demo`(去 scope、非字母数字 → `_`)。
不自洽 ⇒ `plugin-id-mismatch`;**出向恒 `plugin-unavailable`**。
3. 在实例内运行:`DSH_PLATFORM_IM_URL` / `DSH_IM_INSTANCE_TOKEN` 由平台注入,
⛔ 不要手填、不要硬编码。出向发言前,bot **必须已是该房成员**(进房是房主 / 管理员动作)。
## 与同目录另外三个示例的关系
`mud-rate` / `office-tasks` / `trpg-turn` 写于反向通道落地**之前**,把 `check` / `onEvent`
直接写在 `manifest` 里 —— 那是**同进程**形态,跨不了进程。它们仍有参考价值(声明面、
业务角色、面板),但**函数侧请照本目录写**。
⚠️ 本示例**刻意不声明 `tables`**(零数据面):一是保持"最小",二是
`package.json#dsh.data.schema` 与契约内 `tables:` 两种写法的**口径尚未裁定**(见接续包 §8-2)。
需要数据面时先定口径,别照本示例推。
## 依据
- 契约:`dsh-server-docs/01-规范/09-IM插件SDK与扩展点契约.md`(§10.4 / §12)
- 落地与验收:工作区 `接续包_IM反向通道实现_20260926.md`
- 内核约束(⛔ 不得破):`store.ts` 零改动 / `hub.ts` 纯插入 / `ws.ts` 只动投递点 / `routes/im.ts` 纯插入
@@ -0,0 +1,170 @@
/**
* @dsh-local/im-plugin-minimal-callbacks —— **可跑的最小示例(含跨进程反向回调)**。
*
* ## 它解决什么问题
* 本目录是「插件作者从 0 到能跑」的**唯一落点**:`poc/business-plugins-im/` 下另外三个示例
* (`mud-rate` / `office-tasks` / `trpg-turn`)写于**反向通道落地之前** ⇒ 它们把 `check` /
* `onEvent` 直接写在 manifest 里,那是**同进程**形态,**跨不了进程**。
* 本示例是补齐后的正确写法。
*
* ## 覆盖的两条反向回调(跨进程 · 平台 → 插件)
* ① **同步发言规则** `host.onSpeakRule()` —— 平台**写之前**把待判定的发言交给长轮询,
* 你在**自己进程内**判,`{ok:false, detail}` ⇒ 拒发。本示例 = **回合制**,
* 🔴 并且**必须**在 manifest 里声明 `onTimeout: 'deny'`(理由见下)。
* ② **异步事件订阅** `host.onEvent()` —— 平台把事件放进你的有界队列,长轮询取回后在本进程执行。
*
* ## 🔴 为什么回合制必须声明 `onTimeout: 'deny'`
* `speakRules[].onTimeout` 的**缺省值是 `'allow'`**(放行)—— 这是为了与平台既有口径
* 「无插件 / 插件故障 ⇒ 放行(回落自由并发)」**逐字一致**,不让插件把房间卡死。
* 但对**回合制 / 限速型**来说,"放行"就是**规则失效**:你的实例不在线(重启 / 崩溃 / 部署窗口)
* 时,非当前回合者的消息会**照常写入**,等于没装规则。
* ⇒ **回合制 / 限速型必须显式声明 `'deny'`**,否则超时即破规则。
*
* ## ⛔ 三条纪律(本示例刻意遵守,照抄即可)
* - **不开端口**:所有调用都是**你拨出去**的(HTTP 到平台);⛔ 插件不得监听端口。
* - **manifest 里不许塞函数**:`check` / `validate` / `onEvent` / `render` / `onAction`
* **跨不了进程**,SDK 会自动剔除 ⇒ 函数侧一律走 `onSpeakRule()` / `onEvent()` 注册。
* - **一条实例只该有一条常驻长轮询**(`ready()` 自动启,⛔ 别自己再写第二条)——它占平台连接额度。
*
* @module @dsh-local/im-plugin-minimal-callbacks
*/
// 示例内用**相对路径**引入 SDK(本仓自带单文件,零依赖、可 vendoring)。
// 真实插件里请把 `sdk/im-plugin-host/index.mjs` 拷进你自己的包,再 `import ... from '<你的包内路径>'`。
import { createImPluginHost } from '../../../sdk/im-plugin-host/index.mjs'
/** 你的包名(🔴 SDK 的 `pluginId` 参数 = 包名,不是平台内部 id)。 */
export const PLUGIN_ID = '@dsh-local/im-plugin-minimal-callbacks'
/** 本示例只用一种房间类型,便于一眼看懂。 */
export const ROOM_TYPE = 'demo-turn'
const TIMEOUT_MS = 50
const CIRCUIT = { threshold: 5, cooldownMs: 30_000 }
/**
* 声明面(交给平台)—— ⛔ **不含任何函数**。
*
* 声明**照传**(平台靠它路由"该问哪个插件");函数侧在 `start()` 里用 `onSpeakRule()` / `onEvent()` 注册。
*/
export const manifest = {
pluginId: 'im_plugin_minimal_callbacks',
packageName: PLUGIN_ID,
label: '最小回调示例',
version: '0.1.0',
// ── 扩展点 1 · 房间类型 ──────────────────────────────────────────────────
roomTypes: [
{
type: ROOM_TYPE,
label: '回合制示例房',
requiresReview: false,
transport: 'chat',
defaultConfig: { turnOf: '' },
uses: ['roomTypes', 'speakRules', 'events'],
timeoutMs: TIMEOUT_MS,
circuit: CIRCUIT,
},
],
// ── 扩展点 2 · bot(出向发言要借它的身份) ──────────────────────────────
bots: [{ botId: 'bot_minimal', label: '示例助手', timeoutMs: TIMEOUT_MS, circuit: CIRCUIT }],
// ── 扩展点 3 · 出向声明(⛔ 未声明 ⇒ 出向恒 `plugin-unavailable`) ──────
outbounds: [
{ outboundId: 'minimal_out', label: '出向', roomTypes: [ROOM_TYPE], timeoutMs: TIMEOUT_MS, circuit: CIRCUIT },
],
// ── 扩展点 4 · 发言规则(**声明**) ─────────────────────────────────────
// 🔴 `onTimeout: 'deny'` 是**本示例的要点**:回合制下超时必须拒,否则规则形同不存在。
speakRules: [
{ rule: 'turn-based', roomTypes: [ROOM_TYPE], onTimeout: 'deny', timeoutMs: TIMEOUT_MS, circuit: CIRCUIT },
],
// ── 扩展点 5 · 事件订阅(**声明**) ─────────────────────────────────────
events: [{ events: ['message.created'], roomTypes: [ROOM_TYPE], timeoutMs: TIMEOUT_MS, circuit: CIRCUIT }],
}
/**
* 回合制判定(**纯函数**,导出以便单测直接断言 —— 跨进程后你更需要单测,因为平台侧不再有你的代码)。
*
* 规则:`config.turnOf` = 当前该行动的人。
* - 空 ⇒ **视为自由**(未开局不该把房间卡死);
* - 是本人 ⇒ 放行;
* - 系统消息 / bot / agent 回写 ⇒ 放行(否则 agent 代答会把自己卡死);
* - 其余 ⇒ 拒,并给**可读原因**(`detail` 会进平台的审计 / 回给调用方)。
*
* ⚠️ 本函数**抛错**时平台按"插件故障 ⇒ 放行"处理(与同进程语义逐字一致)——
* 所以"判不了就拒"(fail-closed)要靠**返回** `{ok:false}`,⛔ 不是靠抛错。
*/
export function decideTurn(input) {
const turnOf = input?.config?.turnOf
if (typeof turnOf !== 'string' || turnOf === '') return { ok: true, reason: 'ok' }
if (turnOf === input.authorId) return { ok: true, reason: 'ok' }
if (input.authorKind === 'bot' || input.envelope?.via === 'system') return { ok: true, reason: 'ok' }
return {
ok: false,
reason: 'bad-args',
detail: `还没轮到「${input.authorId}」行动(当前回合 = ${turnOf})`,
}
}
/**
* 起插件:登记 + 挂两条回调 + 自动开始长轮询。
*
* @param {object} [options]
* @param {string} [options.baseUrl] - 缺省读 `DSH_PLATFORM_IM_URL`(平台起实例时注入)。
* @param {string} [options.token] - 缺省读 `DSH_IM_INSTANCE_TOKEN`。
* @param {(e: object) => void} [options.onLog] - 具名日志出口(⛔ SDK 自己不 print)。
* @returns {Promise<{host: object, reg: object, counters: () => object}>}
*/
export async function start(options = {}) {
const onLog = typeof options.onLog === 'function' ? options.onLog : (e) => console.warn('[minimal-callbacks]', e)
const host = await createImPluginHost({
pluginId: PLUGIN_ID,
manifest,
...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
...(options.token === undefined ? {} : { token: options.token }),
onLog,
})
// ── ① 同步发言规则(跨进程)─────────────────────────────────────────────
host.onSpeakRule({
rule: 'turn-based',
roomTypes: [ROOM_TYPE],
onTimeout: 'deny', // 🔴 与 manifest 的声明**必须一致**;回合制 / 限速型写 'deny'
check: (input) => decideTurn(input),
})
// ── ② 异步事件订阅(跨进程 · 尽力送达)─────────────────────────────────
let events = 0
host.onEvent({
events: ['message.created'],
roomTypes: [ROOM_TYPE],
onEvent: async (event) => {
events += 1
// ⚠️ 事件队列**有界**(满则丢最旧并计 `bridgeDropped`)⇒ ⛔ 别把这里当可靠消息流;
// 要可靠性请用 `host.subscribe()`(游标增量,不会丢)。
//
// 本示例:把"有人行动了"广播成一帧(ephemeral:不落库 / 不进历史)。
// 真实业务里这里可以写库、调模型、发公告 —— 都是**在你自己的进程内**跑。
await host.frame({
roomId: event.roomId,
channel: 'turn.activity',
data: { at: event.at, authorId: event.data?.authorId ?? '', seq: event.data?.seq ?? 0 },
outboundId: 'minimal_out',
})
},
})
// 🔴 登记成功后,只要上面注册过回调 ⇒ `ready()` **自动开始长轮询**(幂等,别自己再启一条)。
const reg = await host.ready()
if (reg.ok !== true) onLog({ at: 'start', ok: false, reason: reg.reason, detail: reg.detail })
return {
host,
reg,
/** 计数读数(⛔ 只回计数,不含内容)—— 上生产后靠它看"回调到底跑没跑"。 */
counters: () => ({ ...host.callbacks, events }),
}
}
@@ -0,0 +1,206 @@
/**
* 极简「平台侧」模拟实现 —— 只为让本示例**不接真平台也能一键跑起来**。
*
* ## 它模拟什么
* 反向通道只用到三个端点(真实现见平台仓 `src/web/routes/im.ts` 的「纯插入」段):
* | 端点 | 方向 | 作用 |
* |---|---|---|
* | `POST /api/im/plugins/register` | 插件 → 平台 | 交声明面 |
* | `POST /api/im/plugins/bridge/pull` | 插件 → 平台(长轮询) | 取工作项;队列空 ⇒ 挂起到 `waitMs` 后回空 |
* | `POST /api/im/plugins/bridge/result` | 插件 → 平台 | 回投结果(⛔ 幂等:过期 `callId` ⇒ `unknown-call-id`) |
* 外加两个出向端点(`out/frame` / `out/message`),用来断言事件回调**真的跑到了**。
*
* ⚠️ **这不是平台的替代品**:它不校验房间成员、不落库、无鉴权语义,只做"协议形状对齐"。
* 端到端真实验收仍要连真平台(见本目录 README「接真平台」)。
*
* @module mock-platform
*/
import { createServer } from 'node:http'
/** 服务端长轮询硬上限(与平台 `ImBridgeCallbackBus.maxPollHoldMs = 20 s` 同一口径)。 */
export const MAX_POLL_HOLD_MS = 20_000
/**
* 起一个模拟平台。
*
* @param {object} [options]
* @param {number} [options.holdCapMs] - **本模拟器**单次挂起上限(缺省 800)。⚠️ 只为让自检快跑;
* 真平台是 20 s。它调小了 ⇒ 客户端会更快重拨,属预期(不会因此误判功能)。
* @param {string} [options.token] - 期望的实例凭据(缺省 `dev-token`)。
* @returns {Promise<object>} 模拟平台句柄(含排队 / 取结果 / 读帧的辅助方法)。
*/
export async function startMockPlatform(options = {}) {
const holdCapMs = Number.isFinite(options.holdCapMs) ? options.holdCapMs : 800
const token = options.token ?? 'dev-token'
/** @type {Array<object>} 待取的工作项 */
const queue = []
/** @type {Map<string, object>} 已回投的结果(按 callId) */
const results = new Map()
/** @type {Array<object>} 收到的帧 */
const frames = []
/** @type {Array<object>} 收到的落库发言 */
const outMessages = []
/** @type {Array<() => void>} 在挂起的长轮询(有货就唤醒) */
let waiters = []
let registered = null
let pullCount = 0
let lastToken = null
function wake() {
const pending = waiters
waiters = []
for (const fn of pending) fn()
}
function json(res, status, body) {
const text = JSON.stringify(body)
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) })
res.end(text)
}
async function readBody(req) {
const chunks = []
for await (const c of req) chunks.push(c)
const text = Buffer.concat(chunks).toString('utf8')
if (text === '') return {}
try {
return JSON.parse(text)
} catch {
return { __badJson: text }
}
}
const server = createServer((req, res) => {
void (async () => {
const path = (req.url ?? '').split('?')[0]
const body = req.method === 'POST' ? await readBody(req) : {}
lastToken = req.headers['x-dsh-im-instance-token'] ?? null
if (path === '/api/im/plugins/register') {
registered = { pluginId: req.headers['x-dsh-im-plugin-id'] ?? null, manifest: body }
json(res, 200, { ok: true, pluginId: registered.pluginId })
return
}
if (path === '/api/im/plugins/bridge/pull') {
pullCount += 1
const want = Number.isFinite(body?.waitMs) ? body.waitMs : 0
const deadline = Date.now() + Math.max(0, Math.min(want, holdCapMs))
while (queue.length === 0 && Date.now() < deadline) {
await new Promise((resolve) => {
const t = setTimeout(resolve, Math.max(1, deadline - Date.now()))
waiters.push(() => {
clearTimeout(t)
resolve()
})
})
}
const max = Number.isFinite(body?.max) ? body.max : 32
const items = queue.splice(0, Math.max(1, max))
json(res, 200, { ok: true, items })
return
}
if (path === '/api/im/plugins/bridge/result') {
const callId = typeof body?.callId === 'string' ? body.callId : ''
if (callId === '') {
json(res, 400, { ok: false, error: 'call-id-required' })
return
}
if (results.has(callId)) {
// 真口径:幂等,过期 / 重复回投 ⇒ 具名拒绝,⛔ 不重复 resolve。
json(res, 200, { ok: false, reason: 'unknown-call-id' })
return
}
results.set(callId, { ok: body.ok === true, reason: body.reason, detail: body.detail })
json(res, 200, { ok: true, accepted: true })
return
}
if (path === '/api/im/plugins/out/frame') {
frames.push(body)
json(res, 200, { ok: true })
return
}
if (path === '/api/im/plugins/out/message') {
outMessages.push(body)
json(res, 200, { ok: true })
return
}
if (path === '/api/im/plugins/unregister') {
registered = null
json(res, 200, { ok: true })
return
}
if (path === '/api/im/rooms') {
json(res, 200, { ok: true, rooms: [] })
return
}
json(res, 404, { ok: false, error: 'not-found', detail: path })
})().catch((err) => {
try {
json(res, 500, { ok: false, error: 'mock-failure', detail: String(err) })
} catch {
/* 已断开 */
}
})
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const port = server.address().port
return {
url: `http://127.0.0.1:${port}`,
token,
/** 平台收到的登记面(含 manifest 原文)—— 用来断言"函数没被送出去"。 */
get registered() {
return registered
},
get frames() {
return frames
},
get outMessages() {
return outMessages
},
get pullCount() {
return pullCount
},
get lastToken() {
return lastToken
},
/** 造一条待判定的发言(模拟"用户在写")。 */
queueSpeakCheck({ callId, input, timeoutMs = 50 }) {
queue.push({ kind: 'speak.check', callId, pluginId: registered?.pluginId ?? '', timeoutMs, input })
wake()
},
/** 造一条事件(模拟"有人发言了")。 */
queueEvent({ callId, event, timeoutMs = 50 }) {
queue.push({ kind: 'event.deliver', callId, pluginId: registered?.pluginId ?? '', timeoutMs, event })
wake()
},
/** 等某个 `callId` 的回投结果(超时 ⇒ 回 `null`,由调用方断言失败)。 */
async waitResult(callId, timeoutMs = 3000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (results.has(callId)) return results.get(callId)
await new Promise((r) => setTimeout(r, 10))
}
return null
},
async close() {
wake()
await new Promise((resolve) => server.close(resolve))
},
}
}
@@ -0,0 +1,15 @@
{
"name": "@dsh-local/im-plugin-minimal-callbacks",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "index.mjs",
"exports": {
".": "./index.mjs"
},
"scripts": {
"self-check": "node self-check.mjs"
},
"dependencies": {},
"license": "MIT"
}
@@ -0,0 +1,126 @@
/**
* 自检(**一键跑**):`node self-check.mjs`
*
* 它证明本示例的五件事 —— 都不接真平台,靠 `mock-platform.mjs` 跑完整协议:
* ① 登记成功,且送上平台的 manifest **不含函数**(跨进程只传数据);
* ② 声明里 `onTimeout: 'deny'` 在位(回合制的命门);
* ③ **同步发言规则**真的生效:非当前回合者 ⇒ 回 `{ok:false}`(**且原因可读**);
* ④ **异步事件订阅**真的跑到:`event.deliver` ⇒ 你的 `onEvent` 执行 ⇒ 出了一帧;
* ⑤ **未接线时行为不变**:缺 `baseUrl` / `token` ⇒ **不抛**、不启长轮询(插件其余功能不受影响)。
*
* 退出码:0 = 全绿;1 = 有红。⛔ 红就是红 —— 别把"没报错"当通过。
*/
import { decideTurn, manifest, start, ROOM_TYPE, PLUGIN_ID } from './index.mjs'
import { startMockPlatform } from './mock-platform.mjs'
let failed = 0
let passed = 0
function check(name, condition, extra = '') {
if (condition) {
passed += 1
console.log(` ✅ ${name}`)
} else {
failed += 1
console.log(` ❌ ${name}${extra === '' ? '' : ` —— ${extra}`}`)
}
}
const ROOM_ID = 'room_demo_1'
const eventOf = (authorId) => ({
name: 'message.created',
roomId: ROOM_ID,
roomType: ROOM_TYPE,
at: 1_758_000_000_000,
data: { messageId: 'm1', seq: 1, authorId, authorKind: 'user', via: 'client', visibility: 'room', payload: { kind: 'text', text: 'hi' } },
})
async function main() {
console.log('── ① 纯函数判定(回合制语义,可单测)')
check('未开局(turnOf 空)⇒ 放行', decideTurn({ config: { turnOf: '' }, authorId: 'u1' }).ok === true)
check('轮到本人 ⇒ 放行', decideTurn({ config: { turnOf: 'u2' }, authorId: 'u2' }).ok === true)
const rejected = decideTurn({ config: { turnOf: 'u1' }, authorId: 'u2' })
check('没轮到 ⇒ 拒,且原因可读', rejected.ok === false && typeof rejected.detail === 'string' && rejected.detail.length > 0, JSON.stringify(rejected))
check('bot / agent 代答不参与回合(⛔ 否则会自卡)', decideTurn({ config: { turnOf: 'u1' }, authorId: 'bot_x', authorKind: 'bot' }).ok === true)
console.log('\n── ② 声明面(不接平台也能看)')
check(`清单里 onTimeout = 'deny'`, manifest.speakRules[0].onTimeout === 'deny')
const platform = await startMockPlatform()
let host
try {
console.log('\n── ③ 接"平台",跑真实协议(登记 → 长轮询 → 回投)')
const logLines = []
const boot = await start({
baseUrl: platform.url,
token: platform.token,
onLog: (e) => logLines.push(e),
})
host = boot.host
check('登记成功(reg.ok === true)', boot.reg.ok === true, JSON.stringify(boot.reg))
check('拼上了实例凭据', platform.lastToken === platform.token)
// 送上平台的 manifest:有声明、但**没有函数**。
const sent = platform.registered?.manifest ?? {}
check('manifest 送上去了且声明在', Array.isArray(sent.speakRules) && sent.speakRules.length === 1)
check('🔴 manifest 里没有函数(check / onEvent 已被 SDK 剔除)', sent.speakRules[0].check === undefined && sent.events[0].onEvent === undefined, JSON.stringify(Object.keys(sent.speakRules[0])))
check('送上平台的 onTimeout 也是 deny', sent.speakRules[0].onTimeout === 'deny')
check('长轮询已启动(callbacks.started)', host.callbacks.started === true)
// ⑶ 同步发言规则:没轮到的发言必须被拒。
platform.queueSpeakCheck({
callId: 'c1',
input: { roomId: ROOM_ID, roomType: ROOM_TYPE, authorId: 'u2', authorKind: 'user', config: { turnOf: 'u1' }, envelope: { via: 'client' } },
})
const r1 = await platform.waitResult('c1')
check('⚠️ 非当前回合者 ⇒ 平台收到 ok:false(拒)', r1 !== null && r1.ok === false, JSON.stringify(r1))
check('拒绝原因可读(不是空 detail)', r1 !== null && typeof r1.detail === 'string' && r1.detail.includes('还没轮到'), JSON.stringify(r1))
// 轮到本人 ⇒ 必须放行(否则规则把正常发言也卡死 = 净变差)。
platform.queueSpeakCheck({
callId: 'c2',
input: { roomId: ROOM_ID, roomType: ROOM_TYPE, authorId: 'u1', authorKind: 'user', config: { turnOf: 'u1' }, envelope: { via: 'client' } },
})
const r2 = await platform.waitResult('c2')
check('✅ 轮到本人 ⇒ 放行(ok:true)', r2 !== null && r2.ok === true, JSON.stringify(r2))
// ⑷ 异步事件订阅:事件送达 ⇒ onEvent 在本进程执行 ⇒ 出一帧。
platform.queueEvent({ callId: 'e1', event: eventOf('u1') })
const r3 = await platform.waitResult('e1')
check('事件回投成功(ok:true)', r3 !== null && r3.ok === true, JSON.stringify(r3))
const deadline = Date.now() + 2000
while (platform.frames.length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10))
check('⚠️ onEvent 真的执行了(平台收到 1 帧)', platform.frames.length === 1, `frames=${platform.frames.length}`)
check('帧内容 = 事件里的作者', platform.frames[0]?.data?.authorId === 'u1', JSON.stringify(platform.frames[0]))
check('出向带了 outboundId', platform.frames[0]?.outboundId === 'minimal_out')
check('读数可观测(items / settled > 0)', host.callbacks.items >= 3 && host.callbacks.settled >= 3, JSON.stringify(host.callbacks))
check('整轮无 onLog 报错', logLines.length === 0, JSON.stringify(logLines))
await host.close()
check('close() 后长轮询已停', host.callbacks.stopped === true)
host = undefined
} finally {
await platform.close()
}
console.log('\n── ④ 未接线(缺 baseUrl / token)⇒ 行为必须不变')
const offline = await start({ baseUrl: '', token: '', onLog: () => {} })
check('不抛(start 正常返回)', offline.host !== undefined)
check('不启长轮询(callbacks.started === false)', offline.host.callbacks.started === false)
check('登记失败回具名结论(not-configured)', offline.reg.ok === false && offline.reg.reason === 'not-configured', JSON.stringify(offline.reg))
check('出向回具名结论而非抛错', (await offline.host.frame({ roomId: ROOM_ID, channel: 'x', data: {} })).reason === 'not-configured')
await offline.host.close()
console.log(`\n══════ 结果:${passed} 例通过 / ${failed} 例失败 ══════`)
console.log(`(插件身份 ${PLUGIN_ID} · 房间类型 ${ROOM_TYPE})`)
process.exit(failed === 0 ? 0 : 1)
}
main().catch((err) => {
console.error('自检自身崩了:', err)
process.exit(1)
})
@@ -0,0 +1,8 @@
// @dsh-local/im-plugin-mud — bundle patch (host row only)
//
// D 单示例插件之二(MUD)。走**实时档**(档案 108 游戏口径,jitter 预算 20 ms),
// 发言规则 = 限速。⛔ 无 client 面。
- insert:
- id: im-plugin-mud
name: '@dsh-local/im-plugin-mud'
@@ -0,0 +1 @@
{"name":"@dsh-local/im-plugin-mud","version":"0.1.0","private":true,"type":"module","main":"lib/index.js","exports":{".":"./lib/index.js"},"dsh":{"bundle":{"patch":"./cordis.patch.yml"}},"dependencies":{},"license":"MIT"}
@@ -0,0 +1,8 @@
// @dsh-local/im-plugin-office — bundle patch (host row only)
//
// D 单示例插件之一(办公群)。⛔ 无 client 面:本插件只在**服务端**把房间类型 +
// 任务卡载荷 + 面板插槽声明给 IM SDK,UI 成品在 E 单。
- insert:
- id: im-plugin-office
name: '@dsh-local/im-plugin-office'
@@ -0,0 +1,17 @@
{
"name": "@dsh-local/im-plugin-office",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
},
"dependencies": {},
"license": "MIT"
}
@@ -0,0 +1,8 @@
// @dsh-local/im-plugin-trpg — bundle patch (host row only)
//
// D 单示例插件之三(跑团)。发言规则 = **回合制**(非当前回合者发言被拒),
// 含角色卡表与 DM/玩家业务角色。⛔ 无 client 面。
- insert:
- id: im-plugin-trpg
name: '@dsh-local/im-plugin-trpg'
@@ -0,0 +1 @@
{"name":"@dsh-local/im-plugin-trpg","version":"0.1.0","private":true,"type":"module","main":"lib/index.js","exports":{".":"./lib/index.js"},"dsh":{"bundle":{"patch":"./cordis.patch.yml"}},"dependencies":{},"license":"MIT"}