dsh-ai-arcade 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-ai-arcade contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,88 @@
1
- # Temporary Holding Version
1
+ # dsh-ai-arcade
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **AI 街机厅** —— 在 [DeepSeek Harness](https://deepseek.com/harness/en/) 里玩游戏,对手是有脑子的:**模型负责「想」,本地引擎负责「动」**。
4
+
5
+ 点左下角的 🎮 打开面板,里面是一个完整的街机厅。
6
+
7
+ | 游戏 | 模型做什么 | 本地做什么 |
8
+ |---|---|---|
9
+ | 🐍 **双脑贪吃蛇** | 每 1.7 秒出一张**策略卡**(目标 / 激进程度 / 风险偏好)+ 一句挑衅 | 按 60fps 跑规则、按策略卡决定每一步、模型慢或挂了自动兜底 |
10
+ | ♟ **中国象棋** | 只**解说**(从引擎给的候选里挑一个 + 说一句心理活动) | 走法生成、合法性校验、alpha-beta 搜索 —— **全部棋力** |
11
+
12
+ ## 为什么模型不直接下棋 / 不直接控制蛇
13
+
14
+ 因为它做不到,而且实测数据很清楚:
15
+
16
+ - **象棋**:马腿、象眼、炮架、将帅照面这些规则一旦交给模型,非法棋是常态;就算合法,几百行的搜索也轻松赢它。所以模型只被允许**从本地给出的合法候选里挑一个**——非法棋因此不可能出现。
17
+ - **贪吃蛇**:单次模型往返 **0.8–2 秒**,而游戏一步只要 140 毫秒。所以模型只出**意图**,具体走法由本地引擎算。
18
+ - **还有一个坑**:宿主默认的 `reasoningEffort` 是 `high`,实测一次游戏规模的提问会**烧掉 2048 个输出 token 全在思考块里、耗时 17 秒、正文为空**。这个插件显式要 `off`,同样的提问变成 **0.78 秒、37 个 token、回答完整**。
19
+
20
+ 断网、模型超时、额度用尽 —— 两个游戏都照常运行,只是少了那张嘴。
21
+
22
+ ## 安装
23
+
24
+ ```sh
25
+ dsh plugin --profile <你的 profile> add dsh-ai-arcade
26
+ ```
27
+
28
+ 装好后点界面左下角的 **🎮**。
29
+
30
+ ## 配置
31
+
32
+ 写在 profile 补丁层,或直接在设置页改:
33
+
34
+ ```yaml
35
+ - insert:
36
+ - id: dsh-ai-arcade
37
+ name: dsh-ai-arcade
38
+ config:
39
+ enabled: true
40
+ provider: deepseek-account # 留空则自动挑第一个可用 provider
41
+ model: deepseek-flash
42
+ reasoningEffort: "off" # 游戏要快;深度复盘时可以单独覆盖
43
+ maxTokens: 2048
44
+ timeoutMs: 45000
45
+ ```
46
+
47
+ ## 架构
48
+
49
+ ```
50
+ 🎮 按钮(插件客户端 bundle:一枚按钮 + 一个 iframe)
51
+ └─ <iframe src="/api/dsh-ai-arcade/play">
52
+ └─ 游戏页面(宿主路由,每次现读磁盘,no-store)
53
+ └─ fetch /api/dsh-ai-arcade/brain
54
+ └─ ctx.llm.stream() ← 凭证留在主进程
55
+ ```
56
+
57
+ **游戏本体为什么不在插件的客户端 bundle 里**:DSH 把所有插件的浏览器半边聚合成一个响应,带一年 `immutable` 强缓存,服务端也缓存这份聚合;改客户端代码必须重启 DSH。把游戏放到宿主路由提供的页面上(`no-store`)之后,改玩法只要刷新页面 —— 这个插件在加入象棋、暂停功能的整个过程中没有重启过。
58
+
59
+ 反过来,插件那一半只当"壳",几乎不需要改。
60
+
61
+ ### 操作模式
62
+
63
+ - **双脑贪吃蛇**:单步(点一次走一格、听 AI 的理由)/实时对战(两条蛇自己打)/我来玩(方向键操控蓝蛇)
64
+ - **中国象棋**:人机对弈(你执红)/AI 对弈(看它自己下);难度滑杆 1–10,同时调搜索深度与"失误率"
65
+ - 两个游戏都有 **⏸ 暂停**(快捷键 `P`)
66
+
67
+ ## 开发
68
+
69
+ ```sh
70
+ npm run build # esbuild 打包客户端壳与游戏页面(需 Node 22+)
71
+ npm test # 38 项打包自检 + 14 项象棋引擎测试
72
+ ```
73
+
74
+ 改游戏只需重新构建页面并覆盖 `lib/play.html`,然后刷新浏览器;改服务端或壳需要重启 DSH。
75
+
76
+ ## 测试覆盖
77
+
78
+ `node --test test/xiangqi.test.mjs` —— 14 项,包括:
79
+
80
+ - **开局着法数 44**(中国象棋标准值),逐子分解断言(车 4 + 马 4 + 相 4 + 仕 2 + 帅 1 + 兵 5 + 炮 24)
81
+ - 马腿、象眼、象不过河、炮必须有炮架才能吃、兵过河才能横走
82
+ - 将帅照面视为将军、被将军时只能走应将着法、make/undo 往返一致
83
+ - 识别双车错将死、搜索能白吃车、能认出一手绝杀
84
+ - 难度滑杆:10 级 20/20 选最优,1 级会失手
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,11 @@
1
+ # dsh-ai-arcade:往插件树里插一行(宿主半边提供 LLM 大脑路由,浏览器半边挂一个游戏面板)。
2
+ # 注:name 必须等于包名;id 是本地行标识(也是设置页命名空间)。
3
+ - insert:
4
+ - id: dsh-ai-arcade
5
+ name: dsh-ai-arcade
6
+ config:
7
+ enabled: true
8
+ provider: deepseek-account
9
+ model: deepseek-flash
10
+ maxTokens: 512
11
+ timeoutMs: 45000
package/lib/brain.js ADDED
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The brain seam — the only place this plugin talks to a model.
3
+ *
4
+ * Three responsibilities, kept out of the route so all of them stay testable:
5
+ * 1. resolve a usable `{ provider, model }` (configured value first, then the
6
+ * first advertised pair — so the plugin still works on a profile that has
7
+ * never heard of `deepseek-account`);
8
+ * 2. drive one `ctx.llm.stream()` call and hand back assembled text, pushing
9
+ * each incremental piece to the caller so the browser can render the model
10
+ * thinking in real time;
11
+ * 3. keep the reasoning budget honest. The host defaults `reasoningEffort` to
12
+ * the model's declared default (high for the DeepSeek models on this box),
13
+ * and at that effort a game-sized answer can spend the entire output budget
14
+ * on the reasoning block and return an EMPTY text block — a "successful"
15
+ * call that says nothing. This plugin therefore asks for `low` by default
16
+ * and retries once without the knob if the model does not support it.
17
+ *
18
+ * Everything else model-shaped is the harness's job (credentials, adapters,
19
+ * retry, JSON mode) — the same seam `dsh-shift-router` uses for its Judge.
20
+ *
21
+ * @module dsh-ai-arcade/brain
22
+ */
23
+
24
+ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
25
+
26
+ /** The opponent's standing character. Per-game tactics come in as the prompt. */
27
+ export const BRAIN_SYSTEM = [
28
+ "你是「双脑贪吃蛇」里的 AI 玩家,控制右边那条蛇;玩家控制左边那条蛇。",
29
+ "你说话干脆、带一点自信的垃圾话,但每次不超过两句。",
30
+ "回答用中文,不要输出 JSON、不要用 Markdown 代码块。",
31
+ ].join("\n");
32
+
33
+ /** Reasoning efforts the DeepSeek adapter advertises. `""` means "do not ask". */
34
+ export const REASONING_EFFORTS = Object.freeze(["off", "low", "high", "max"]);
35
+
36
+ /**
37
+ * Split an assembled call into its spoken text and its private reasoning.
38
+ *
39
+ * Both are returned: the game panel shows the reply as the answer and the
40
+ * reasoning as a dimmer "thinking" trace, which is exactly the "watch it think"
41
+ * affordance this plugin is built around.
42
+ *
43
+ * @param assembler - the live block assembler.
44
+ * @returns the concatenated `text` and `reasoning` blocks so far.
45
+ */
46
+ function blocksOf(assembler) {
47
+ try {
48
+ let text = "";
49
+ let reasoning = "";
50
+ for (const block of assembler.blocks()) {
51
+ if (typeof block?.text !== "string") continue;
52
+ if (block.type === "text") text += block.text;
53
+ else if (block.type === "reasoning") reasoning += block.text;
54
+ }
55
+ return { text, reasoning };
56
+ } catch {
57
+ return { text: "", reasoning: "" };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * A short human-readable reason from an adapter failure.
63
+ *
64
+ * @param failure - the failure carried by the assembler's finish state.
65
+ * @returns a message suitable for the game panel.
66
+ */
67
+ function describeFailure(failure) {
68
+ if (failure === null || typeof failure !== "object") return "model call failed";
69
+ const message = failure.message ?? failure.code ?? failure.kind;
70
+ return typeof message === "string" && message !== "" ? message : "model call failed";
71
+ }
72
+
73
+ /**
74
+ * Resolve the provider/model pair this plugin should call.
75
+ *
76
+ * @param ctx - host plugin context (needs the `llm` service).
77
+ * @param settings - resolved plugin settings.
78
+ * @returns the pair, or null when the host advertises no usable model.
79
+ */
80
+ export async function resolveTarget(ctx, settings) {
81
+ const provider = settings.provider;
82
+ const model = settings.model;
83
+ if (provider !== "" && model !== "") {
84
+ try {
85
+ await ctx.llm.resolveModelInfo(provider, model);
86
+ return { provider, model, source: "config" };
87
+ } catch {
88
+ // Fall through to discovery: a profile without this provider is not an error.
89
+ }
90
+ }
91
+ try {
92
+ const providers = ctx.llm.listProviders() ?? [];
93
+ for (const entry of providers) {
94
+ const id = typeof entry === "string" ? entry : entry?.id;
95
+ if (typeof id !== "string" || id === "") continue;
96
+ const models = await ctx.llm.listModels(id);
97
+ if (!Array.isArray(models)) continue;
98
+ const first = models.find((candidate) => typeof candidate === "string" && candidate !== "");
99
+ if (first !== undefined) return { provider: id, model: first, source: "auto" };
100
+ }
101
+ } catch {
102
+ // No discovery either — report the absence instead of guessing.
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Drive one streamed brain call.
109
+ *
110
+ * @param ctx - host plugin context.
111
+ * @param options - system/prompt text, resolved target, caps, abort signal, an
112
+ * `onDelta` callback for spoken text and an `onThought` callback for reasoning.
113
+ * @returns `{ ok: true, text, reasoning, usage }` or
114
+ * `{ ok: false, text, reasoning, code, detail }`.
115
+ */
116
+ export async function streamBrain(ctx, options) {
117
+ const { system, prompt, provider, model, maxTokens, temperature, reasoningEffort, signal, onDelta, onThought } =
118
+ options;
119
+
120
+ /**
121
+ * One attempt, with or without an explicit reasoning effort.
122
+ *
123
+ * @param effort - `""` to leave the choice to the host.
124
+ * @returns the attempt outcome.
125
+ */
126
+ const attempt = async (effort) => {
127
+ const assembler = new BlockAssembler();
128
+ let sentText = "";
129
+ let sentThought = "";
130
+
131
+ /** Forward whatever incremental content arrived since the last call. */
132
+ const flush = () => {
133
+ const now = blocksOf(assembler);
134
+ if (now.text.length > sentText.length) {
135
+ const delta = now.text.slice(sentText.length);
136
+ sentText = now.text;
137
+ if (typeof onDelta === "function") onDelta(delta);
138
+ }
139
+ if (now.reasoning.length > sentThought.length) {
140
+ const delta = now.reasoning.slice(sentThought.length);
141
+ sentThought = now.reasoning;
142
+ if (typeof onThought === "function") onThought(delta);
143
+ }
144
+ };
145
+
146
+ const config = {
147
+ provider,
148
+ model,
149
+ system,
150
+ messages: [
151
+ createUserMessage({
152
+ content: [{ type: "text", text: prompt }],
153
+ source: { kind: "user" },
154
+ }),
155
+ ],
156
+ temperature,
157
+ maxTokens,
158
+ signal,
159
+ };
160
+ if (typeof effort === "string" && effort !== "") config.reasoningEffort = effort;
161
+
162
+ const stream = ctx.llm.stream(config);
163
+ for await (const chunk of stream) {
164
+ assembler.push(chunk);
165
+ flush();
166
+ }
167
+ const finish = assembler.finish;
168
+ flush();
169
+
170
+ const blocks = blocksOf(assembler);
171
+ const usage = typeof assembler.usage === "object" ? assembler.usage : undefined;
172
+ if (finish !== undefined && finish !== null && (finish.kind === "error" || finish.kind === "aborted")) {
173
+ return {
174
+ ok: false,
175
+ text: blocks.text,
176
+ reasoning: blocks.reasoning,
177
+ code: finish.kind,
178
+ detail: describeFailure(finish.failure),
179
+ ...(usage === undefined ? {} : { usage }),
180
+ };
181
+ }
182
+ return {
183
+ ok: true,
184
+ text: blocks.text,
185
+ reasoning: blocks.reasoning,
186
+ ...(usage === undefined ? {} : { usage }),
187
+ };
188
+ };
189
+
190
+ try {
191
+ return await attempt(reasoningEffort);
192
+ } catch (error) {
193
+ const detail = error instanceof Error ? error.message : String(error);
194
+ // A model that advertises no reasoning support rejects the knob outright;
195
+ // the call itself is still fine, so retry once without it.
196
+ if (typeof reasoningEffort === "string" && reasoningEffort !== "" && /reasoning/i.test(detail)) {
197
+ try {
198
+ return await attempt("");
199
+ } catch (retryError) {
200
+ return {
201
+ ok: false,
202
+ text: "",
203
+ reasoning: "",
204
+ code: "stream-failed",
205
+ detail: retryError instanceof Error ? retryError.message : String(retryError),
206
+ };
207
+ }
208
+ }
209
+ return { ok: false, text: "", reasoning: "", code: "stream-failed", detail };
210
+ }
211
+ }
package/lib/client.js ADDED
@@ -0,0 +1,113 @@
1
+ /* generated by tools/build-client.mjs — edit src/client/ instead */
2
+ window.__ModuleLoader__.load({
3
+ id: "dsh-ai-arcade",
4
+ factory: (require) => {
5
+ var module = { exports: {} };
6
+ var exports = module.exports;
7
+ var React = require("react");
8
+ var __create = Object.create;
9
+ var __defProp = Object.defineProperty;
10
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
11
+ var __getOwnPropNames = Object.getOwnPropertyNames;
12
+ var __getProtoOf = Object.getPrototypeOf;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __export = (target, all) => {
15
+ for (var name in all)
16
+ __defProp(target, name, { get: all[name], enumerable: true });
17
+ };
18
+ var __copyProps = (to, from, except, desc) => {
19
+ if (from && typeof from === "object" || typeof from === "function") {
20
+ for (let key of __getOwnPropNames(from))
21
+ if (!__hasOwnProp.call(to, key) && key !== except)
22
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
23
+ }
24
+ return to;
25
+ };
26
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
27
+ // If the importer is in node compatibility mode or this is not an ESM
28
+ // file that has been converted to a CommonJS file using a Babel-
29
+ // compatible transform (i.e. "__esModule" has not been set), then set
30
+ // "default" to the CommonJS "module.exports" for node compatibility.
31
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
32
+ mod
33
+ ));
34
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
35
+
36
+ // src/client/index.js
37
+ var index_exports = {};
38
+ __export(index_exports, {
39
+ apply: () => apply,
40
+ inject: () => inject
41
+ });
42
+ module.exports = __toCommonJS(index_exports);
43
+
44
+ // src/client/panel.js
45
+ var import_react = __toESM(require("react"), 1);
46
+ var h = import_react.default.createElement;
47
+ var PLAY_ROUTE = "/api/dsh-ai-arcade/play";
48
+ var TAG_ID = "dsh-ai-arcade/arcade.css";
49
+ var CSS = [
50
+ ".dsha_btn{box-sizing:border-box;position:fixed;left:16px;bottom:72px;z-index:2147483000;height:44px;min-width:44px;padding:0 14px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;border-radius:22px;background:#1f2430;color:#e6e8ee;box-shadow:0 6px 24px rgba(0,0,0,.35);cursor:pointer;user-select:none;font-size:16px;line-height:20px}",
51
+ ".dsha_btn:hover{background:#2a3140}",
52
+ ".dsha_panel{box-sizing:border-box;position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:2147483000;width:min(96vw,1080px);height:min(90vh,720px);display:flex;flex-direction:column;gap:8px;padding:10px;border-radius:16px;background:#1f2430;color:#e6e8ee;box-shadow:0 20px 60px rgba(0,0,0,.55);font-size:13px}",
53
+ ".dsha_bar{flex:none;display:flex;align-items:center;gap:8px}",
54
+ ".dsha_title{font-size:14px;font-weight:600;line-height:20px}",
55
+ ".dsha_tag{font-size:11px;color:#6b7280}",
56
+ ".dsha_ghost{margin-left:auto;box-sizing:border-box;height:26px;padding:0 10px;border:1px solid #3a4150;border-radius:6px;background:transparent;color:#9aa2b1;cursor:pointer;font-size:12px}",
57
+ ".dsha_ghost+.dsha_ghost{margin-left:0}",
58
+ ".dsha_ghost:hover{background:#2a3140;color:#e6e8ee}",
59
+ ".dsha_frame{flex:1;min-height:0;width:100%;border:1px solid #3a4150;border-radius:12px;background:#14171d;display:block}"
60
+ ].join("");
61
+ function ensureStyles() {
62
+ if (typeof document === "undefined") return;
63
+ if (document.querySelector(`style[data-plugin-css="${TAG_ID}"]`) !== null) return;
64
+ const style = document.createElement("style");
65
+ style.setAttribute("data-plugin-css", TAG_ID);
66
+ style.textContent = CSS;
67
+ document.head.appendChild(style);
68
+ }
69
+ function ArcadePanel() {
70
+ const [open, setOpen] = (0, import_react.useState)(false);
71
+ const [nonce, setNonce] = (0, import_react.useState)(0);
72
+ (0, import_react.useEffect)(() => {
73
+ ensureStyles();
74
+ }, []);
75
+ if (!open) {
76
+ return h("button", { className: "dsha_btn", title: "AI 街机厅", onClick: () => setOpen(true) }, "🎮");
77
+ }
78
+ return h("div", { className: "dsha_panel" }, [
79
+ h("div", { className: "dsha_bar", key: "bar" }, [
80
+ h("span", { className: "dsha_title", key: "t" }, "AI 街机厅"),
81
+ h("span", { className: "dsha_tag", key: "tag" }, "双脑贪吃蛇"),
82
+ h("button", { className: "dsha_ghost", key: "reload", onClick: () => setNonce((value) => value + 1) }, "重新载入"),
83
+ h("button", { className: "dsha_ghost", key: "close", onClick: () => setOpen(false) }, "收起 ✕")
84
+ ]),
85
+ h("iframe", {
86
+ className: "dsha_frame",
87
+ key: nonce,
88
+ src: `${PLAY_ROUTE}?v=${nonce}`,
89
+ title: "AI 街机厅"
90
+ })
91
+ ]);
92
+ }
93
+
94
+ // src/client/index.js
95
+ var inject = ["slots"];
96
+ function apply(ctx) {
97
+ ctx.slots.inject(
98
+ "shell.overlay",
99
+ () => ctx.slots.register(
100
+ {
101
+ name: "shell.overlay",
102
+ id: "dsh-ai-arcade:overlay",
103
+ order: 110,
104
+ inject: () => ({})
105
+ },
106
+ ArcadePanel
107
+ )
108
+ );
109
+ }
110
+
111
+ return module.exports;
112
+ }
113
+ });
package/lib/config.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Plugin configuration: the schema the host validates against, the code-side
3
+ * defaults (so a direct `apply()` call works without the loader), and the
4
+ * normalisation of every knob.
5
+ *
6
+ * The Cordis loader applies `Config` defaults, but `apply` is also reachable
7
+ * through direct construction (tests, tooling), so this module resolves its own
8
+ * defaults instead of trusting the caller.
9
+ *
10
+ * @module dsh-ai-arcade/config
11
+ */
12
+
13
+ import z from "@deepseek-ai/schemastery";
14
+
15
+ /** Reasoning efforts the DeepSeek adapter advertises. */
16
+ const EFFORTS = ["off", "low", "high", "max"];
17
+
18
+ /** Code-side defaults, mirrored by the `Config` schema below. */
19
+ export const DEFAULTS = Object.freeze({
20
+ enabled: true,
21
+ provider: "deepseek-account",
22
+ model: "deepseek-flash",
23
+ // Measured on this box: at the host default (high) a game-sized prompt spends
24
+ // the whole output budget on the reasoning block and returns an EMPTY text
25
+ // block after ~9s; at `low` the same prompt answers in ~1-2s with text.
26
+ reasoningEffort: "low",
27
+ maxTokens: 2048,
28
+ timeoutMs: 45_000,
29
+ temperature: 0.7,
30
+ maxBodyBytes: 65_536,
31
+ // Not a feature: changing this string is what makes the loader re-read this
32
+ // plugin row, which is how a host-half edit (and the client bundle served
33
+ // from it) is picked up without restarting DSH. `tools/dev-sync.sh` bumps it.
34
+ devStamp: "",
35
+ });
36
+
37
+ export const Config = z.object({
38
+ /** Master switch; `false` registers no HTTP route and spends no credits. */
39
+ enabled: z.boolean().default(DEFAULTS.enabled),
40
+ /** Provider id for the game brain (empty = auto-pick the first usable one). */
41
+ provider: z.string().default(DEFAULTS.provider),
42
+ /** Model id for the game brain. */
43
+ model: z.string().default(DEFAULTS.model),
44
+ /** Reasoning budget per brain call: `off`, `low`, `high` or `max`. */
45
+ reasoningEffort: z.string().default(DEFAULTS.reasoningEffort),
46
+ /** Output cap per brain call. */
47
+ maxTokens: z.natural().default(DEFAULTS.maxTokens),
48
+ /** Hard ceiling on one brain call; the UI shows a timeout instead of hanging. */
49
+ timeoutMs: z.natural().default(DEFAULTS.timeoutMs),
50
+ /** Sampling temperature — higher makes the opponent more erratic/characterful. */
51
+ temperature: z.number().default(DEFAULTS.temperature),
52
+ /** Request body cap for the brain route. */
53
+ maxBodyBytes: z.natural().default(DEFAULTS.maxBodyBytes),
54
+ /** Development reload trigger; see {@link DEFAULTS.devStamp}. */
55
+ devStamp: z.string().default(DEFAULTS.devStamp),
56
+ });
57
+
58
+ /**
59
+ * One of `allowed`, or `fallback` when the value is absent/unknown.
60
+ *
61
+ * @param value - raw configured value.
62
+ * @param allowed - accepted strings.
63
+ * @param fallback - value used otherwise.
64
+ * @returns the resolved string.
65
+ */
66
+ function pick(value, allowed, fallback) {
67
+ return typeof value === "string" && allowed.includes(value) ? value : fallback;
68
+ }
69
+
70
+ /**
71
+ * A finite number clamped into `[min, max]`, or `fallback`.
72
+ *
73
+ * @param value - raw configured value.
74
+ * @param min - lower bound.
75
+ * @param max - upper bound.
76
+ * @param fallback - value used otherwise.
77
+ * @returns the resolved number.
78
+ */
79
+ function clampNumber(value, min, max, fallback) {
80
+ const number = Number(value);
81
+ if (!Number.isFinite(number)) return fallback;
82
+ return Math.min(max, Math.max(min, number));
83
+ }
84
+
85
+ /**
86
+ * A positive integer, or `fallback`.
87
+ *
88
+ * @param value - raw configured value.
89
+ * @param fallback - value used otherwise.
90
+ * @returns the resolved integer.
91
+ */
92
+ function positiveInteger(value, fallback) {
93
+ const number = Number(value);
94
+ return Number.isSafeInteger(number) && number > 0 ? number : fallback;
95
+ }
96
+
97
+ /**
98
+ * Resolve user configuration into the plain settings object the plugin uses.
99
+ *
100
+ * @param raw - the loader-supplied config (possibly undefined).
101
+ * @returns resolved settings.
102
+ */
103
+ export function resolveConfig(raw) {
104
+ const config = raw === null || typeof raw !== "object" ? {} : raw;
105
+ return {
106
+ enabled: config.enabled !== false,
107
+ provider: typeof config.provider === "string" ? config.provider.trim() : DEFAULTS.provider,
108
+ model: typeof config.model === "string" ? config.model.trim() : DEFAULTS.model,
109
+ reasoningEffort: pick(config.reasoningEffort, EFFORTS, DEFAULTS.reasoningEffort),
110
+ maxTokens: positiveInteger(config.maxTokens, DEFAULTS.maxTokens),
111
+ timeoutMs: positiveInteger(config.timeoutMs, DEFAULTS.timeoutMs),
112
+ temperature: clampNumber(config.temperature, 0, 2, DEFAULTS.temperature),
113
+ maxBodyBytes: positiveInteger(config.maxBodyBytes, DEFAULTS.maxBodyBytes),
114
+ devStamp: typeof config.devStamp === "string" ? config.devStamp : DEFAULTS.devStamp,
115
+ };
116
+ }