@bgnut/cli 1.0.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 GomuLabs
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 ADDED
@@ -0,0 +1,67 @@
1
+ # @bgnut/cli
2
+
3
+ 桌游匠 BGNut 开发者工具 —— 从零建游戏、本地双人模拟、一键发布到 [BGNut 平台](https://bgnut.gomulabs.com)。零运行时依赖,Node ≥ 20。
4
+
5
+ ```bash
6
+ npx @bgnut/cli <command>
7
+ # 或全局安装后直接用 bgnut
8
+ npm install -g @bgnut/cli
9
+ ```
10
+
11
+ ## 命令
12
+
13
+ ### `bgnut init [dir]`
14
+
15
+ - **空目录**:生成完整脚手架(`index.html` + `game.js` 示例游戏「抢五」+ `bgnut.json`),示例演示了指令同步三步契约,改造它即可。
16
+ - **已有项目**:只生成 `bgnut.json` 接入清单,并打印三步接入说明,不动你的代码。
17
+
18
+ `bgnut.json`(房间配置的事实来源):
19
+
20
+ ```json
21
+ {
22
+ "name": "my-game",
23
+ "version": "0.1.0",
24
+ "gameId": null,
25
+ "multiplayer": true,
26
+ "maxPlayers": 2,
27
+ "config": {}
28
+ }
29
+ ```
30
+
31
+ `gameId` 是你在开发者中心创建桌游后管理页的数字 ID;`multiplayer: false` 表示单机游戏(平台不出联机入口,直接试玩)。
32
+
33
+ ### `bgnut dev [--port=4173]`
34
+
35
+ 本地开发服务器:
36
+
37
+ - `http://localhost:4173/` —— 单机 mock 模式直接玩;
38
+ - `http://localhost:4173/__harness` —— **双窗位模拟对战**:页内 mock 中继,与线上同一套桥协议(同配置 / 同种子 / 全序指令回显),不用连平台就能验证联机逻辑。
39
+
40
+ 游戏内引用的托管 SDK 地址会被自动重写为本地副本,离线可用。
41
+
42
+ ### `bgnut login [--server=...]`
43
+
44
+ 粘贴你在开发者中心「发布令牌」页创建的 `bgp_` 令牌,校验后写入 `~/.bgnutrc`。
45
+
46
+ ### `bgnut doctor`
47
+
48
+ 发布前本地体检:清单字段、入口 `index.html`、包体等。
49
+
50
+ ### `bgnut publish`
51
+
52
+ 按 `bgnut.json` 的 `gameId` + `version` 打 ZIP 并提交平台审核。审核通过即上架、即可联机。
53
+
54
+ ## 典型流程
55
+
56
+ ```bash
57
+ bgnut init my-game && cd my-game
58
+ bgnut dev # 本地双窗位调联机
59
+ bgnut login # 一次性
60
+ bgnut publish # 提审
61
+ ```
62
+
63
+ 游戏运行时 API 见 [`@bgnut/sdk`](https://www.npmjs.com/package/@bgnut/sdk)。
64
+
65
+ ## License
66
+
67
+ MIT © GomuLabs
package/bin/bgnut.mjs ADDED
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ // bgnut —— 桌游匠开发者 CLI(@bgnut/cli)
3
+ // 命令:init [dir] / login [token] / doctor / dev [--port] / publish
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
5
+ import { createServer } from 'node:http';
6
+ import { homedir } from 'node:os';
7
+ import { basename, extname, join, relative, resolve } from 'node:path';
8
+ import { createInterface } from 'node:readline/promises';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { buildZip } from '../lib/zip.mjs';
11
+
12
+ const PKG_ROOT = resolve(fileURLToPath(import.meta.url), '../..');
13
+ const RC_PATH = join(homedir(), '.bgnutrc');
14
+ const DEFAULT_SERVER = 'https://bgnut.gomulabs.com';
15
+ const SDK_HOSTED = 'https://bgnut-play.gomulabs.com/sdk/v1.js';
16
+ const MAX_ZIP = 20 * 1024 * 1024;
17
+ const IGNORE = new Set(['node_modules', '.git', 'dist-zip', '.DS_Store']);
18
+
19
+ const [, , cmd, ...args] = process.argv;
20
+ const log = (s) => console.log(s);
21
+ const fail = (s) => {
22
+ console.error(`✗ ${s}`);
23
+ process.exit(1);
24
+ };
25
+
26
+ const readRc = () => {
27
+ try {
28
+ return JSON.parse(readFileSync(RC_PATH, 'utf8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+ const readManifest = (dir = '.') => {
34
+ const p = join(dir, 'bgnut.json');
35
+ if (!existsSync(p)) return null;
36
+ try {
37
+ return JSON.parse(readFileSync(p, 'utf8'));
38
+ } catch {
39
+ fail('bgnut.json 不是合法 JSON');
40
+ }
41
+ };
42
+
43
+ function collectFiles(dir, base = dir, out = []) {
44
+ for (const name of readdirSync(dir)) {
45
+ if (IGNORE.has(name)) continue;
46
+ const full = join(dir, name);
47
+ const st = statSync(full);
48
+ if (st.isDirectory()) collectFiles(full, base, out);
49
+ else out.push({ rel: relative(base, full).replaceAll('\\', '/'), full, size: st.size });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ // ---------- init ----------
55
+ async function cmdInit() {
56
+ const target = resolve(args[0] ?? '.');
57
+ mkdirSync(target, { recursive: true });
58
+ const entries = readdirSync(target).filter((n) => !IGNORE.has(n));
59
+ const attach = entries.length > 0;
60
+ const name = basename(target);
61
+
62
+ if (!attach) {
63
+ // 空目录:完整脚手架
64
+ for (const f of ['index.html', 'game.js', 'README.md']) {
65
+ writeFileSync(join(target, f), readFileSync(join(PKG_ROOT, 'template', f), 'utf8').replaceAll('%%NAME%%', name));
66
+ }
67
+ log(`✓ 脚手架已生成:${target}`);
68
+ } else {
69
+ log(`目录非空,进入接入模式(不改动你的代码)`);
70
+ if (!existsSync(join(target, 'index.html'))) {
71
+ log(`⚠ 未找到 index.html —— 平台要求包根目录必须有 index.html 入口`);
72
+ }
73
+ }
74
+
75
+ const mfPath = join(target, 'bgnut.json');
76
+ if (!existsSync(mfPath)) {
77
+ writeFileSync(
78
+ mfPath,
79
+ JSON.stringify({ name, version: '0.1.0', gameId: null, multiplayer: true, maxPlayers: 2, config: {} }, null, 2),
80
+ );
81
+ log(`✓ bgnut.json 已生成(发布前在开发者中心创建桌游,把 gameId 填进来)`);
82
+ }
83
+
84
+ if (attach) {
85
+ log(`\n接入三步合约(详见 README / 平台文档):`);
86
+ log(` 1. index.html 引入 SDK:<script src="${SDK_HOSTED}"></script>`);
87
+ log(` 2. 等 await BGNut.join() 拿到 ctx 再初始化,用 ctx.random() 取代 Math.random()`);
88
+ log(` 3. 本地操作改为 ctx.send(指令),在 ctx.onCommand 里统一应用(含自己的回显)`);
89
+ }
90
+ log(`\n下一步:bgnut dev 本地模拟对战;bgnut doctor 体检;bgnut publish 发布提审`);
91
+ }
92
+
93
+ // ---------- login ----------
94
+ async function cmdLogin() {
95
+ let token = args.find((a) => !a.startsWith('--'));
96
+ const server = (args.find((a) => a.startsWith('--server='))?.slice(9) ?? readRc()?.server ?? DEFAULT_SERVER).replace(/\/+$/, '');
97
+ if (!token) {
98
+ log(`在开发者中心「发布令牌」页创建令牌(bgp_ 开头),粘贴到这里:`);
99
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
100
+ token = (await rl.question('令牌: ')).trim();
101
+ rl.close();
102
+ }
103
+ if (!token?.startsWith('bgp_')) fail('令牌应以 bgp_ 开头');
104
+ const res = await fetch(`${server}/api/v1/creator/games`, { headers: { authorization: `Bearer ${token}` } });
105
+ if (!res.ok) fail(`令牌校验失败(${res.status}):${(await res.json().catch(() => ({})))?.message ?? '请确认令牌有效且账号具有创作者身份'}`);
106
+ writeFileSync(RC_PATH, JSON.stringify({ server, token }, null, 2));
107
+ log(`✓ 已登录 ${server},凭据存于 ~/.bgnutrc`);
108
+ }
109
+
110
+ // ---------- doctor ----------
111
+ function cmdDoctor() {
112
+ const dir = resolve('.');
113
+ const mf = readManifest(dir);
114
+ const problems = [];
115
+ const warns = [];
116
+ if (!mf) problems.push('缺少 bgnut.json(运行 bgnut init 生成)');
117
+ if (!existsSync(join(dir, 'index.html'))) problems.push('缺少 index.html(包根目录入口)');
118
+ if (mf && mf.version && !/^\d+\.\d+\.\d+$/.test(mf.version)) problems.push('bgnut.json.version 须为 x.y.z');
119
+ if (mf && mf.multiplayer !== false && existsSync(join(dir, 'index.html'))) {
120
+ const html = readFileSync(join(dir, 'index.html'), 'utf8');
121
+ if (!html.includes('/sdk/v1.js') && !html.includes('BGNut')) warns.push('联机包未检测到 SDK 引用(三步合约第 1 步)');
122
+ }
123
+ const files = collectFiles(dir);
124
+ const total = files.reduce((s, f) => s + f.size, 0);
125
+ if (total > MAX_ZIP) problems.push(`内容 ${(total / 1024 / 1024).toFixed(1)}MB 超过 20MB 上限`);
126
+ for (const f of files) if (f.rel.includes('..') || f.rel.startsWith('/')) problems.push(`非法路径:${f.rel}`);
127
+
128
+ log(`文件 ${files.length} 个,共 ${(total / 1024).toFixed(0)} KB`);
129
+ warns.forEach((w) => log(`⚠ ${w}`));
130
+ if (problems.length) {
131
+ problems.forEach((p) => console.error(`✗ ${p}`));
132
+ process.exit(1);
133
+ }
134
+ log('✓ 体检通过,可以 publish');
135
+ }
136
+
137
+ // ---------- dev ----------
138
+ function cmdDev() {
139
+ const dir = resolve('.');
140
+ const port = Number(args.find((a) => a.startsWith('--port='))?.slice(7) ?? 4173);
141
+ const sdkSource = readFileSync(join(PKG_ROOT, 'template', 'sdk-v1.js'), 'utf8');
142
+ const harness = readFileSync(join(PKG_ROOT, 'template', 'harness.html'), 'utf8');
143
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.wasm': 'application/wasm', '.mp3': 'audio/mpeg', '.wav': 'audio/wav' };
144
+
145
+ createServer((req, res) => {
146
+ const url = new URL(req.url, 'http://x');
147
+ let p = decodeURIComponent(url.pathname);
148
+ if (p === '/sdk/v1.js') {
149
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
150
+ return res.end(sdkSource);
151
+ }
152
+ if (p === '/__harness') {
153
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
154
+ return res.end(harness);
155
+ }
156
+ if (p === '/') p = '/index.html';
157
+ const full = join(dir, p.replaceAll('..', ''));
158
+ if (!existsSync(full) || statSync(full).isDirectory()) {
159
+ res.writeHead(404);
160
+ return res.end('not found');
161
+ }
162
+ let body = readFileSync(full);
163
+ const type = MIME[extname(full).toLowerCase()] ?? 'application/octet-stream';
164
+ // 托管 SDK 地址重写到本地副本:游戏包保持生产 URL,本地开发离线可用
165
+ if (type.startsWith('text/html')) body = Buffer.from(body.toString('utf8').replaceAll(SDK_HOSTED, '/sdk/v1.js'));
166
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
167
+ res.end(body);
168
+ }).listen(port, () => {
169
+ log(`✓ 本地开发服务已启动`);
170
+ log(` 单机模式: http://localhost:${port}/ (SDK 自动进入 mock)`);
171
+ log(` 双人模拟: http://localhost:${port}/__harness (页内 mock 中继,双窗位对战)`);
172
+ });
173
+ }
174
+
175
+ // ---------- publish ----------
176
+ async function cmdPublish() {
177
+ const rc = readRc();
178
+ if (!rc?.token) fail('未登录:先运行 bgnut login');
179
+ const dir = resolve('.');
180
+ const mf = readManifest(dir);
181
+ if (!mf) fail('缺少 bgnut.json');
182
+ if (!mf.gameId) fail('bgnut.json.gameId 为空:在开发者中心创建桌游后把 ID 填入');
183
+ if (!/^\d+\.\d+\.\d+$/.test(String(mf.version))) fail('bgnut.json.version 须为 x.y.z');
184
+ if (!existsSync(join(dir, 'index.html'))) fail('缺少 index.html');
185
+
186
+ const files = collectFiles(dir).map((f) => ({ name: f.rel, data: readFileSync(f.full) }));
187
+ const zip = buildZip(files);
188
+ if (zip.length > MAX_ZIP) fail(`包体 ${(zip.length / 1024 / 1024).toFixed(1)}MB 超过 20MB`);
189
+ log(`打包 ${files.length} 个文件,${(zip.length / 1024).toFixed(0)} KB,提交 v${mf.version} → ${rc.server}`);
190
+
191
+ const form = new FormData();
192
+ form.set('package', new Blob([zip], { type: 'application/zip' }), 'game.zip');
193
+ form.set('version', String(mf.version));
194
+ const res = await fetch(`${rc.server}/api/v1/creator/games/${mf.gameId}/package`, {
195
+ method: 'POST',
196
+ headers: { authorization: `Bearer ${rc.token}` },
197
+ body: form,
198
+ });
199
+ const body = await res.json().catch(() => ({}));
200
+ if (!res.ok) fail(`提交失败(${res.status}):${body?.message ?? '未知错误'}`);
201
+ log(`✓ 已提交审核(状态:${body.status ?? 'in_review'})。通过后自动发布上线。`);
202
+ }
203
+
204
+ // ---------- 路由 ----------
205
+ const HELP = `bgnut —— 桌游匠开发者 CLI
206
+
207
+ bgnut init [dir] 新建脚手架(空目录)/ 接入存量项目(非空目录)
208
+ bgnut login [token] 用开发者中心的发布令牌登录(--server=... 可选)
209
+ bgnut doctor 发布前本地体检
210
+ bgnut dev [--port=4173] 本地开发:单机 mock + 双人模拟 harness
211
+ bgnut publish 打包 ZIP 并提交审核
212
+ `;
213
+
214
+ switch (cmd) {
215
+ case 'init': await cmdInit(); break;
216
+ case 'login': await cmdLogin(); break;
217
+ case 'doctor': cmdDoctor(); break;
218
+ case 'dev': cmdDev(); break;
219
+ case 'publish': await cmdPublish(); break;
220
+ default: log(HELP);
221
+ }
package/lib/zip.mjs ADDED
@@ -0,0 +1,60 @@
1
+ // 零依赖 ZIP 写入器(stored 无压缩 + 标准 CRC32),供 bgnut publish 打包。
2
+ const CRC_TABLE = (() => {
3
+ const t = new Uint32Array(256);
4
+ for (let n = 0; n < 256; n++) {
5
+ let c = n;
6
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
7
+ t[n] = c >>> 0;
8
+ }
9
+ return t;
10
+ })();
11
+
12
+ export function crc32(buf) {
13
+ let c = 0xffffffff;
14
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
15
+ return (c ^ 0xffffffff) >>> 0;
16
+ }
17
+
18
+ /** entries: [{name, data:Buffer}] → 标准 ZIP Buffer */
19
+ export function buildZip(entries) {
20
+ const localParts = [];
21
+ const cdParts = [];
22
+ let offset = 0;
23
+ for (const { name, data } of entries) {
24
+ const nameBuf = Buffer.from(name.replaceAll('\\', '/'), 'utf8');
25
+ const crc = crc32(data);
26
+ const local = Buffer.alloc(30);
27
+ local.writeUInt32LE(0x04034b50, 0);
28
+ local.writeUInt16LE(20, 4); // version needed
29
+ local.writeUInt16LE(0x0800, 6); // UTF-8 flag
30
+ local.writeUInt16LE(0, 8); // stored
31
+ local.writeUInt32LE(crc, 14);
32
+ local.writeUInt32LE(data.length, 18);
33
+ local.writeUInt32LE(data.length, 22);
34
+ local.writeUInt16LE(nameBuf.length, 26);
35
+ localParts.push(local, nameBuf, data);
36
+
37
+ const cd = Buffer.alloc(46);
38
+ cd.writeUInt32LE(0x02014b50, 0);
39
+ cd.writeUInt16LE(20, 4);
40
+ cd.writeUInt16LE(20, 6);
41
+ cd.writeUInt16LE(0x0800, 8);
42
+ cd.writeUInt16LE(0, 10);
43
+ cd.writeUInt32LE(crc, 16);
44
+ cd.writeUInt32LE(data.length, 20);
45
+ cd.writeUInt32LE(data.length, 24);
46
+ cd.writeUInt16LE(nameBuf.length, 28);
47
+ cd.writeUInt32LE(offset, 42);
48
+ cdParts.push(cd, nameBuf);
49
+
50
+ offset += 30 + nameBuf.length + data.length;
51
+ }
52
+ const cd = Buffer.concat(cdParts);
53
+ const eocd = Buffer.alloc(22);
54
+ eocd.writeUInt32LE(0x06054b50, 0);
55
+ eocd.writeUInt16LE(entries.length, 8);
56
+ eocd.writeUInt16LE(entries.length, 10);
57
+ eocd.writeUInt32LE(cd.length, 12);
58
+ eocd.writeUInt32LE(offset, 16);
59
+ return Buffer.concat([...localParts, cd, eocd]);
60
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@bgnut/cli",
3
+ "version": "1.0.0",
4
+ "description": "桌游匠 BGNut 开发者工具:init 脚手架/接入、login(发布令牌)、doctor 校验、dev 本地双人模拟、publish 发布提审",
5
+ "type": "module",
6
+ "bin": {
7
+ "bgnut": "bin/bgnut.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "template",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "keywords": [
19
+ "bgnut",
20
+ "boardgame",
21
+ "cli",
22
+ "桌游"
23
+ ],
24
+ "homepage": "https://bgnut.gomulabs.com",
25
+ "author": "GomuLabs",
26
+ "license": "MIT",
27
+ "private": false,
28
+ "publishConfig": {
29
+ "access": "public"
30
+ }
31
+ }
@@ -0,0 +1,22 @@
1
+ # %%NAME%%
2
+
3
+ BGNut 桌游项目。三步合约:
4
+
5
+ 1. `index.html` 引入平台 SDK(`https://bgnut-play.gomulabs.com/sdk/v1.js`);
6
+ 2. `await BGNut.join()` 拿到 `ctx` 后再初始化,随机一律用 `ctx.random()`(同种子所有客户端同序列);
7
+ 3. 玩家操作只 `ctx.send(指令)`,状态只在 `ctx.onCommand` 里修改(含自己的回显)——所有客户端沿同一指令流确定性演进。
8
+
9
+ ## 开发
10
+
11
+ ```bash
12
+ bgnut dev # 单机 mock: / 双人模拟: /__harness
13
+ bgnut doctor # 发布前体检
14
+ ```
15
+
16
+ ## 发布
17
+
18
+ 1. 在开发者中心创建桌游,把 ID 填入 `bgnut.json` 的 `gameId`;
19
+ 2. `bgnut login`(令牌在开发者中心「发布令牌」页生成);
20
+ 3. `bgnut publish` 提交审核,通过后自动上线。
21
+
22
+ `bgnut.json` 说明:`multiplayer: false` 可发布纯单机包;`maxPlayers` 联机人数(2–8);`config` 会原样下发给所有客户端。
@@ -0,0 +1,46 @@
1
+ // %%NAME%% —— BGNut 三步合约示例:轮流 +1,先到 5 获胜
2
+ // 合约:① 引入 SDK ② await BGNut.join() 后再初始化(随机用 ctx.random)
3
+ // ③ 操作只 send,状态只在 onCommand 里改(含自己的回显,所有客户端同路径演进)
4
+ (async () => {
5
+ const ctx = await BGNut.join(); // 无宿主时自动单机 mock
6
+
7
+ // —— 状态(每个客户端各自持有,靠同一指令流保持一致)——
8
+ const score = Array.from({ length: Math.max(ctx.maxPlayers, 1) }, () => 0);
9
+ let turn = Math.floor(ctx.random() * score.length); // 同种子 → 所有客户端算出同一先手
10
+ let over = false;
11
+
12
+ const $ = (id) => document.getElementById(id);
13
+ function render() {
14
+ $('board').textContent = score.join(' : ');
15
+ $('players').textContent = ctx.members.map((m) => `${m.seat === ctx.seat ? '★' : ''}${m.name}${m.online ? '' : '(掉线)'}`).join(' vs ');
16
+ $('hint').textContent = over ? '对局结束' : turn === ctx.seat ? '轮到你了' : `等待${ctx.members[turn]?.name ?? '对方'}`;
17
+ $('act').disabled = over || turn !== ctx.seat;
18
+ }
19
+
20
+ // —— 指令应用(唯一改状态的地方)——
21
+ function apply(cmd) {
22
+ if (over || cmd.data !== 'add') return;
23
+ score[cmd.seat] += 1;
24
+ if (score[cmd.seat] >= 5) {
25
+ over = true;
26
+ if (cmd.seat === ctx.seat) ctx.finish(cmd.seat); // 由获胜方客户端报告
27
+ } else {
28
+ turn = (turn + 1) % score.length;
29
+ }
30
+ render();
31
+ }
32
+
33
+ ctx.replay.forEach(apply); // 重连/中途加入:先重放历史
34
+ ctx.onCommand(apply);
35
+ ctx.onMembers(render);
36
+ ctx.onPhase(({ phase, winner }) => {
37
+ if (phase === 'ended') {
38
+ over = true;
39
+ $('hint').textContent = winner === ctx.seat ? '你赢了!' : winner === null ? '对局结束' : `${ctx.members[winner]?.name ?? '对方'}获胜`;
40
+ render();
41
+ }
42
+ });
43
+
44
+ $('act').onclick = () => ctx.send('add'); // 不直接改状态,等回显
45
+ render();
46
+ })();
@@ -0,0 +1,77 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>BGNut 双人模拟 · bgnut dev</title>
6
+ <style>
7
+ body { margin: 0; font-family: system-ui, sans-serif; background: #EEEAE1; height: 100vh; display: flex; flex-direction: column; }
8
+ header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #25634B; color: #fff; font-size: 14px; }
9
+ header button { font: inherit; padding: 6px 14px; border: 0; border-radius: 6px; cursor: pointer; }
10
+ main { flex: 1; display: flex; gap: 2px; min-height: 0; }
11
+ .pane { flex: 1; display: flex; flex-direction: column; min-width: 0; }
12
+ .pane .tag { padding: 6px 12px; font-size: 12px; font-weight: 600; color: #626C64; background: #F7F5EF; }
13
+ iframe { flex: 1; border: 0; width: 100%; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <header>
18
+ <strong>BGNut 本地双人模拟</strong>
19
+ <span>页内 mock 中继:同一份配置 / 同一种子 / 全序指令回显</span>
20
+ <button onclick="restart()">重开一局</button>
21
+ </header>
22
+ <main>
23
+ <div class="pane"><div class="tag">座位 0 · 玩家1</div><iframe id="f0" src="/"></iframe></div>
24
+ <div class="pane"><div class="tag">座位 1 · 玩家2</div><iframe id="f1" src="/"></iframe></div>
25
+ </main>
26
+
27
+ <script>
28
+ // 页内 mock 中继:实现与主站对局页相同的 postMessage 桥协议(ADR-0007)
29
+ const frames = [document.getElementById('f0'), document.getElementById('f1')];
30
+ const members = [
31
+ { seat: 0, name: '玩家1', handle: null, avatarUrl: null, online: true },
32
+ { seat: 1, name: '玩家2', handle: null, avatarUrl: null, online: true },
33
+ ];
34
+ let ready, seed, seq, log, ended;
35
+
36
+ function reset() {
37
+ ready = [false, false];
38
+ seed = (Date.now() % 2147483647) | 0;
39
+ seq = 0;
40
+ log = [];
41
+ ended = false;
42
+ }
43
+ reset();
44
+
45
+ function post(i, msg) { frames[i].contentWindow.postMessage(msg, '*'); }
46
+ function initSeat(i) {
47
+ post(i, { bgnut: 'init', seat: i, members, config: {}, maxPlayers: 2, seed, replay: log.slice() });
48
+ }
49
+
50
+ window.addEventListener('message', (ev) => {
51
+ const m = ev.data;
52
+ if (!m || typeof m !== 'object' || !m.bgnut) return;
53
+ const i = frames.findIndex((f) => f.contentWindow === ev.source);
54
+ if (i < 0) return;
55
+ if (m.bgnut === 'ready') {
56
+ ready[i] = true;
57
+ if (ready[0] && ready[1]) { initSeat(0); initSeat(1); }
58
+ } else if (m.bgnut === 'command' && !ended) {
59
+ const cmd = { seq: ++seq, seat: i, data: m.data };
60
+ log.push(cmd);
61
+ post(0, { bgnut: 'command', ...cmd });
62
+ post(1, { bgnut: 'command', ...cmd });
63
+ } else if (m.bgnut === 'finish' && !ended) {
64
+ ended = true;
65
+ const winner = typeof m.winner === 'number' ? m.winner : null;
66
+ post(0, { bgnut: 'phase', phase: 'ended', winner });
67
+ post(1, { bgnut: 'phase', phase: 'ended', winner });
68
+ }
69
+ });
70
+
71
+ function restart() {
72
+ reset();
73
+ frames.forEach((f) => { f.src = f.src; });
74
+ }
75
+ </script>
76
+ </body>
77
+ </html>
@@ -0,0 +1,27 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>%%NAME%%</title>
7
+ <style>
8
+ body { margin: 0; font-family: system-ui, sans-serif; background: #F7F5EF; color: #202A25;
9
+ display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 32px 16px; }
10
+ button { font: inherit; padding: 10px 24px; border: 0; border-radius: 8px; background: #25634B; color: #fff; cursor: pointer; }
11
+ button:disabled { opacity: .5; cursor: not-allowed; }
12
+ #players { color: #626C64; font-size: 14px; }
13
+ #board { font-size: 40px; font-weight: 700; font-variant-numeric: tabular-nums; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <h1>%%NAME%%</h1>
18
+ <div id="players">连接中…</div>
19
+ <div id="board">0 : 0</div>
20
+ <div id="hint"></div>
21
+ <button id="act">+1</button>
22
+
23
+ <!-- 运行时以平台托管 SDK 为准;bgnut dev 会自动重写为本地副本 -->
24
+ <script src="https://bgnut-play.gomulabs.com/sdk/v1.js"></script>
25
+ <script src="./game.js"></script>
26
+ </body>
27
+ </html>
@@ -0,0 +1,153 @@
1
+ /*!
2
+ * BGNut SDK v1 —— 桌游匠联机运行时桥(ADR-0007 指令中继模型)
3
+ * 唯一事实来源:packages/sdk/sdk.js
4
+ * 托管副本:bgnut-site/public/sdk/v1.js(node scripts/sync-sdk.mjs 同步,禁止直接改副本)
5
+ *
6
+ * 游戏三步合约:
7
+ * 1. 引入本 SDK;
8
+ * 2. const ctx = await BGNut.join() 后再初始化(用 ctx.random() 取代 Math.random);
9
+ * 3. 本地操作一律 ctx.send(指令),在 ctx.onCommand 里统一应用(含自己的回显)。
10
+ *
11
+ * 无宿主(直接打开页面 / 审核初玩)时自动进入单机 mock 模式。
12
+ */
13
+ (function (root, factory) {
14
+ if (typeof module === 'object' && module.exports) module.exports = factory();
15
+ else root.BGNut = factory();
16
+ })(typeof self !== 'undefined' ? self : this, function () {
17
+ 'use strict';
18
+
19
+ // 确定性 PRNG(mulberry32)——同种子必同序列
20
+ function mulberry32(seed) {
21
+ var a = seed >>> 0;
22
+ return function () {
23
+ a |= 0;
24
+ a = (a + 0x6d2b79f5) | 0;
25
+ var t = Math.imul(a ^ (a >>> 15), 1 | a);
26
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
27
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
28
+ };
29
+ }
30
+
31
+ function makeEmitter() {
32
+ var handlers = [];
33
+ return {
34
+ on: function (fn) {
35
+ handlers.push(fn);
36
+ return function () {
37
+ var i = handlers.indexOf(fn);
38
+ if (i >= 0) handlers.splice(i, 1);
39
+ };
40
+ },
41
+ emit: function (payload) {
42
+ for (var i = 0; i < handlers.length; i++) {
43
+ try {
44
+ handlers[i](payload);
45
+ } catch (e) {
46
+ console.error('[BGNut] 回调异常:', e);
47
+ }
48
+ }
49
+ },
50
+ };
51
+ }
52
+
53
+ function buildCtx(init, post) {
54
+ var commandEv = makeEmitter();
55
+ var membersEv = makeEmitter();
56
+ var phaseEv = makeEmitter();
57
+ var ctx = {
58
+ /** 我的座位号(0 起) */
59
+ seat: init.seat,
60
+ /** 成员列表 [{seat,name,handle,avatarUrl,online}] */
61
+ members: init.members,
62
+ /** 房间配置(bgnut.json manifest.config,所有客户端一致) */
63
+ config: init.config || {},
64
+ maxPlayers: init.maxPlayers,
65
+ /** 共享随机种子 */
66
+ seed: init.seed,
67
+ /** 开局历史指令(重连/中途加载时先按序应用它,再监听 onCommand) */
68
+ replay: init.replay || [],
69
+ /** 确定性随机(种子驱动,所有客户端同序列) */
70
+ random: mulberry32(init.seed),
71
+ /** 发送指令:服务器定序后广播给所有人(含自己) */
72
+ send: function (data) {
73
+ post({ bgnut: 'command', data: data });
74
+ },
75
+ /** 报告对局结束(首个报告生效) */
76
+ finish: function (winner) {
77
+ post({ bgnut: 'finish', winner: typeof winner === 'number' ? winner : null });
78
+ },
79
+ /** 新指令(含自己的回显):cb({seq, seat, data}) */
80
+ onCommand: commandEv.on,
81
+ /** 成员变化(上线/掉线/加入):cb(members) */
82
+ onMembers: membersEv.on,
83
+ /** 阶段变化:cb({phase:'playing'|'ended', winner}) */
84
+ onPhase: phaseEv.on,
85
+ _dispatch: { command: commandEv.emit, members: membersEv.emit, phase: phaseEv.emit },
86
+ };
87
+ return ctx;
88
+ }
89
+
90
+ // ---------- 桥模式(在主站对局页 iframe 中) ----------
91
+ function joinViaBridge() {
92
+ return new Promise(function (resolve) {
93
+ var ctx = null;
94
+ window.addEventListener('message', function (ev) {
95
+ var m = ev.data;
96
+ if (!m || typeof m !== 'object' || !m.bgnut) return;
97
+ if (m.bgnut === 'init' && !ctx) {
98
+ ctx = buildCtx(m, function (out) {
99
+ window.parent.postMessage(out, '*');
100
+ });
101
+ resolve(ctx);
102
+ } else if (ctx) {
103
+ if (m.bgnut === 'command') ctx._dispatch.command({ seq: m.seq, seat: m.seat, data: m.data });
104
+ else if (m.bgnut === 'members') {
105
+ ctx.members = m.members;
106
+ ctx._dispatch.members(m.members);
107
+ } else if (m.bgnut === 'phase') ctx._dispatch.phase({ phase: m.phase, winner: m.winner });
108
+ }
109
+ });
110
+ window.parent.postMessage({ bgnut: 'ready' }, '*');
111
+ });
112
+ }
113
+
114
+ // ---------- 单机 mock 模式(无宿主:本地直开 / 审核初玩 / bgnut dev 单窗) ----------
115
+ function joinMock() {
116
+ var seq = 0;
117
+ var ctx = buildCtx(
118
+ {
119
+ seat: 0,
120
+ members: [{ seat: 0, name: '本地玩家', handle: null, avatarUrl: null, online: true }],
121
+ config: {},
122
+ maxPlayers: 1,
123
+ seed: (Date.now() % 2147483647) | 0,
124
+ replay: [],
125
+ },
126
+ function (out) {
127
+ if (out.bgnut === 'command') {
128
+ seq += 1;
129
+ var cmd = { seq: seq, seat: 0, data: out.data };
130
+ setTimeout(function () {
131
+ ctx._dispatch.command(cmd);
132
+ }, 0);
133
+ } else if (out.bgnut === 'finish') {
134
+ setTimeout(function () {
135
+ ctx._dispatch.phase({ phase: 'ended', winner: out.winner });
136
+ }, 0);
137
+ }
138
+ },
139
+ );
140
+ console.info('[BGNut] 无宿主环境,进入单机 mock 模式');
141
+ return Promise.resolve(ctx);
142
+ }
143
+
144
+ var joined = null;
145
+ return {
146
+ version: '1.0.0',
147
+ /** 加入对局:返回 ctx。桥模式等待宿主 init;无宿主自动单机 mock。 */
148
+ join: function () {
149
+ if (!joined) joined = window.parent !== window ? joinViaBridge() : joinMock();
150
+ return joined;
151
+ },
152
+ };
153
+ });