Compare commits
217
Commits
5170aa2055
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3172993650 | ||
|
|
1cf2ef1ded | ||
|
|
35216f75e7 | ||
|
|
a7ec8aa6a7 | ||
|
|
30a1b4114c | ||
|
|
9c4510697c | ||
|
|
842c68ef55 | ||
|
|
e1bcfd91ec | ||
|
|
00c2681107 | ||
|
|
695d3be25c | ||
|
|
f2a948f059 | ||
|
|
84fa739ddd | ||
|
|
09e80e5052 | ||
|
|
41fcd65464 | ||
|
|
af059b40e9 | ||
|
|
facceeae9b | ||
|
|
239af0aa39 | ||
|
|
5d90891b38 | ||
|
|
d79bed0253 | ||
|
|
58c974e114 | ||
|
|
053302c5df | ||
|
|
62328b6fd6 | ||
|
|
556c47a1b6 | ||
|
|
4414e1bcab | ||
|
|
73d7826fbe | ||
|
|
266be19e76 | ||
|
|
690fdef78a | ||
|
|
fff4848654 | ||
|
|
745b0a1aeb | ||
|
|
e1b290ea22 | ||
|
|
1a3308ecd0 | ||
|
|
9ad477f474 | ||
|
|
a3cd74d316 | ||
|
|
6976b55038 | ||
|
|
2fa375fc90 | ||
|
|
5a39326fd4 | ||
|
|
66246c8f5b | ||
|
|
baafc4a4f4 | ||
|
|
e00d051112 | ||
|
|
562f58dd2d | ||
|
|
eb5fb44d97 | ||
|
|
c6f1103b32 | ||
|
|
34be99d84a | ||
|
|
76de9432ef | ||
|
|
081d9e9b06 | ||
|
|
286a8b9c16 | ||
|
|
ec67fe6e6d | ||
|
|
d29a659541 | ||
|
|
8b7efa7de0 | ||
|
|
1e168ae81e | ||
|
|
bd45503d47 | ||
|
|
2d7655a5d7 | ||
|
|
8eae3258ca | ||
|
|
1e3a21b476 | ||
|
|
d177c52c3f | ||
|
|
3963b3b791 | ||
|
|
3a9c5d6fdb | ||
|
|
4cc83ea713 | ||
|
|
2570dd7b97 | ||
|
|
88784ad52a | ||
|
|
dfaa4f0ef4 | ||
|
|
471e2e9479 | ||
|
|
30547f6a54 | ||
|
|
a383c0330e | ||
|
|
77b2bff40a | ||
|
|
224df1c21c | ||
|
|
7e5a950cc7 | ||
|
|
1c124cf216 | ||
|
|
e47d9d3007 | ||
|
|
33904e0e58 | ||
|
|
85db7765ac | ||
|
|
58a7a2dc68 | ||
|
|
a8873b9a84 | ||
|
|
72227efd05 | ||
|
|
9840ed70b2 | ||
|
|
462e2e9267 | ||
|
|
4ffa59bf8e | ||
|
|
7acd3ed96e | ||
|
|
fdc6fc2264 | ||
|
|
0a7951ba32 | ||
|
|
a173d410e0 | ||
|
|
73cdf7d95c | ||
|
|
d1ad63983b | ||
|
|
f25211c64e | ||
|
|
f2823c398a | ||
|
|
c0c6a7c800 | ||
|
|
e1556d50f4 | ||
|
|
bdd8ddb2f6 | ||
|
|
0aa66e1ec7 | ||
|
|
e2762d631d | ||
|
|
56fce85040 | ||
|
|
c5863936c6 | ||
|
|
621eb8722e | ||
|
|
c9f6df6e10 | ||
|
|
b651aaae9b | ||
|
|
522d747542 | ||
|
|
8406268b50 | ||
|
|
b945c6fc91 | ||
|
|
ac376c27e3 | ||
|
|
44c7d42fd0 | ||
|
|
20bb4aaad7 | ||
|
|
b15825325b | ||
|
|
c4f954702a | ||
|
|
61f42ec683 | ||
|
|
1b2dc4cae2 | ||
|
|
1e51daeb50 | ||
|
|
1daa22ddbe | ||
|
|
98631944db | ||
|
|
b6b11d7ca1 | ||
|
|
00d6ba3fc4 | ||
|
|
c2a7a071fd | ||
|
|
c8577cfe60 | ||
|
|
4d6f1284d8 | ||
|
|
0a6f3fa24b | ||
|
|
eb1edd86a7 | ||
|
|
997267c2e4 | ||
|
|
5e0546f675 | ||
|
|
0024cd6d18 | ||
|
|
0ffb03979f | ||
|
|
ec550ba257 | ||
|
|
a03ebb45eb | ||
|
|
71ed67abff | ||
|
|
18151f1107 | ||
|
|
23036f9cfa | ||
|
|
65c627e2de | ||
|
|
d0b2265716 | ||
|
|
5311780ad3 | ||
|
|
cb3bd54175 | ||
|
|
7e0a8b79a4 | ||
|
|
601de1f903 | ||
|
|
36e593ecef | ||
|
|
950ba4581c | ||
|
|
e8f4180483 | ||
|
|
ba5aec9bca | ||
|
|
9742509d32 | ||
|
|
2e2a6b0025 | ||
|
|
712206fba0 | ||
|
|
6a756a3274 | ||
|
|
9fddab82d5 | ||
|
|
7f0bd17b9e | ||
|
|
b7c002d6f3 | ||
|
|
cfca336e98 | ||
|
|
9a695878ba | ||
|
|
02dc5d51ca | ||
|
|
fea85c6644 | ||
|
|
2fa663a9e5 | ||
|
|
ae14622d4b | ||
|
|
f997eb0dec | ||
|
|
b32613dd34 | ||
|
|
5514366af7 | ||
|
|
a46bf665d5 | ||
|
|
f526294f1b | ||
|
|
02131fc2c7 | ||
|
|
c326be58ac | ||
|
|
72accbd9d4 | ||
|
|
52f88a285f | ||
|
|
79c2c804dc | ||
|
|
2edf57ec40 | ||
|
|
c57d1d9214 | ||
|
|
64b818d514 | ||
|
|
705c0389b5 | ||
|
|
065ebb1f58 | ||
|
|
9b69bb8611 | ||
|
|
3d972f09f4 | ||
|
|
fa4dbec3a6 | ||
|
|
2ea91e82ab | ||
|
|
4142f590ee | ||
|
|
da594adb1e | ||
|
|
045d9ec8c9 | ||
|
|
d10c43e6ea | ||
|
|
77be6c0d19 | ||
|
|
b54de5bbda | ||
|
|
7404719c24 | ||
|
|
ff346a759f | ||
|
|
f5567a220a | ||
|
|
2e4d55d96d | ||
|
|
84546cd7be | ||
|
|
9539ee93e0 | ||
|
|
6b4faab029 | ||
|
|
416a51e422 | ||
|
|
96cbbd8b0a | ||
|
|
b6c9af25e5 | ||
|
|
d2502276e0 | ||
|
|
e49c28eae8 | ||
|
|
1d546e0211 | ||
|
|
e1f67dde1c | ||
|
|
f1ab6e7fd9 | ||
|
|
f2e766a89a | ||
|
|
8901221e09 | ||
|
|
9d58145bd8 | ||
|
|
8b24acab3f | ||
|
|
7fc5dc55ae | ||
|
|
733090ec13 | ||
|
|
4152ea69c6 | ||
|
|
08b9e56fd4 | ||
|
|
5708926711 | ||
|
|
88deab3533 | ||
|
|
bed9b4f35d | ||
|
|
5a1afedfd3 | ||
|
|
cd064756d6 | ||
|
|
669a2a5e14 | ||
|
|
e224d1fa3e | ||
|
|
134f4c877c | ||
|
|
dcb8b1bd78 | ||
|
|
078c485b69 | ||
|
|
8e35556c33 | ||
|
|
393c6709eb | ||
|
|
550e6da9d1 | ||
|
|
6961c18117 | ||
|
|
44264db8ba | ||
|
|
000f9e4290 | ||
|
|
962c79866f | ||
|
|
967641a7c7 | ||
|
|
8a40e682ab | ||
|
|
65f0998fbd | ||
|
|
24e05189c9 | ||
|
|
c826df1108 |
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: cocos-mcp
|
||||
description: Use when 要对 YouleNexus 工程做任何 Cocos Creator 编辑器操作(场景/节点/组件/prefab/资源/动画/预览截图),或 mcp__funplay_cocos__* 工具没出现、调用报连接失败、curl 127.0.0.1:8765/health 不通时。
|
||||
---
|
||||
|
||||
# funplay-cocos-mcp 接入与排障
|
||||
|
||||
工程 `cocoscreator_projects/YouleNexus`。编辑器操作走 `mcp__funplay_cocos__*` 工具(**105 个独立工具**,不是聚合派发)。
|
||||
|
||||
「禁止手改 `.scene`/`.prefab`/`.anim`/`.meta`」这条规则的唯一来源是 AGENTS.md,本文件不复述——按仓库第二准则,同一条规则不在两处定义。
|
||||
|
||||
## 工具怎么用:不在本文件
|
||||
|
||||
MCP server 连上后会自注入完整说明(每个工具 description 带用法/枚举/示例)。**照它的说明用,不要在本文件重复维护一份工具清单**——扩展换版本时那份副本必然过期误导。需要查工具清单时调 `get_tool_catalog`。
|
||||
|
||||
当前扩展 `funplay-cocos-mcp` v0.5.1(`extensions/funplay-cocos-mcp/`),工具名形如 `mcp__funplay_cocos__<verb>_<noun>`(如 `create_node` / `inspect_component` / `run_project_preview` / `capture_preview_screenshot`)。**不是** `action:` 参数派发模式——没有 `cocos_scene` / `cocos_node` 这种聚合入口,每个操作就是一把独立工具。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
1. Cocos Creator 3.8.8 已打开 `YouleNexus` 工程;
|
||||
2. 扩展 **Funplay Cocos MCP** 已启用(`extensions/funplay-cocos-mcp/funplay-cocos-mcp.config.json` 里 `autostart: true` 默认会自动起 sidecar,监听 `http://127.0.0.1:8765/mcp`);
|
||||
3. Codex 侧**不需要** `.mcp.json` —— `funplay_cocos` 配置在用户级 `~/.Codex.json`,项目级刻意不维护 MCP 配置(schema 校验也不允许空对象,见 memory `cocos-creator-mcp-setup`)。
|
||||
|
||||
## 探活
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8765/health
|
||||
# 期望:{"ok":true,"name":"Funplay Cocos MCP - YouleNexus","version":"0.5.1","projectName":"YouleNexus",...}
|
||||
```
|
||||
|
||||
或在会话内直接调 `mcp__funplay_cocos__get_project_info`,期望 `{ok:true, ...}` 包含 `projectName: YouleNexus`、`cocosVersion: 3.8.8`。
|
||||
|
||||
## 连不上时
|
||||
|
||||
**提示用户去编辑器里启用扩展 / 等待 autostart 起服务**,不要盲目重试,更不要绕开 MCP 去手改序列化文件。服务只在编辑器进程内,外部无法代为启动。
|
||||
|
||||
按此顺序排查:编辑器是否开着 → 扩展管理器里 `Funplay Cocos MCP` 是否启用 → `~/.Codex.json` 里 `mcpServers.funplay_cocos` 条目是否还在(type=http、url=http://127.0.0.1:8765/mcp)→ `/health` 是否通 → 工具列表里是否出现 `mcp__funplay_cocos__*`(用 `get_tool_catalog` 列出来核对)。`~/.Codex.json` 改动后**需重启 Codex 会话**才生效。
|
||||
|
||||
## 已知坑
|
||||
|
||||
funplay 扩展踩过的坑(每条都有对应 memory 详解,结论先列这里):
|
||||
|
||||
- **SpriteFrame UUID**:设组件 `spriteFrame` 属性时 value 必须用 **SpriteFrame 真 UUID**(含 `@sub-asset` 后缀),不是 ImageAsset 路径,也不是裸 UUID——否则 Sprite 不会显示(见 memory `cocos-mcp-spriteframe-uuid`)。
|
||||
- **挂脚本路径判断**:`framework/` 路径下挂脚本能正常识别;老版本曾误判 `framework/` 为非脚本路径,已修正(见 memory `cocos-mcp-mount-script-path`)。
|
||||
- **mount_script 持久化限制**:MCP 的 mount 系列工具不写 prefab JSON 的 `__type__` / `__scriptAsset`,需要用户在编辑器里手动挂一次;不要绕过去 Edit prefab JSON(UUID 引用会被破坏,见 memory `prefab-persist-mount-script`)。
|
||||
- **library 缓存**:不要 `rm -rf YouleNexus/library`;用 Cocos 编辑器的 reload 重建(见 memory `cocos-mcp-library-cache`)。
|
||||
- **工具前缀迁移**:仓库里若还有 `mcp__cocos-creator-mcp__*` 写法(旧 16 聚合工具版),需要改成 `mcp__funplay_cocos__*` 对应动作;AGENTS.md 的允许规则已迁好。
|
||||
|
||||
## 安装方式与历史沿革
|
||||
|
||||
项目记忆 `cocos-creator-mcp-setup` —— 历次换装(164 工具 stdio 桥 → 16 聚合 HTTP :3000 → 105 独立 HTTP :8765)与配置位置演进全在那里。
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: native-bridge-contract
|
||||
description: Use when 要实现/修改 Cocos 前端与原生侧、配置服务的对接——远程配置文件读取(gameserver / urlserver)、window.settings 同步取值、WebViewJavascriptBridge (WVJB) handler 注册与互调(分享/视频/语音/电话/通讯录/电量/网络/摇一摇),或需要核对这些接口名、数据格式、回调约定、URL 构造。
|
||||
---
|
||||
|
||||
# 客户端侧适配约束:远程配置 / 原生数据接口 / 原生↔H5 桥接(须与原项目逐字一致)
|
||||
|
||||
继「服务器零改动」之后的**第二类不可妥协约束**:除 WebSocket 协议外,前端还通过「远程配置文件」与「原生接口」获取大量数据。新 Cocos 前端必须与原项目**逐字一致**地复刻这些读取/互调/注册方式(接口名、数据格式、回调约定、URL 构造),让原生侧与配置服务**零改动**即可对接。三类机制:
|
||||
|
||||
1. **远程配置文件读取**:入口 `Game_Config.Debugger.gameserver`(`projects/Game_Surface_3/js/01_SubGame/00_SubGame_Config.js:11`,远程 `.txt` URL + `ifast_random()` 防缓存 + `serverType` 切正式/本地)。读取链:`Logic.setGameServer()`(解析 URL 参数 `gameconfig` 或 `Func.getothername("gameserver")` 覆盖,`12_Logic.js:1259-1281`)→ `get_config(gameserver)`(**引擎层函数**,GET 远程 txt,`12_Logic.js:521`)→ `ServerUrl_Succ(_msg)` 回调(`12_Logic.js:533`)→ `GameData.Server = _msg.data.urlserver`(`12_Logic.js:549`)。**URL 构造、请求方式、回包字段解析须一致。**
|
||||
|
||||
2. **原生同步数据接口(注入对象)**:`Func.getothername(name)` → `window.settings.getothername(name)`(`js/00_Surface/05_Func.js:2467-2471`)。`window.settings` 是**原生注入的全局对象**,用于同步取配置(如渠道/包信息/gameserver 覆盖)。
|
||||
|
||||
3. **原生↔H5 异步桥 = WebViewJavascriptBridge (WVJB)**(`05_Func.js:2627` 起):
|
||||
- 初始化 `setupWebViewJavascriptBridge(callback)`(经 `window.WVJBCallbacks` / `WebViewJavascriptBridgeReady` 事件 / `wvjbscheme://__BRIDGE_LOADED__` iframe)。
|
||||
- **H5 注册供原生调用**:`bridge.registerHandler("<name>", (data, responseCallback) => {…})`。已注册 handler(名称/数据结构须一致):`getVideoinfo`、`sharelogin`、`sharesuccess`、`gameui_play_voice`、`gameui_stop_voice`、`getphoneinfo`、`getAddressBook`、`phonestate`、`appservice`、`getaudiourl`、`getBattery`、`getwifiLevel`、`getnetwork`、`shakeEnd` 等(`05_Func.js:2651+`)。
|
||||
- **H5 调用原生**:`bridge.callHandler("<name>", data, responseCallback)`(WVJB 约定)。
|
||||
- 覆盖能力:分享、视频、语音录制/播放、电话状态、通讯录、电量/wifi/网络、摇一摇等。
|
||||
|
||||
> **Cocos 适配注意**:原项目是 H5(WebView + WVJB + `window.settings` + 引擎 `get_config`)。新 Cocos 前端无论走 WebView 还是原生(jsb),都必须对接**同名 handler、同样的数据结构与回调约定**,并保持远程配置读取流程一致,使**原生侧与配置服务无需改动**即可互通。这套桥接的落地属 sdk/平台层范畴(后续 Plan)。改这部分前先核对上述源码位置,不要臆测接口名或数据格式。
|
||||
@@ -1,45 +1,51 @@
|
||||
---
|
||||
name: cocos-mcp
|
||||
description: Use when 要对 YouleNexus 工程做任何 Cocos Creator 编辑器操作(场景/节点/组件/prefab/资源/动画/预览截图),或 cocos_* 工具压根没出现、调用报连接失败、curl 127.0.0.1:3000 不通时。
|
||||
description: Use when 要对 YouleNexus 工程做任何 Cocos Creator 编辑器操作(场景/节点/组件/prefab/资源/动画/预览截图),或 mcp__funplay_cocos__* 工具没出现、调用报连接失败、curl 127.0.0.1:8765/health 不通时。
|
||||
---
|
||||
|
||||
# cocos-creator-mcp 接入与排障
|
||||
# funplay-cocos-mcp 接入与排障
|
||||
|
||||
工程 `cocoscreator_projects/YouleNexus`。编辑器操作走 `mcp__cocos-creator-mcp__cocos_*` 工具。
|
||||
工程 `cocoscreator_projects/YouleNexus`。编辑器操作走 `mcp__funplay_cocos__*` 工具(**105 个独立工具**,不是聚合派发)。
|
||||
|
||||
「禁止手改 `.scene`/`.prefab`/`.anim`/`.meta`」这条规则的唯一来源是 CLAUDE.md,本文件不复述——按仓库第二准则,同一条规则不在两处定义。
|
||||
|
||||
## 工具怎么用:不在本文件
|
||||
|
||||
MCP server 连上后会自动注入完整说明(CRITICAL RULES + intent→tool 速查表 + 批量操作),每个工具的 description 也带 action 枚举和示例。**照它的说明用,不要在本文件重复维护一份工具清单**——扩展换版本时那份副本必然过期误导。
|
||||
MCP server 连上后会自注入完整说明(每个工具 description 带用法/枚举/示例)。**照它的说明用,不要在本文件重复维护一份工具清单**——扩展换版本时那份副本必然过期误导。需要查工具清单时调 `get_tool_catalog`。
|
||||
|
||||
当前扩展 `cocos-mcp-server`(`extensions/cocos-mcp-server/`),暴露 16 个聚合工具(`cocos_scene` / `node` / `component` / `prefab` / `asset` / `editor` / `view` / `composite` / `knowledge` / `validate` / `template` / `capture` / `builder` / `animation` / `spine` / `label`),每个靠 `action` 参数分派。工具默认是 deferred,先 `ToolSearch` 加载 schema 再调。
|
||||
当前扩展 `funplay-cocos-mcp` v0.5.1(`extensions/funplay-cocos-mcp/`),工具名形如 `mcp__funplay_cocos__<verb>_<noun>`(如 `create_node` / `inspect_component` / `run_project_preview` / `capture_preview_screenshot`)。**不是** `action:` 参数派发模式——没有 `cocos_scene` / `cocos_node` 这种聚合入口,每个操作就是一把独立工具。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
1. Cocos Creator 3.7+ 已打开 `YouleNexus` 工程;
|
||||
2. 扩展 **Cocos MCP Server** 已启用,且在其面板里点了 **Start Server**(监听 `http://127.0.0.1:3000/mcp`)。
|
||||
|
||||
Claude Code 侧配置在仓库根 `.mcp.json`,HTTP 直连,服务器名 `cocos-creator-mcp`(改名会同时打断 `.claude/settings.local.json` 里的 `mcp__cocos-creator-mcp__*` 允许规则)。
|
||||
1. Cocos Creator 3.8.8 已打开 `YouleNexus` 工程;
|
||||
2. 扩展 **Funplay Cocos MCP** 已启用(`extensions/funplay-cocos-mcp/funplay-cocos-mcp.config.json` 里 `autostart: true` 默认会自动起 sidecar,监听 `http://127.0.0.1:8765/mcp`);
|
||||
3. Claude Code 侧**不需要** `.mcp.json` —— `funplay_cocos` 配置在用户级 `~/.claude.json`,项目级刻意不维护 MCP 配置(schema 校验也不允许空对象,见 memory `cocos-creator-mcp-setup`)。
|
||||
|
||||
## 探活
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3000/health
|
||||
# 期望:{"status":"ok","tools":16,"transport":"streamable-http"}
|
||||
curl http://127.0.0.1:8765/health
|
||||
# 期望:{"ok":true,"name":"Funplay Cocos MCP - YouleNexus","version":"0.5.1","projectName":"YouleNexus",...}
|
||||
```
|
||||
|
||||
或在会话内直接调 `mcp__funplay_cocos__get_project_info`,期望 `{ok:true, ...}` 包含 `projectName: YouleNexus`、`cocosVersion: 3.8.8`。
|
||||
|
||||
## 连不上时
|
||||
|
||||
**提示用户去编辑器里启动服务,不要盲目重试,更不要绕开 MCP 去手改序列化文件。** 服务只在编辑器进程内,外部无法代为启动。
|
||||
**提示用户去编辑器里启用扩展 / 等待 autostart 起服务**,不要盲目重试,更不要绕开 MCP 去手改序列化文件。服务只在编辑器进程内,外部无法代为启动。
|
||||
|
||||
按此顺序排查:编辑器是否开着 → 扩展是否启用 → 面板是否点过 Start Server → `/health` 是否通 → `.mcp.json` 是否仍指向 `http://127.0.0.1:3000/mcp`(历史上曾指向已删除的 stdio-bridge 路径)。改完 `.mcp.json` 需重启会话才生效。
|
||||
|
||||
`settings/mcp-server.json` 里 `autoStart: false`,所以每次开编辑器都得手动点 Start Server;这大概率就是 `ECONNREFUSED` 的常见成因(改 true 应可自动启动,**未实测**)。
|
||||
按此顺序排查:编辑器是否开着 → 扩展管理器里 `Funplay Cocos MCP` 是否启用 → `~/.claude.json` 里 `mcpServers.funplay_cocos` 条目是否还在(type=http、url=http://127.0.0.1:8765/mcp)→ `/health` 是否通 → 工具列表里是否出现 `mcp__funplay_cocos__*`(用 `get_tool_catalog` 列出来核对)。`~/.claude.json` 改动后**需重启 Claude Code 会话**才生效。
|
||||
|
||||
## 已知坑
|
||||
|
||||
- `cocos_asset{action:"search"}` 的 `type` 过滤**不生效**:传 `cc.SceneAsset` 照样返回脚本和目录,返回消息还谎称「of type 'cc.SceneAsset'」。自己按结果里的 `type` 字段过滤。
|
||||
- `cocos_scene{action:"hierarchy"}` 的 `includeComponents: true` **不生效**,返回树里没有组件信息。要拿组件得对每个节点追加 `cocos_node{action:"info"}`。
|
||||
- 工程当前**没有任何场景资源**(`assets/` 下 0 个 `.scene`,只有 `framework/` 的 `.ts`)。编辑器里那个 `scene-2d` 是 Creator 内置模板(`db://internal/default_file_content/scene/scene-2d.scene`)新建出来的未保存场景,不是工程资产——别浪费时间去仓库里找它。
|
||||
- 安装方式与历史沿革记在项目记忆 `cocos-creator-mcp-setup`。
|
||||
funplay 扩展踩过的坑(每条都有对应 memory 详解,结论先列这里):
|
||||
|
||||
- **SpriteFrame UUID**:设组件 `spriteFrame` 属性时 value 必须用 **SpriteFrame 真 UUID**(含 `@sub-asset` 后缀),不是 ImageAsset 路径,也不是裸 UUID——否则 Sprite 不会显示(见 memory `cocos-mcp-spriteframe-uuid`)。
|
||||
- **挂脚本路径判断**:`framework/` 路径下挂脚本能正常识别;老版本曾误判 `framework/` 为非脚本路径,已修正(见 memory `cocos-mcp-mount-script-path`)。
|
||||
- **mount_script 持久化限制**:MCP 的 mount 系列工具不写 prefab JSON 的 `__type__` / `__scriptAsset`,需要用户在编辑器里手动挂一次;不要绕过去 Edit prefab JSON(UUID 引用会被破坏,见 memory `prefab-persist-mount-script`)。
|
||||
- **library 缓存**:不要 `rm -rf YouleNexus/library`;用 Cocos 编辑器的 reload 重建(见 memory `cocos-mcp-library-cache`)。
|
||||
- **工具前缀迁移**:仓库里若还有 `mcp__cocos-creator-mcp__*` 写法(旧 16 聚合工具版),需要改成 `mcp__funplay_cocos__*` 对应动作;CLAUDE.md 的允许规则已迁好。
|
||||
|
||||
## 安装方式与历史沿革
|
||||
|
||||
项目记忆 `cocos-creator-mcp-setup` —— 历次换装(164 工具 stdio 桥 → 16 聚合 HTTP :3000 → 105 独立 HTTP :8765)与配置位置演进全在那里。
|
||||
|
||||
+13
-15
@@ -17,27 +17,25 @@ yarn-error.log*
|
||||
# ---------- Node 依赖 ----------
|
||||
**/node_modules/
|
||||
|
||||
# ---------- Cocos Creator 工程生成物(可由编辑器重建,勿入库) ----------
|
||||
# 适用于 cocoscreator_projects/ 下所有工程
|
||||
**/library/
|
||||
**/temp/
|
||||
**/local/
|
||||
**/build/
|
||||
**/native/
|
||||
**/profiles/
|
||||
# pnpm 本地内容寻址缓存(机器生成,可由依赖清单重建)
|
||||
/.pnpm-store/
|
||||
|
||||
# Codex / Git 隔离开发工作树(机器本地)
|
||||
/.worktrees/
|
||||
|
||||
# ---------- Cocos Creator 工程生成物 ----------
|
||||
# 由各工程自己的 .gitignore 用锚定路径负责(YouleNexus/.gitignore、templates/game-seed/.gitignore)。
|
||||
# 此处**不得**再写 **/library/ 这类无锚点模式——它会连 assets/framework 下的
|
||||
# native/ local/ profiles/ 等业务目录一起吞掉(已踩过,见 plan 2026-08-27 Task 1)。
|
||||
|
||||
# 构建流水线的临时工程(build-game 产生,可随时重建)
|
||||
cocoscreator_projects/build-workspace/
|
||||
|
||||
# ---------- 子游戏共享框架的 junction(指向 YouleNexus 真源,按机器重建) ----------
|
||||
# 由 scripts/setup-links 在每台机器上重建,不入库
|
||||
cocoscreator_projects/games/*/assets/framework
|
||||
cocoscreator_projects/games/*/assets/framework.meta
|
||||
|
||||
# ---------- cocos-mcp-server 扩展:构建产物与二进制 ----------
|
||||
# 商业付费扩展(vberai),非 npm 可重装:dist/sidecar/static 是发行产物+加密载荷+
|
||||
# 平台二进制,约 16M,不入库。换机器时从购买渠道重新拷贝整个扩展目录。
|
||||
cocoscreator_projects/YouleNexus/extensions/cocos-mcp-server/dist/
|
||||
cocoscreator_projects/YouleNexus/extensions/cocos-mcp-server/sidecar/
|
||||
cocoscreator_projects/YouleNexus/extensions/cocos-mcp-server/static/
|
||||
|
||||
# ---------- Claude Code 个人本地配置 ----------
|
||||
# .claude/skills/ 入库(团队共享),本地权限覆盖不入库
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"cocos-creator-mcp": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:3000/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## 第一准则:服务器零改动
|
||||
|
||||
**任何前端开发都必须完全遵循前后端数据包的协议与数据结构,做到服务器零改动。** 这是本仓库不可逾越的最高准则,优先于一切其它考量:
|
||||
|
||||
- 协议信封、`route`/`rpc` 命名、字段名与类型、`roomtype` 配置编码、`deskinfo` 快照结构等,**必须与现有协议逐字节对齐**(依据 `docs/protocol/`),不得为前端方便而改变格式或新增/重命名字段。
|
||||
- 遇到协议层有疑问时,**先读 `docs/protocol/` 对应章节核对,不要臆测**;宁可前端多做适配,也绝不要求服务器配合改动。
|
||||
- 新的 Cocos 前端(`YouleNexus`)的目标,就是在不改服务器的前提下复刻该协议契约、实现联调兼容。
|
||||
|
||||
## 第二准则:数据源权威、唯一,下游不兜底
|
||||
|
||||
**每一类数据只能有一个权威且唯一的来源(single source of truth);下游消费方不得猜测、修补、兜底。**
|
||||
|
||||
- **唯一来源**:同一份配置/数据(如服务器地址、渠道身份、协议常量)只在一个地方定义,其它地方一律引用,不得各处复制或并存第二份。
|
||||
- **下游零兜底**:消费方拿到的数据缺失或非法时,**直接显式报错/抛出,把问题暴露到来源**,不得用默认值「猜一个」、不得静默 `?? 兜底值`、不得 try/catch 后塞个 fallback 蒙混。错误要早暴露、可定位,而不是被下游悄悄掩盖。
|
||||
- **配置即契约**:来源(如 `profiles.ts` 的服务器地址)必须把该提供的字段显式提供齐全;缺了就是配置错误,应在来源处修正,而不是让下游补。
|
||||
- 例外只有一种:来源本身明确定义了「可选 + 缺省语义」,且该缺省**写在来源处**(而非散落在各下游)。
|
||||
|
||||
> 这条与「服务器零改动」并列:前者保边界契约不变,后者保内部数据流可信、单源、可追溯。新增/修改任何带默认值、回退、容错分支的代码前,先问:这是不是在替上游兜底?若是,改为在来源处修正 + 在下游暴露。
|
||||
|
||||
## 仓库总览
|
||||
|
||||
这是友乐(Youle)棋牌游戏平台的前端工作区,核心是「**一套前端模板 + 多个子游戏**」的架构,并正在迁移到 Cocos Creator。主要目录:
|
||||
|
||||
- **`projects/`** —— 旧版 HTML5 前端。`projects/Game_Surface_3` 是**前端模板(平台外壳)**,也是新前端 UI 迁移的**原工程(迁移源)**;其余目录都是**基于该模板扩展出来的子游戏**,平台层几乎不动,只改子游戏层 + 美术数据。分层结构与关键文件见 `projects/AGENTS.md`。
|
||||
- **`docs/protocol/`** —— 前端模板框架与服务器收发包的**协议规范**(权威文档,8 篇)。改动任何网络层代码前**必须先读对应章节**,服务器不可改,前端必须严格对齐协议。
|
||||
- **`cocoscreator_projects/YouleNexus/`** —— 新的 Cocos Creator 3.8+ 前端工程,目标是按 `docs/protocol` 复刻协议契约,做到服务器零改动即可联调。
|
||||
|
||||
### 服务器文档与二七王服务端
|
||||
|
||||
- **`server/development-guide/`**:服务器通用开发文档。查服务端框架、子游戏接入、通信协议和开发约束时,从 [README](server/development-guide/README.md) 开始,按任务阅读对应章节。
|
||||
- **`server/games/erqiwang/`**:二七王当前改动后的服务器代码。核对实际行为时读取这里的实现,不以 `projects/erqiwang` 的旧前端推断当前服务器规则。
|
||||
- 二七王规则设计位于 [design.md](server/games/erqiwang/docs/design/design.md),玩法协议位于 [packet_protocol.md](server/games/erqiwang/docs/protocol/packet_protocol.md)。前端接入时同时核对设计、协议与服务端实现;发现差异应明确记录,不把设计要求冒充已实现行为。
|
||||
- `roomtype` 由目标子游戏定义,可能是数组或字符串;平台通用流程不可擅自转换类型。二七王实际解析以 `class.config.js` 为入口,局数与扣卡检查 `class.export.js`;位索引只在协议文档中维护,不在本文件重复定义。
|
||||
- 查阅服务器源码用于前端适配,不代表授权修改服务器代码;前端开发继续遵守“服务器零改动”。
|
||||
|
||||
### 原工程 `Game_Surface_3` → `YouleNexus` 的 UI 迁移
|
||||
|
||||
`YouleNexus` 的 UI 资源(`assets/framework/ui/prefabs/`)从**原工程 `projects/Game_Surface_3`** 逐层复刻。数据契约与流程规范:
|
||||
|
||||
- 数据模型(权威):`docs/superpowers/specs/原工程数据模型.md`;迁移流程:`docs/superpowers/specs/UI-手工迁移规范.md`(9 步 funplay MCP + 8 项验证清单)。
|
||||
- 已完成的参照真相源:`assets/framework/ui/prefabs/Login_Layer.prefab`(Layer 2)。
|
||||
|
||||
> ⚠️ **索引与类型(易踩坑,实测修正)**:源数据索引**直接按 ID**——`gameabc_Object.json` 的 `ObjectList[ObjectID]`、`gameabc_Image.json` 的 `ImageFileList[ImageFileID]`(**不是** `ID - 1`,旧文档的 off-by-one 是错的);组件类型看 `ObjectType`(`2`=Sprite、`4`=Text),**不是** XML 的 `SpiritType`(那是另一套枚举)。
|
||||
|
||||
## 网络协议(`docs/protocol/`,新旧前端共同契约)
|
||||
|
||||
`docs/protocol/` 是权威文档,改任何网络层代码前先读对应章节,**不要凭本文件的转述臆测**。
|
||||
|
||||
**两个不可妥协的约束:** ① `roomtype` 房间配置编码的类型与结构必须与目标子游戏**逐字节一致**(服务器据此解析规则);② `deskinfo` 对局快照须可序列化/反序列化以支持 `isbattle==1` 时 `Game_Modify.Reconnect(deskinfo)` 断线重连。
|
||||
|
||||
## 客户端侧适配约束:远程配置 / 原生数据接口 / 原生↔H5 桥接
|
||||
|
||||
继「服务器零改动」之后的**第二类不可妥协约束**:除 WebSocket 协议外,前端还通过「远程配置文件」(`gameserver` → `urlserver`)与「原生接口」(`window.settings` 同步取值、WVJB 异步桥)获取大量数据。新 Cocos 前端必须与原项目**逐字一致**地复刻这些读取/互调/注册方式(接口名、数据格式、回调约定、URL 构造),让原生侧与配置服务**零改动**即可对接。
|
||||
|
||||
具体机制、handler 清单与源码位置见 `native-bridge-contract` 技能(`.Codex/skills/native-bridge-contract/SKILL.md`)——改这部分前先读它核对,不要臆测接口名或数据格式。
|
||||
|
||||
## Cocos Creator 新前端:优先使用 funplay-cocos-mcp
|
||||
|
||||
新工程 `cocoscreator_projects/YouleNexus`。**任何涉及该工程的编辑器操作**(场景、节点、组件、prefab、资源、动画、预览/截图等),都必须走 `mcp__funplay_cocos__*` 这组 MCP 工具驱动编辑器。
|
||||
|
||||
**绝不允许手改** `.scene` / `.prefab` / `.anim` / `.meta`——它们是 UUID 引用的序列化资源,任何文本编辑都会破坏引用结构,导致「资源导入失败」且只能人工恢复。**MCP 连不上时也不例外**:应提示用户去编辑器启动服务,而不是退而求其次去手改文件。
|
||||
|
||||
具体工具用法不写在这里——MCP server 连上后会自注入完整说明(工具清单、action 速查、批量操作),以其为准,**正常操作不需要先读任何仓库文档**。
|
||||
|
||||
`cocos-mcp` 技能(`.Codex/skills/cocos-mcp/SKILL.md`)只记录 MCP 自己给不了的部分:前置条件、探活、排障、已知坑。**工具正常时不必读它;一旦 `cocos_*` 工具没出现、调用报连接失败、或行为与预期不符,就去读。**
|
||||
@@ -25,10 +25,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
这是友乐(Youle)棋牌游戏平台的前端工作区,核心是「**一套前端模板 + 多个子游戏**」的架构,并正在迁移到 Cocos Creator。三个顶层目录:
|
||||
|
||||
- **`projects/`** —— 旧版 HTML5 前端。`projects/Game_Surface_3` 是**前端模板(平台外壳)**,其余目录都是**基于该模板扩展出来的子游戏**,平台层几乎不动,只改子游戏层 + 美术数据。分层结构与关键文件见 `projects/CLAUDE.md`。
|
||||
- **`projects/`** —— 旧版 HTML5 前端。`projects/Game_Surface_3` 是**前端模板(平台外壳)**,也是新前端 UI 迁移的**原工程(迁移源)**;其余目录都是**基于该模板扩展出来的子游戏**,平台层几乎不动,只改子游戏层 + 美术数据。分层结构与关键文件见 `projects/CLAUDE.md`。
|
||||
- **`docs/protocol/`** —— 前端模板框架与服务器收发包的**协议规范**(权威文档,8 篇)。改动任何网络层代码前**必须先读对应章节**,服务器不可改,前端必须严格对齐协议。
|
||||
- **`cocoscreator_projects/YouleNexus/`** —— 新的 Cocos Creator 3.8+ 前端工程,目标是按 `docs/protocol` 复刻协议契约,做到服务器零改动即可联调。
|
||||
|
||||
### 原工程 `Game_Surface_3` → `YouleNexus` 的 UI 迁移
|
||||
|
||||
`YouleNexus` 的 UI 资源(`assets/framework/ui/prefabs/`)从**原工程 `projects/Game_Surface_3`** 逐层复刻。数据契约与流程规范:
|
||||
|
||||
- 数据模型(权威):`docs/superpowers/specs/原工程数据模型.md`;迁移流程:`docs/superpowers/specs/UI-手工迁移规范.md`(9 步 funplay MCP + 8 项验证清单)。
|
||||
- 已完成的参照真相源:`assets/framework/ui/prefabs/Login_Layer.prefab`(Layer 2)。
|
||||
|
||||
> ⚠️ **索引与类型(易踩坑,实测修正)**:源数据索引**直接按 ID**——`gameabc_Object.json` 的 `ObjectList[ObjectID]`、`gameabc_Image.json` 的 `ImageFileList[ImageFileID]`(**不是** `ID - 1`,旧文档的 off-by-one 是错的);组件类型看 `ObjectType`(`2`=Sprite、`4`=Text),**不是** XML 的 `SpiritType`(那是另一套枚举)。
|
||||
|
||||
## 网络协议(`docs/protocol/`,新旧前端共同契约)
|
||||
|
||||
`docs/protocol/` 是权威文档,改任何网络层代码前先读对应章节,**不要凭本文件的转述臆测**。
|
||||
@@ -41,9 +50,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
具体机制、handler 清单与源码位置见 `native-bridge-contract` 技能(`.claude/skills/native-bridge-contract/SKILL.md`)——改这部分前先读它核对,不要臆测接口名或数据格式。
|
||||
|
||||
## Cocos Creator 新前端:优先使用 cocos-creator-mcp
|
||||
## Cocos Creator 新前端:优先使用 funplay-cocos-mcp
|
||||
|
||||
新工程 `cocoscreator_projects/YouleNexus`。**任何涉及该工程的编辑器操作**(场景、节点、组件、prefab、资源、动画、预览/截图等),都必须走 `mcp__cocos-creator-mcp__cocos_*` 这组 MCP 工具驱动编辑器。
|
||||
新工程 `cocoscreator_projects/YouleNexus`。**任何涉及该工程的编辑器操作**(场景、节点、组件、prefab、资源、动画、预览/截图等),都必须走 `mcp__funplay_cocos__*` 这组 MCP 工具驱动编辑器。
|
||||
|
||||
**绝不允许手改** `.scene` / `.prefab` / `.anim` / `.meta`——它们是 UUID 引用的序列化资源,任何文本编辑都会破坏引用结构,导致「资源导入失败」且只能人工恢复。**MCP 连不上时也不例外**:应提示用户去编辑器启动服务,而不是退而求其次去手改文件。
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
ImgID=10 ui\atlas-common\00010.png.meta: main_uuid=dcc4530b-344b-40b6-8fbb-9fa35db6a289, spriteFrames=[('f9941', 'dcc4530b-344b-40b6-8fbb-9fa35db6a289@f9941')]
|
||||
ImgID=11 ui\atlas-common\00011.png.meta: main_uuid=eabf148a-fa6a-460a-be4f-8e586eb80e07, spriteFrames=[('f9941', 'eabf148a-fa6a-460a-be4f-8e586eb80e07@f9941')]
|
||||
ImgID=33 ui\atlas-common\00033.png.meta: main_uuid=eedce6d7-3ea6-434c-83e1-87d9a04b2bad, spriteFrames=[('f9941', 'eedce6d7-3ea6-434c-83e1-87d9a04b2bad@f9941')]
|
||||
ImgID=41 ui\atlas-common\00041.png.meta: main_uuid=3e7492cb-7ae1-4f56-806d-07096662d8fb, spriteFrames=[('f9941', '3e7492cb-7ae1-4f56-806d-07096662d8fb@f9941')]
|
||||
ImgID=54 ui\atlas-common\00054.png.meta: main_uuid=4b04584c-c0f4-4586-8be7-05b0adca4157, spriteFrames=[('f9941', '4b04584c-c0f4-4586-8be7-05b0adca4157@f9941')]
|
||||
ImgID=87 ui\atlas-common\00087.png.meta: main_uuid=c6295419-a30e-4e88-b7d7-d4099114319d, spriteFrames=[('f9941', 'c6295419-a30e-4e88-b7d7-d4099114319d@f9941')]
|
||||
ImgID=95 ui\atlas-hall\00095.png.meta: main_uuid=998850c4-7861-4c12-8cb4-be7a7415c42a, spriteFrames=[('f9941', '998850c4-7861-4c12-8cb4-be7a7415c42a@f9941')]
|
||||
ImgID=97 ui\atlas-common\00097_1.png.meta: main_uuid=5690de47-2417-4e4c-b948-b772697cd010, spriteFrames=[('f9941', '5690de47-2417-4e4c-b948-b772697cd010@f9941')]
|
||||
ImgID=97 ui\atlas-common\00097_2.png.meta: main_uuid=64f9e5ff-6381-4051-9be8-0e678a056519, spriteFrames=[('f9941', '64f9e5ff-6381-4051-9be8-0e678a056519@f9941')]
|
||||
ImgID=97 ui\atlas-common\00097_3.png.meta: main_uuid=1fa00bd2-79b8-49e9-bdf3-c0f48dd0ff0e, spriteFrames=[('f9941', '1fa00bd2-79b8-49e9-bdf3-c0f48dd0ff0e@f9941')]
|
||||
ImgID=97 ui\atlas-common\00097_4.png.meta: main_uuid=fdf821b1-90eb-433d-ac8d-7826a943d482, spriteFrames=[('f9941', 'fdf821b1-90eb-433d-ac8d-7826a943d482@f9941')]
|
||||
ImgID=97 ui\atlas-common\00097_5.png.meta: main_uuid=d8abacb4-7e61-4cd1-87fa-68de231eb7cd, spriteFrames=[('f9941', 'd8abacb4-7e61-4cd1-87fa-68de231eb7cd@f9941')]
|
||||
ImgID=97 ui\atlas-common\00097_6.png.meta: main_uuid=13972a6c-5bca-44f4-b8c7-52f7f2e1ab58, spriteFrames=[('f9941', '13972a6c-5bca-44f4-b8c7-52f7f2e1ab58@f9941')]
|
||||
ImgID=97 ui\atlas-common\00097_7.png.meta: main_uuid=fc639c96-4683-4f02-8769-df540f1ee86c, spriteFrames=[('f9941', 'fc639c96-4683-4f02-8769-df540f1ee86c@f9941')]
|
||||
ImgID=97 ui\atlas-common\00097_8.png.meta: main_uuid=133c04b5-5ec3-4bab-b362-2049efe926fe, spriteFrames=[('f9941', '133c04b5-5ec3-4bab-b362-2049efe926fe@f9941')]
|
||||
ImgID=98 ui\atlas-common\00098.png.meta: main_uuid=837b6d75-1a66-40a0-8b7a-42dae7146c2a, spriteFrames=[('f9941', '837b6d75-1a66-40a0-8b7a-42dae7146c2a@f9941')]
|
||||
ImgID=249 ui\atlas-common\00249.png.meta: main_uuid=b44cf3e4-ec84-4208-a68f-ec5e30ecd593, spriteFrames=[('f9941', 'b44cf3e4-ec84-4208-a68f-ec5e30ecd593@f9941')]
|
||||
ImgID=380 ui\atlas-common\00380_1.png.meta: main_uuid=1305d4c3-fc5b-4873-8ec4-e619f7514692, spriteFrames=[('f9941', '1305d4c3-fc5b-4873-8ec4-e619f7514692@f9941')]
|
||||
ImgID=380 ui\atlas-common\00380_2.png.meta: main_uuid=4a3b9a9e-c742-4e4c-ab43-b5126cfd3201, spriteFrames=[('f9941', '4a3b9a9e-c742-4e4c-ab43-b5126cfd3201@f9941')]
|
||||
ImgID=380 ui\atlas-common\00380_3.png.meta: main_uuid=0251bf88-1cba-4559-9cb7-1c15af24dc4f, spriteFrames=[('f9941', '0251bf88-1cba-4559-9cb7-1c15af24dc4f@f9941')]
|
||||
ImgID=380 ui\atlas-common\00380_4.png.meta: main_uuid=183530d0-bb81-4915-906d-982ed8b179d1, spriteFrames=[('f9941', '183530d0-bb81-4915-906d-982ed8b179d1@f9941')]
|
||||
ImgID=380 ui\atlas-common\00380_5.png.meta: main_uuid=15a52240-5754-42ef-a767-2dfb6556d23d, spriteFrames=[('f9941', '15a52240-5754-42ef-a767-2dfb6556d23d@f9941')]
|
||||
ImgID=380 ui\atlas-common\00380_6.png.meta: main_uuid=6440a9c3-6ead-4b90-ab66-6e34f614317d, spriteFrames=[('f9941', '6440a9c3-6ead-4b90-ab66-6e34f614317d@f9941')]
|
||||
ImgID=381 ui\atlas-common\00381.png.meta: main_uuid=31eeca1a-3b00-4b66-b2c1-0d647d6c821c, spriteFrames=[('f9941', '31eeca1a-3b00-4b66-b2c1-0d647d6c821c@f9941')]
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skillId": "cocos-mcp-workflow",
|
||||
"skillName": "funplay-cocos-mcp-workflow",
|
||||
"templateVersion": 2,
|
||||
"templateHash": "b0371afe3dd7084052927af8130a0929c0877cd48ade67ca9bd9605e1d8cee35",
|
||||
"installedHash": "b0371afe3dd7084052927af8130a0929c0877cd48ade67ca9bd9605e1d8cee35",
|
||||
"extensionVersion": "0.5.1",
|
||||
"updatedAt": "2026-08-31T22:41:36.037Z"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: funplay-cocos-mcp-workflow
|
||||
description: "Edit, inspect, validate, preview, and debug Cocos Creator projects through Funplay Cocos MCP. Use when working with scenes, nodes, prefabs, assets, TypeScript, logs, screenshots, preview behavior, runtime state, or MCP connectivity."
|
||||
---
|
||||
|
||||
# Funplay Cocos MCP Workflow
|
||||
|
||||
## Instructions
|
||||
|
||||
## Operating Loop
|
||||
|
||||
1. Establish context.
|
||||
- Read `cocos://project/context` or call `get_editor_state` before assuming the project, active scene, MCP URL, selection, visible windows, or tool profile.
|
||||
- Inspect the active scene with `get_scene_info` and `get_hierarchy`; use `get_selection`, `list_scenes`, `list_assets`, or `list_prefabs` when identity or ownership is unclear.
|
||||
- Treat user-provided node and asset names as hints. Resolve the real hierarchy path, node UUID, asset UUID, or `db://assets/...` URL before editing.
|
||||
- Call `get_tool_catalog` when a required tool may be hidden by the `core`, `full`, or custom exposure profile.
|
||||
2. Choose the edit surface.
|
||||
- Edit TypeScript and ordinary project files with repository tools or the MCP file tools, then refresh the affected asset and run diagnostics.
|
||||
- Edit live scene nodes with focused scene/component tools or `execute_javascript` using `context="scene"`; save the scene when the change must persist.
|
||||
- Use `execute_javascript` with `context="editor"` for asset-db, Editor messages, project orchestration, and filesystem work that belongs in the editor process.
|
||||
- Inspect prefab ownership and references before mutation. Prefer focused prefab tools, or edit a verified linked instance and apply it back through the editor workflow.
|
||||
- Preserve an existing UI or gameplay prefab hierarchy and change only the necessary nodes, components, and serialized fields; do not rebuild the entire prefab unless explicitly requested.
|
||||
3. Execute the smallest coherent change.
|
||||
- Prefer one guarded `execute_javascript` operation for tightly related editor work, but use focused tools when they provide clearer validation or safer arguments.
|
||||
- Keep JavaScript safety checks enabled unless the code and its paths were reviewed explicitly.
|
||||
- Null-check every scene, node, component, asset, and filesystem lookup. Return concise structured before/after values, including stable UUIDs or asset URLs where useful.
|
||||
- Save or refresh only the assets and scenes intentionally changed.
|
||||
- Do not guess alternate paths, silently create replacement objects, or run self-healing fallback loops after a missing reference or unsupported editor message.
|
||||
4. Read back and validate.
|
||||
- Re-inspect the exact node, component, prefab instance, or asset after mutation; a successful command response alone is not proof of the final editor state.
|
||||
- Run `run_script_diagnostics` or `get_script_diagnostic_context` after TypeScript changes, then use `validate_scene` and project logs before claiming success.
|
||||
- For visual or runtime work, run the appropriate browser, Game View, or simulator preview and verify with runtime state, input, logs, and screenshots.
|
||||
- State exactly what was verified and what still requires a native build, device, network, store, or manual check.
|
||||
|
||||
## Scene, Prefab, and Asset Safety
|
||||
|
||||
- Do not treat Cocos `.scene`, `.prefab`, or `.meta` files as ordinary text. Prefer scene-process, prefab, and asset-db operations that preserve UUID references and editor import state.
|
||||
- If `edit_prefab_json` is used, target a verified prefab path and the smallest exact JSON path or literal replacement, then run `validate_prefab_references` and inspect the result.
|
||||
- Before structural prefab work, call `inspect_prefab`; for scene instances, call `inspect_prefab_instance` and choose deliberately between apply and revert.
|
||||
- Replacing a prefab at the same path can keep the asset UUID while changing internal object IDs and breaking serialized references, animation tracks, nested prefab links, and scene overrides.
|
||||
- Inspect dependencies with `inspect_asset_dependencies` and validate them with `validate_asset_dependencies` before and after sensitive asset changes.
|
||||
- Never copy a `.meta` file when duplicating an asset. Use `duplicate_prefab` or asset-db operations so the new asset receives its own UUID.
|
||||
|
||||
## Tool Exposure and Execution Contexts
|
||||
|
||||
- The default `core` profile exposes the main inspection, diagnostics, logs, screenshots, scene, asset, and unified JavaScript workflow.
|
||||
- The `full` profile adds focused mutation tools for nodes, components, prefabs, UI, runtime control, input simulation, files, and project preview.
|
||||
- If a named tool is unavailable under a custom profile, adapt to the exposed catalog and report the missing capability instead of pretending it ran.
|
||||
- In scene context, use the Cocos runtime and scene APIs for live hierarchy and component work. In editor context, use `Editor` APIs and messages for asset-db and extension orchestration.
|
||||
- Use `execute_scene_script` and `execute_editor_script` only as compatibility entrypoints; prefer `execute_javascript` with an explicit context for new workflows.
|
||||
|
||||
## Script and Asset Validation
|
||||
|
||||
- After external script changes, refresh the affected asset or `db://assets`, then run TypeScript no-emit diagnostics. Use diagnostic context to read focused source snippets before repairing errors.
|
||||
- Cocos import and compilation are asynchronous. After refresh, re-query diagnostics, logs, or asset info instead of assuming the first request observed the final state.
|
||||
- Read `get_recent_logs` or `search_project_logs` for import, serialization, preview, and runtime failures. Do not clear persistent project logs without explicit confirmation.
|
||||
- Use `validate_scene` as a compact final pass, not as a replacement for targeted readback of the values changed.
|
||||
|
||||
## Preview and Runtime Verification
|
||||
|
||||
- Query `get_preview_mode` before changing preview behavior. Use `run_project_preview` only when preview execution is needed and distinguish browser `localUrl` from a LAN `networkUrl`.
|
||||
- Use `get_runtime_state` for pause, frame, and time-scale state; use focused runtime or component methods only when runtime behavior must be exercised.
|
||||
- Use `capture_scene_screenshot` for scene-side composition, `capture_preview_screenshot` for game output, and `capture_editor_screenshot` for editor UI or extension panels.
|
||||
- When low-level input is needed, list editor windows first and target the preview or simulator deliberately. Prefer semantic button events when available.
|
||||
- Restore temporary runtime state such as pause or time scale before finishing unless the user explicitly wants it left changed.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- If MCP is unreachable, limit claims to safe filesystem inspection or code edits; do not claim scene, prefab, editor, preview, or runtime verification.
|
||||
- If a node lookup is ambiguous, return the matching paths and UUIDs and choose only after identifying the user-visible or prefab-owned target.
|
||||
- If editor readback and serialized text disagree, trust editor and asset-db readback first and investigate whether the wrong asset, scene instance, or stale import was inspected.
|
||||
- Fix diagnostics or new error logs caused by the change before visual or runtime validation.
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skillId": "cocos-ui-composition",
|
||||
"skillName": "funplay-cocos-ui-composition",
|
||||
"templateVersion": 1,
|
||||
"templateHash": "835f4c4b6e6dc79cabf4e5048ac056b6f2441b2b77c0c555ca502740cc175e72",
|
||||
"installedHash": "835f4c4b6e6dc79cabf4e5048ac056b6f2441b2b77c0c555ca502740cc175e72",
|
||||
"extensionVersion": "0.5.1",
|
||||
"updatedAt": "2026-08-31T22:41:37.608Z"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: funplay-cocos-ui-composition
|
||||
description: "Build and revise responsive Cocos Creator UI for mobile, desktop, and web, including portrait and landscape layouts, safe areas, prefabs, Widget and Layout behavior, scrolling, text, input, animation, and performance validation."
|
||||
---
|
||||
|
||||
# Funplay Cocos UI Composition
|
||||
|
||||
## Instructions
|
||||
|
||||
## Operating Loop
|
||||
|
||||
1. Inspect before editing.
|
||||
- Confirm the active scene, Canvas, design resolution, Fit Width/Fit Height policy, target orientations, SafeArea usage, and relevant prefab asset paths.
|
||||
- Inspect the existing hierarchy, `UITransform` sizes and anchor points, `Widget` constraints and align modes, `Layout` ownership, sibling order, render order, serialized references, animation targets, and prefab instance state.
|
||||
- Treat screenshots and design coordinates as visual intent, not as permission to replace a working hierarchy.
|
||||
2. Classify each region.
|
||||
- Mark art as full-bleed or safe-area content.
|
||||
- Mark placement as edge-aligned by Widget, stretched between edges, content-sized by Layout, repeated content, scrollable content, modal, overlay, or world-space UI.
|
||||
- Decide which system owns position and size. Avoid letting Widget, Layout, animation, and manual code fight over the same property.
|
||||
3. Make the smallest coherent change.
|
||||
- Preserve the prefab root, existing children, components, names, UUID-backed references, animation tracks, nested prefab links, and scene overrides unless a specific replacement is required.
|
||||
- Modify only the necessary nodes, components, fields, and children; do not rebuild the entire prefab unless explicitly requested.
|
||||
- Prefer Cocos MCP scene, prefab, and asset-db workflows over generic text replacement for serialized assets.
|
||||
4. Read back and validate.
|
||||
- Read exact hierarchy, node UUIDs, `UITransform` sizes and anchors, Widget edges, Layout settings, sprites, labels, opacity, input blockers, event bindings, and prefab ownership back from Cocos.
|
||||
- Test layout, input, safe area, localization, animation interruption, close/reopen state, and runtime data changes.
|
||||
- Capture preview screenshots at representative aspect ratios. Use native builds on representative devices before claiming device performance or platform validation.
|
||||
|
||||
## Component Selection
|
||||
|
||||
| Component | Use it for | Configure deliberately | Avoid |
|
||||
| --- | --- | --- | --- |
|
||||
| `Canvas` | Root 2D/UI render space and design-resolution adaptation | Verify project design resolution, Fit Width/Fit Height, camera and layer behavior | Adding duplicate Canvases only to organize folders or assuming one resolution fits every aspect ratio |
|
||||
| `UITransform` | UI size, anchor point, coordinate conversion, hit testing, and render priority | Set `contentSize` and anchor point intentionally; keep scale for visual effects rather than basic layout | Treating one screenshot position as universal or changing scale when size/Widget constraints should change |
|
||||
| `Widget` | Edge, center, stretch, and parent-relative alignment | Choose top/bottom/left/right/center constraints and `ONCE`, `ON_WINDOW_RESIZE`, or `ALWAYS` based on runtime needs | Animating properties that an `ALWAYS` Widget rewrites at the end of the frame |
|
||||
| `SafeArea` | Keeping critical controls inside notches and system gesture areas | Put it on the top interaction container; allow full-bleed backgrounds outside it | Applying safe-area insets twice or placing important controls outside the safe rectangle |
|
||||
| `Layout` | Horizontal, vertical, or grid arrangement and container/child resizing | Choose type, resize mode, padding, spacing, constraint, and start axis; call `updateLayout` only when same-frame measurement is necessary | Putting Layout and Widget on the same node or manually positioning children driven by Layout |
|
||||
| `Sprite` | Icons, panels, progress fills, and sliced or tiled UI art | Use sliced frames for scalable borders, preserve SpriteFrame references, and choose fill/type intentionally | Stretching bordered art as a simple Sprite or leaving decorative nodes interactive |
|
||||
| `Label` / `RichText` | Localized text and formatted text | Set fonts, fallback coverage, alignment, wrapping, line height, overflow, and cache mode from actual update frequency | Broad `SHRINK` use on frequently changing labels or shipping without required CJK and symbol glyphs |
|
||||
| `ScrollView` + `Mask` | Content larger than a viewport | Use a dedicated view/mask and content node, enable only required axes, and configure inertia, brake, bounce, and nested input deliberately | Combining Widget and Layout on the same Content node, or creating thousands of live rows without pooling |
|
||||
| `Mask` | Rectangular, ellipse, graphics, or sprite-stencil clipping | Match the mask type to the visual requirement and keep renderer constraints in mind | Adding Sprite or Label renderers to a Mask node where Cocos requires the mask-owned Graphics/Sprite |
|
||||
| `Button`, `Toggle`, `Slider`, `EditBox` | Semantic interaction | Verify transition state, target node, event handlers, hit area, keyboard/focus behavior, and disabled state | Duplicate event bindings, tiny touch targets, or visual-only disabled states |
|
||||
| `UIOpacity` | Fading a UI subtree | Coordinate opacity with active state and input blocking | Hiding a modal visually while it still receives or blocks input |
|
||||
| `BlockInputEvents` | Preventing pointer/touch events from passing through overlays and modals | Put it on the intended blocking region and validate sibling order and active state | Assuming a visible scrim blocks gameplay input by itself |
|
||||
| `PageView` | Paged horizontal or vertical content | Recompute page and content sizes when the viewport changes and test drag thresholds | Hard-coding page positions from one device width |
|
||||
|
||||
## Design Resolution, Widget, and Safe Area
|
||||
|
||||
- Treat the design resolution as the coordinate baseline, not a physical-device whitelist. Verify the project Fit Width/Fit Height policy and any runtime `view.setDesignResolutionSize` policy before changing layout.
|
||||
- Use Widget to express attachment and stretch. Use `ON_WINDOW_RESIZE` for resizable desktop/web or large-screen layouts, and use `ALWAYS` only when continuous alignment is worth its property ownership cost.
|
||||
- When Widget owns an edge or size, change the Widget offsets or align mode rather than writing a Node position or UITransform size that will be overwritten later.
|
||||
- Keep UI scale at one for ordinary layout. Resize through `UITransform.contentSize`, Widget constraints, Layout properties, or a deliberate design-resolution policy.
|
||||
- Put decorative backgrounds outside the SafeArea interaction root so they can bleed to physical edges. Put buttons, labels, navigation, and other critical content under SafeArea.
|
||||
- SafeArea already obtains `sys.getSafeAreaRect` and adjusts through Widget. Do not add a second manual inset unless the project has a documented additional margin.
|
||||
- Re-evaluate layout on window resize, orientation change, or safe-area change when the target platform can change dimensions at runtime.
|
||||
|
||||
## Portrait and Landscape Patterns
|
||||
|
||||
- For portrait screens, organize persistent UI into Top, flexible Center, and Bottom regions. Let tall screens expand the center instead of multiplying every vertical coordinate by aspect ratio.
|
||||
- For landscape screens, use Left, Center, Right, and stable corner regions. Verify 16:9, ultrawide, 16:10, and 4:3 instead of treating landscape as one shape.
|
||||
- Separate camera/world composition from Canvas UI adaptation. A correct Widget layout does not prove the gameplay camera shows the intended world area.
|
||||
- Use alternate art or deliberate cover/crop behavior when one background cannot preserve composition across phone, tablet, and desktop aspect ratios.
|
||||
- Reposition only regions whose composition genuinely changes. Do not fork or rebuild the entire screen prefab for a few aspect-dependent offsets.
|
||||
|
||||
## Auto Layout and Dynamic Content
|
||||
|
||||
- A Layout component drives its children or container according to `ResizeMode`. Do not manually write the driven dimensions and expect them to survive the next layout update.
|
||||
- For `ResizeMode.CHILDREN`, verify the container size and the resulting child sizes. For `ResizeMode.CONTAINER`, verify anchor point and growth direction so the container expands predictably.
|
||||
- Grid Layout uses its configured cell and constraint policy. Use fixed rows or columns deliberately and do not expect heterogeneous child preferred sizes to define every cell.
|
||||
- Runtime Layout property changes normally settle on the next frame. Call `updateLayout` only when code must read the final result in the same operation.
|
||||
- Keep nested Layout chains shallow, batch data changes, pool repeated items, and avoid rebuilding a large hierarchy for every small model update.
|
||||
|
||||
## Sprites, Text, Scrolling, and Input
|
||||
|
||||
- Use sliced SpriteFrames for scalable bordered panels and buttons. Preserve caps/insets and inspect the actual SpriteFrame reference after prefab edits.
|
||||
- For Label, choose `CLAMP`, `SHRINK`, or `RESIZE_HEIGHT` from a documented overflow policy. `SHRINK` can cost more CPU when text updates; `RESIZE_HEIGHT` transfers height ownership to the label.
|
||||
- Choose Label cache mode from content behavior: avoid caching assumptions for highly dynamic text, and verify character coverage and atlas capacity for localized content.
|
||||
- Structure a ScrollView as root, masked view, and content. Enable only the needed direction, verify the content reference, and test child-button cancellation and nested scroll behavior.
|
||||
- Keep one intentional interactive target per control, bind events once, and validate the serialized target, component, handler, and custom event data.
|
||||
- Give touch controls a project-defined minimum hit area even when the visible art is smaller. Verify with preview input rather than only inspecting dimensions.
|
||||
- Synchronize modal visibility, `UIOpacity`, active state, Button interactability, and `BlockInputEvents` so hidden panels neither receive nor leak input.
|
||||
|
||||
## Animation and Prefab Safety
|
||||
|
||||
- Animate a `Visual` or `Container` child when the root is driven by Widget or Layout. Do not animate a property that layout rewrites every frame.
|
||||
- Stop or cancel an existing tween/animation before replaying it and restore deterministic position, scale, opacity, active state, and input blocking on disable or close.
|
||||
- Use unscaled scheduling or an explicit UI clock when menus and modal transitions must continue while gameplay time scale is zero.
|
||||
- Preserve existing prefab objects by default. Replacing a prefab hierarchy can break internal object IDs, animation tracks, serialized component references, nested prefabs, and scene overrides even when the asset UUID remains stable.
|
||||
- Prefer serialized references and stable semantic names over repeated `getChildByPath` lookups. If a path is required, verify it and fail clearly rather than silently creating an alternate hierarchy.
|
||||
|
||||
## Performance and Validation
|
||||
|
||||
- Profile before restructuring. Common UI costs include excessive nodes, draw-call breaks, masks/stencils, overdraw, dynamic text generation, repeated Layout work, and large unpooled lists.
|
||||
- Preserve render batching by grouping compatible sprites and materials without changing intended sibling/render order. Use dynamic atlas or static batching only after verifying asset eligibility and runtime behavior.
|
||||
- Use `get_performance_snapshot` to compare node, component, UI, depth, and memory-oriented counters before and after material changes.
|
||||
- Validate portrait at 16:9, 19.5:9 or 20:9, a cutout phone, and a portrait tablet. Validate landscape at 16:9, ultrawide, 16:10, 4:3, and both cutout sides.
|
||||
- In every profile, verify full-bleed art, safe interactive content, text overflow and glyphs, scroll bounds, modal blocking, touch targets, animation interruption, and close/reopen state.
|
||||
|
||||
## Official Cocos References
|
||||
|
||||
- [Cocos Creator 3.8 UI system](https://docs.cocos.com/creator/3.8/manual/en/ui-system/)
|
||||
- [UITransform](https://docs.cocos.com/creator/3.8/api/en/class/UITransform) and [Widget](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/widget.html)
|
||||
- [SafeArea](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/safearea.html) and [multi-resolution adaptation](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/engine/multi-resolution.html)
|
||||
- [Layout](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/layout.html) and [ScrollView](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/scrollview.html)
|
||||
- [Label](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/label.html) and [Mask](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/mask.html)
|
||||
- [Node hierarchy and UI rendering order](https://docs.cocos.com/creator/3.8/manual/en/concepts/scene/node-tree.html)
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
[InternetShortcut]
|
||||
URL=https://docs.cocos.com/creator/manual/en/scripting/setup.html#custom-script-template
|
||||
@@ -2,12 +2,12 @@
|
||||
#///////////////////////////
|
||||
# Cocos Creator 3D Project
|
||||
#///////////////////////////
|
||||
library/
|
||||
temp/
|
||||
local/
|
||||
build/
|
||||
profiles/
|
||||
native
|
||||
/library/
|
||||
/temp/
|
||||
/local/
|
||||
/build/
|
||||
/profiles/
|
||||
/native/
|
||||
#//////////////////////////
|
||||
# NPM
|
||||
#//////////////////////////
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "647a7b22-853c-4083-b6e9-c287ccc6babc",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "e4f0c9ce-255e-463b-b907-bec24412c756",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {createBuildIdentity} from './build-identity.ts';
|
||||
import {_decorator,Prefab,SceneAsset,Node,assetManager,AssetManager,TextAsset,js} from 'cc';
|
||||
import versionManifestParser,{type VersionManifest} from './version-manifest.js';
|
||||
import {GameComposition,type CommonRoomView} from '../../scripts/platform-login/GameComposition.ts';
|
||||
import {getGameDefinition,gameBundleName,gameResourcePath,type SubgameEntry} from './game-definitions.ts';
|
||||
import type {GameDefinition} from '../../framework/sdk/contracts/game-definition.ts';
|
||||
const {ccclass,property}=_decorator;
|
||||
/** App composition root: each game assigns its own pre-room page, room view and scene. */
|
||||
@ccclass('SubgameAssets')
|
||||
export class SubgameAssets extends GameComposition {
|
||||
@property gameKey:string='';
|
||||
private loaded:Readonly<{definition:GameDefinition;createRoom:Prefab;room:Prefab}>|null=null;
|
||||
get createRoom(){return this.loaded?.createRoom??null;}
|
||||
get room(){return this.loaded?.room??null;}
|
||||
@property(SceneAsset) roomScene:SceneAsset|null=null;
|
||||
private manifestValue:Readonly<VersionManifest>|null=null;
|
||||
get versionManifest(){return this.manifestValue;}
|
||||
get displayVersion(){if(!this.manifestValue)throw Error('version.xml has not been loaded');return this.manifestValue.version;}
|
||||
async loadBuildIdentity(){
|
||||
await this.loadGameAssets();
|
||||
const key=this.gameKey,path=this.definition.config.versionResource;
|
||||
if(path!==key+'/version')throw Error('Version resource does not match selected game: '+key);
|
||||
const bundleName='version-'+key;
|
||||
const bundle=await new Promise<AssetManager.Bundle>((resolve,reject)=>assetManager.loadBundle(bundleName,(error,result)=>{
|
||||
if(error)reject(Error('Cannot load '+bundleName+': '+error.message));
|
||||
else if(!result)reject(Error('Missing '+bundleName));else resolve(result);
|
||||
}));
|
||||
const asset=await new Promise<TextAsset>((resolve,reject)=>bundle.load(path,TextAsset,(error,result)=>{
|
||||
if(error)reject(Error('Cannot load '+path+'.xml: '+error.message));
|
||||
else if(!result)reject(Error('Missing '+path+'.xml'));else resolve(result);
|
||||
}));
|
||||
try {
|
||||
if(this.gameKey!==key)throw Error('Game selection changed during version.xml load');
|
||||
const manifest=versionManifestParser.parseVersionManifest(asset.text);
|
||||
const identity=createBuildIdentity(manifest);
|
||||
this.manifestValue=manifest;
|
||||
return identity;
|
||||
} finally {bundle.release(path,TextAsset);}
|
||||
}
|
||||
private async loadGameAssets(){
|
||||
const key=this.gameKey;
|
||||
if(this.loaded){if(this.loaded.definition.key!==key)throw Error('Game selection changed after loading');return;}
|
||||
const bundle=await new Promise<AssetManager.Bundle>((resolve,reject)=>assetManager.loadBundle(gameBundleName(key),(error,result)=>{
|
||||
if(error)reject(error);else if(!result)reject(Error('Missing game bundle: '+key));else resolve(result);
|
||||
}));
|
||||
const definition=getGameDefinition(key,name=>js.getClassByName(name) as unknown as SubgameEntry|null);
|
||||
const load=(resource:string)=>new Promise<Prefab>((resolve,reject)=>bundle.load(gameResourcePath(definition.config.assetRoot,resource),Prefab,(error,result)=>{
|
||||
if(error)reject(error);else if(!result)reject(Error('Missing game prefab: '+resource));else resolve(result);
|
||||
}));
|
||||
const [createRoom,room]=await Promise.all([load(definition.resources.createRoom),load(definition.resources.room)]);
|
||||
if(this.gameKey!==key)throw Error('Game selection changed during asset load');
|
||||
this.loaded={definition,createRoom,room};
|
||||
}
|
||||
get definition(){if(!this.loaded)throw Error('Subgame assets have not been loaded');return this.loaded.definition;}
|
||||
resolveRoomView(root:Node):CommonRoomView {
|
||||
const name=this.definition.roomViewClass;
|
||||
const view=root.getComponent(name) as (CommonRoomView|null);
|
||||
if(!view||typeof view.render!=='function'||typeof view.close!=='function'||typeof view.dissolutionReceived!=='function')throw Error(`Room prefab missing valid ${name}`);
|
||||
return view;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "ec19e470-cf36-40e8-b420-e4314e5fbf07",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {resolveIdentity,type ChannelIdentity} from '../../framework/config/identity.ts';
|
||||
import type {VersionManifest} from './version-manifest.js';
|
||||
/** XML has no market field. This application value is for Web testing only. */
|
||||
export const WEB_MARKET_ID=4;
|
||||
export function createBuildIdentity(manifest:VersionManifest):Readonly<ChannelIdentity> {
|
||||
return resolveIdentity([()=>({agentid:manifest.agentid,channelid:manifest.channelid,
|
||||
gameid:manifest.gameid,version:manifest.versionCode,marketid:WEB_MARKET_ID})]);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b1825c2d-fa8a-4bd5-b20d-cad54a388938",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type {GameDefinition} from '../../framework/sdk/contracts/game-definition.ts';
|
||||
|
||||
export interface SubgameEntry {readonly definition:GameDefinition;}
|
||||
export function gameBundleName(key:string):string {
|
||||
if(!/^[A-Za-z0-9_-]+$/.test(key))throw Error('Invalid gameKey: '+key);
|
||||
return 'game-'+key;
|
||||
}
|
||||
/** Resolve only after the selected bundle has registered its entry class. */
|
||||
export function getGameDefinition(key:string,findEntry:(name:string)=>SubgameEntry|null):GameDefinition {
|
||||
gameBundleName(key);
|
||||
const entry=findEntry('GameEntry_'+key);
|
||||
if(!entry?.definition)throw Error('Missing subgame entry: '+key);
|
||||
if(entry.definition.key!==key)throw Error('Subgame entry key mismatch: '+key);
|
||||
return entry.definition;
|
||||
}
|
||||
export function gameResourcePath(root:string,resource:string):string {
|
||||
if(!/^games\/[A-Za-z0-9_-]+$/.test(root)||!resource.startsWith(root+'/')||!resource.endsWith('.prefab')||resource.split('/').some(part=>!part||part==='.'||part==='..')||resource.includes('\\'))throw Error('Invalid private game resource: '+resource);
|
||||
return resource.slice(root.length+1,-'.prefab'.length);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "8d097469-ffe1-48e5-9e8f-eb8d7c6b8774",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface VersionManifest {
|
||||
readonly agentid:string;readonly agentName:string;readonly channelid:string;readonly channelName:string;
|
||||
readonly gameid:string;readonly gameName:string;readonly versionCode:number;readonly version:string;
|
||||
}
|
||||
export function parseVersionManifest(xml:string):Readonly<VersionManifest>;
|
||||
declare const parser:{parseVersionManifest:typeof parseVersionManifest};
|
||||
export default parser;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4bbb1366-4f50-4d7b-9a67-2f163b4c20c1",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use strict';
|
||||
// Shared by Cocos runtime and the build extension: one parser for the native contract.
|
||||
const {DOMParser}=require('@xmldom/xmldom');
|
||||
function parseVersionManifest(xml){
|
||||
const fail=message=>{throw Error('version.xml: '+message);};
|
||||
if(typeof xml!=='string'||!xml.trim())fail('empty document');
|
||||
if(/<!DOCTYPE|<!ENTITY/i.test(xml))fail('DTD and entity declarations are unsupported');
|
||||
let doc;
|
||||
try {doc=new DOMParser({onError:(_level,message)=>fail(message)}).parseFromString(xml,'text/xml');}
|
||||
catch(error){fail(error.message);}
|
||||
const root=doc.documentElement;
|
||||
if(!root||root.tagName!=='game'||root.namespaceURI)fail('expected game root');
|
||||
const fields={};
|
||||
for(let child=root.firstChild;child;child=child.nextSibling){
|
||||
if(child.nodeType===3&&!child.nodeValue.trim()||child.nodeType===8)continue;
|
||||
if(child.nodeType!==1||!['agent','game','channel','version'].includes(child.tagName)||child.namespaceURI)fail('unexpected child');
|
||||
if(fields[child.tagName])fail('duplicate '+child.tagName);
|
||||
fields[child.tagName]=child;
|
||||
for(let nested=child.firstChild;nested;nested=nested.nextSibling){if(nested.nodeType!==3||nested.nodeValue.trim())fail('unexpected nested content');}
|
||||
}
|
||||
const attr=(tag,name)=>{const node=fields[tag];if(!node)fail('missing '+tag);const value=node.getAttribute(name);if(value===null||!value.trim())fail('missing '+tag+'@'+name);return value;};
|
||||
const code=attr('version','value');
|
||||
if(!/^\d+$/.test(code)||!Number.isSafeInteger(Number(code)))fail('version@value must be a nonnegative safe integer');
|
||||
return Object.freeze({agentid:attr('agent','id'),agentName:attr('agent','name'),
|
||||
gameid:attr('game','id'),gameName:attr('game','name'),channelid:attr('channel','id'),channelName:attr('channel','name'),
|
||||
versionCode:Number(code),version:attr('version','name')});
|
||||
}
|
||||
exports.parseVersionManifest=parseVersionManifest;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "javascript",
|
||||
"imported": true,
|
||||
"uuid": "2de3f49a-c136-4137-9635-55a8598c867c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -1,6 +1,82 @@
|
||||
# framework — 框架真源(唯一权威副本)
|
||||
|
||||
各子游戏工程的 `assets/framework` 是指向**本目录**的 junction(见 `scripts/setup-links.mjs`)。
|
||||
六层结构(依赖严格单向,详见 spec §3):core / net / protocol / platform / ui / sdk。
|
||||
协议相关实现以 `docs/protocol/` 为权威,远程配置与原生接口也必须复刻原工程。服务器、配置服务和原生侧均零改动。
|
||||
|
||||
协议相关实现一律以 `docs/protocol/` 为唯一信息源(spec §0.1),不在代码或注释中内联复制协议字段。
|
||||
## 现代运行时的唯一组合路径
|
||||
|
||||
```text
|
||||
game CompositionRoot -> bootstrapPlatform(single GameEntry)
|
||||
game implementation -> framework/sdk only
|
||||
WireClient -> PlatformRuntime -> Router exactly once
|
||||
PlatformStore -> selectors -> GameHost/UI read-only consumers
|
||||
```
|
||||
|
||||
这里的 `bootstrapPlatform(single GameEntry)` 是组合设计记号,当前没有同名函数。
|
||||
已实现的构造入口是 `new PlatformRuntime(options)`:`gameEntry` 由构建期固定提供一个,
|
||||
`resolveRuntimeConfig` 使用 `config/runtime-config.ts` 的解析器,
|
||||
`createWireClient(config)` 用最终 `config.servers` 构造 `WireClient`,注入实际 Transport 工厂。
|
||||
组合方还提供 `ScenePort`、资源加载、最短展示等待和登录设备快照能力,然后调用 `start()`;
|
||||
界面取得账号后显式调用 `login(account)`,socket open 本身不会发送登录。
|
||||
`integration/platform-vertical-slice.test.ts` 展示了这一组合的完整无界面回放。
|
||||
|
||||
Runtime 内部创建唯一 Router、RuntimeSession、PlatformStore、平台命令和 GameSessionHost。
|
||||
游戏实现只通过 `framework/sdk/index.ts` 导入公开契约;该入口只重导出 `sdk/contracts`,
|
||||
不再公开旧 Store、EventBus 或任意路由发包能力。SDK contracts 不导入内部框架模块或 `cc`;
|
||||
SDK runtime 也不导入框架实现层,平台适配由 `platform/game-host-adapter.ts` 承担。
|
||||
|
||||
不提供 GameRegistry、运行时 API 协商、多个游戏的包、游戏直接访问 Store/EventBus 的路径。
|
||||
每次房间会话创建新的 GameModule 和 Host lease,旧 lease 不能读取或操纵新房间。
|
||||
平台先原子提交房间和玩家快照,再创建游戏、发布平台事件;truthy `deskinfo` 在房间场景就绪后
|
||||
按原对象引用交给 `restore`,不进入 PlatformStore,falsey/缺失值不触发恢复。
|
||||
连接事件也提交到同一个 Store;重连只更换 app 连接状态,保留 room/players 引用。
|
||||
切服先发送原始 `connect_*server` 信封,进房成功再原子标记已登录;普通重连才重新发送已保存的登录信封。
|
||||
|
||||
登录、进房的非零 `state` 通过必需的 `ScenePort.showServerDenial({ rpc, state, data })`
|
||||
报告;`data` 保留服务器原对象,消费方依据 `showerror`、`error`、`roomcode` 等原字段处理。
|
||||
这类结果不提交成功状态、不关闭连接,调用方可重试;成功回包缺少必需字段仍是显式错误。
|
||||
|
||||
`GameEntry.resolveSeatCount` 只接收真实房间的 `roomtype`,预检只检查该方法存在;
|
||||
GameHost 在进房边界验证返回值是正整数并与服务器座位数组长度相同。
|
||||
`assertGameContract(entry, samples, assert)` 的 `assert` 由测试调用方从 `node:assert/strict`
|
||||
显式导入并传入;harness 仅引用断言类型,避免 Creator 加载脚本时解析 Node 内置模块。
|
||||
每个样本由游戏提供 `roomtype`、`expectedSeatCount`
|
||||
和对应的 `makeHost` 工厂,不要求游戏支持框架虚构的房间配置。
|
||||
|
||||
远程地址唯一取自 `data.urlserver`。配置请求使用空 body 的 POST,沿用无条件 `?` 缓存参数拼接。
|
||||
原生 settings 方法名称、调用次序和 WVJB 初始化/handler 名称由 `config/sources`、
|
||||
`adapters/native` 实现,`framework-tests/config` 与 `framework-tests/native` 验证。
|
||||
|
||||
## 仍被 Cocos 脚本使用的兼容隔离区
|
||||
|
||||
以下是 `LEGACY_RUNTIME` 的完整文件清单,路径相对本目录;现代框架文件禁止直接或传递依赖它们:
|
||||
|
||||
- `net/net-client.ts`
|
||||
- `platform/session.ts`
|
||||
- `platform/startup.ts`
|
||||
- `platform/room-rpc-bus.ts`
|
||||
- `platform/readonly.ts`
|
||||
- `platform/stores/app-store.ts`
|
||||
- `platform/stores/player-store.ts`
|
||||
- `platform/stores/room-store.ts`
|
||||
- `platform/stores/types.ts`
|
||||
- `protocol/room-handlers.ts`
|
||||
|
||||
当前 `YouleNexus/assets/scripts/LoginFlow.ts`、`RoomEventProbe.ts`、`RoomSceneStart.ts`
|
||||
仍直接引用上述兼容文件。因此它们继续保留;阶段 3 必须由 Presenter 和真正的游戏 Composition Root
|
||||
接管这些脚本的调用后,阶段 6 才能删除兼容区。当前无界面现代回放通过,不表示 Cocos UI 已接入现代运行时。
|
||||
|
||||
保留的兼容逻辑测试(相对 `framework-tests/`)是 `net/net-client.test.ts`、
|
||||
`platform/app-store.test.ts`、`platform/player-store.test.ts`、`platform/room-store.test.ts`、
|
||||
`platform/session.test.ts`、`platform/startup.test.ts`、`platform/room-rpc-bus.test.ts`、
|
||||
`protocol/room-handlers.test.ts`。它们测试旧脚本仍需使用的行为,不属于现代运行时的依赖图。
|
||||
旧 SDK 的完整标识符盘点仅允许 `architecture/import-boundaries.test.mjs` 中的负例,
|
||||
以及 `protocol/room-handlers.ts` 内一处历史说明。名称子串 `requireActiveGame` 是现代处理器的局部状态检查方法,
|
||||
不是已移除的旧类型;自动盘点按完整标识符匹配。
|
||||
|
||||
`scripts/check-import-boundaries.mjs` 不再给 SDK 入口任何内部依赖豁免,也不给旧活动游戏文件名称检查豁免。
|
||||
`framework-tests/architecture/import-boundaries.test.mjs` 对全部现代框架 TypeScript 文件检查传递依赖,
|
||||
并验证重导出、别名、动态导入、CommonJS 和 import type 的负例;无法静态判断的传递加载直接拒绝。
|
||||
|
||||
阶段 3 的 Presenter/Composition Root 接管、theme/Prefab binding、首个真实子游戏迁移及 ZIP 发布均属于后续独立计划。
|
||||
本次逻辑验收不改动 Cocos 序列化资源或现有 UI 迁移产物。
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "aea92450-9c09-40ed-8a4d-5cced7a56b3b",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "c1b8189d-1134-4ff3-9426-d2668fd9ab6f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { assertLocalStartupContext, type LocalStartupContext } from '../../config/local-startup.ts';
|
||||
|
||||
export interface LocalStoragePort {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export const LOCAL_MACHINE_KEY = 'youle.local-test.machine.v1';
|
||||
|
||||
export function sampleLocalRandom(
|
||||
random: () => number,
|
||||
failure: (reason: string, cause?: unknown) => Error,
|
||||
): number {
|
||||
let value: number;
|
||||
try { value = random(); } catch (cause) { throw failure('source failed', cause); }
|
||||
if (!Number.isFinite(value)) throw failure('expected finite number');
|
||||
if (value < 0 || value >= 1) throw failure('expected range [0, 1)');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function localAccountKey(context: LocalStartupContext): string {
|
||||
assertLocalStartupContext(context);
|
||||
const { identity } = context.config;
|
||||
const scope = JSON.stringify([
|
||||
context.profile,
|
||||
context.config.servers,
|
||||
identity.agentid,
|
||||
identity.gameid,
|
||||
identity.channelid,
|
||||
identity.marketid,
|
||||
]);
|
||||
return `youle.local-test.account.v1:${scope}`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "0693ece5-bed1-4b21-8ae7-7393c2eee8a7",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { LocalStartupContext } from '../../config/local-startup.ts';
|
||||
import type { LoginAccountIdentity, LoginDeviceSnapshot } from '../../protocol/contracts/index.ts';
|
||||
import { LOCAL_MACHINE_KEY, sampleLocalRandom, type LocalStoragePort } from './local-storage.ts';
|
||||
import { createLocalVisitorProvider } from './visitor-account.ts';
|
||||
|
||||
export interface LocalHostSnapshot {
|
||||
readonly location: unknown;
|
||||
readonly returnCitySN?: unknown;
|
||||
}
|
||||
|
||||
export interface LocalLoginSources {
|
||||
prepareLogin(): Promise<LoginAccountIdentity>;
|
||||
getLoginDeviceSnapshot(): LoginDeviceSnapshot;
|
||||
resetAccount(): Promise<LoginAccountIdentity>;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
readonly context: LocalStartupContext;
|
||||
readonly storage: LocalStoragePort;
|
||||
readonly random: () => number;
|
||||
readonly now: () => number;
|
||||
readonly sampleHost: () => LocalHostSnapshot;
|
||||
readonly isSessionActive: () => boolean;
|
||||
}
|
||||
|
||||
function machineFailure(path: string, reason: string, cause?: unknown): Error {
|
||||
const error = new Error(`Invalid local machine cache ${LOCAL_MACHINE_KEY} at ${path}: ${reason}`) as Error & { cause?: unknown };
|
||||
if (cause !== undefined) error.cause = cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getMachineId(options: Options): string {
|
||||
let raw: string | null;
|
||||
try { raw = options.storage.getItem(LOCAL_MACHINE_KEY); }
|
||||
catch (cause) { throw machineFailure('$', 'storage read failed', cause); }
|
||||
if (raw !== null) {
|
||||
let record: unknown;
|
||||
try { record = JSON.parse(raw); }
|
||||
catch (cause) { throw machineFailure('$', 'invalid JSON', cause); }
|
||||
if (!isRecord(record)) throw machineFailure('$', 'expected object');
|
||||
if (record.schemaVersion !== 1) throw machineFailure('schemaVersion', 'expected 1');
|
||||
for (const field of Object.keys(record)) {
|
||||
if (field !== 'schemaVersion' && field !== 'machineid') throw machineFailure(field, 'unknown field');
|
||||
}
|
||||
if (typeof record.machineid !== 'string') throw machineFailure('machineid', 'expected string');
|
||||
const match = /^(0|[1-9][0-9]*)-([1-9][0-9]{3})$/.exec(record.machineid);
|
||||
if (!match || !Number.isSafeInteger(Number(match[1])) || Number(match[2]) > 9998) {
|
||||
throw machineFailure('machineid', 'expected nonnegative safe timestamp and suffix 1000..9998');
|
||||
}
|
||||
return record.machineid;
|
||||
}
|
||||
let timestamp: number;
|
||||
try { timestamp = options.now(); }
|
||||
catch (cause) { throw machineFailure('now', 'source failed', cause); }
|
||||
if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw machineFailure('now', 'expected nonnegative safe integer');
|
||||
const random = sampleLocalRandom(options.random, (reason, cause) => machineFailure('random', reason, cause));
|
||||
const machineid = `${timestamp}-${1000 + Math.floor(random * 8999)}`;
|
||||
try { options.storage.setItem(LOCAL_MACHINE_KEY, JSON.stringify({ schemaVersion: 1, machineid })); }
|
||||
catch (cause) { throw machineFailure('$', 'storage write failed', cause); }
|
||||
return machineid;
|
||||
}
|
||||
|
||||
export function createLocalLoginSources(options: Options): LocalLoginSources {
|
||||
const provider = createLocalVisitorProvider(options);
|
||||
let device: LoginDeviceSnapshot | undefined;
|
||||
let preparing: Promise<LoginAccountIdentity> | undefined;
|
||||
let resetting: Promise<LoginAccountIdentity> | undefined;
|
||||
|
||||
function prepareLogin(): Promise<LoginAccountIdentity> {
|
||||
if (resetting) return Promise.reject(new Error('Cannot prepare login while reset is pending'));
|
||||
if (preparing) return preparing;
|
||||
device = undefined;
|
||||
preparing = (async () => {
|
||||
const cachedAccount = await provider.getAccount();
|
||||
const machineid = getMachineId(options);
|
||||
const snapshot = options.sampleHost();
|
||||
if (!isRecord(snapshot) || !Object.prototype.hasOwnProperty.call(snapshot, 'location')
|
||||
|| (snapshot.location !== null && !isRecord(snapshot.location))) {
|
||||
throw new Error('Invalid local host location: expected explicit null or record');
|
||||
}
|
||||
const city = snapshot.returnCitySN;
|
||||
if (city !== undefined) {
|
||||
if (!isRecord(city)) throw new Error('Invalid local host returnCitySN: expected record');
|
||||
for (const field of ['ip', 'province', 'city'] as const) {
|
||||
if (typeof city[field] !== 'string') throw new Error(`Invalid local host returnCitySN.${field}: expected string`);
|
||||
}
|
||||
}
|
||||
const account: LoginAccountIdentity = Object.freeze({ ...cachedAccount,
|
||||
...(city === undefined ? {} : { province: city.province as string, city: city.city as string }),
|
||||
});
|
||||
const nextDevice: LoginDeviceSnapshot = Object.freeze({ location: snapshot.location,
|
||||
machineid, machineroom: '', deviceLogin: Object.freeze({ enabled: false as const }),
|
||||
loginPlayerId: Object.freeze({ enabled: false as const }),
|
||||
...(city === undefined ? {} : { ip: city.ip as string }),
|
||||
});
|
||||
device = nextDevice;
|
||||
return account;
|
||||
})().finally(() => { preparing = undefined; });
|
||||
return preparing;
|
||||
}
|
||||
|
||||
function resetAccount(): Promise<LoginAccountIdentity> {
|
||||
device = undefined;
|
||||
if (preparing) return Promise.reject(new Error('Cannot reset account while prepare is pending'));
|
||||
if (resetting) return Promise.reject(new Error('Cannot reset account while reset is pending'));
|
||||
resetting = provider.resetAccount().finally(() => { resetting = undefined; });
|
||||
return resetting;
|
||||
}
|
||||
|
||||
function getLoginDeviceSnapshot(): LoginDeviceSnapshot {
|
||||
if (device === undefined) throw new Error('Local login device requires a successful prepareLogin');
|
||||
return device;
|
||||
}
|
||||
|
||||
return Object.freeze({ prepareLogin, resetAccount, getLoginDeviceSnapshot });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4deb8dec-916d-4dda-b709-ac5f38fa0253",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { LoginAccountIdentity } from '../../protocol/contracts/index.ts';
|
||||
import { assertLocalStartupContext, type LocalStartupContext } from '../../config/local-startup.ts';
|
||||
import { localAccountKey, sampleLocalRandom, type LocalStoragePort } from './local-storage.ts';
|
||||
|
||||
const AVATARS = Object.freeze([
|
||||
'https://img2.baidu.com/it/u=1823726962,2692568839&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
'https://img0.baidu.com/it/u=320875415,2231067789&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
|
||||
'https://img1.baidu.com/it/u=3407774307,2859661062&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
'https://img2.baidu.com/it/u=1161398577,3592656327&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
|
||||
'https://img1.baidu.com/it/u=2061657615,4194426181&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=501',
|
||||
'https://img2.baidu.com/it/u=3081347414,2363887197&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
'https://img1.baidu.com/it/u=1364384963,3982203144&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
|
||||
'https://img1.baidu.com/it/u=3041194995,2386060237&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
|
||||
'https://img2.baidu.com/it/u=2904976445,2884058010&fm=253&fmt=auto&app=120&f=JPEG?w=529&h=500',
|
||||
'https://img0.baidu.com/it/u=1801052125,1596537112&fm=253&fmt=auto&app=120&f=JPEG?w=529&h=500',
|
||||
] as const);
|
||||
|
||||
export interface LocalVisitorProvider {
|
||||
getAccount(): Promise<LoginAccountIdentity>;
|
||||
resetAccount(): Promise<LoginAccountIdentity>;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
readonly context: LocalStartupContext;
|
||||
readonly storage: LocalStoragePort;
|
||||
readonly random: () => number;
|
||||
readonly isSessionActive: () => boolean;
|
||||
}
|
||||
|
||||
const ACCOUNT_FIELDS = Object.freeze(['openid', 'nickname', 'avatar', 'sex', 'province', 'city', 'unionid'] as const);
|
||||
|
||||
function wrap(key: string, path: string, reason: string, cause?: unknown): Error {
|
||||
const error = new Error(`Invalid local account cache ${key} at ${path}: ${reason}`) as Error & { cause?: unknown };
|
||||
if (cause !== undefined) error.cause = cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
function exactRecord(value: unknown, key: string, path: string, fields: readonly string[]): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw wrap(key, path, 'expected object');
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const field of fields) {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, field)) throw wrap(key, `${path}.${field}`, 'missing field');
|
||||
}
|
||||
for (const field of Object.keys(record)) if (!fields.includes(field)) throw wrap(key, `${path}.${field}`, 'unknown field');
|
||||
return record;
|
||||
}
|
||||
|
||||
function validateAccount(value: unknown, key: string): LoginAccountIdentity {
|
||||
const account = exactRecord(value, key, 'account', ACCOUNT_FIELDS);
|
||||
for (const field of ['openid', 'nickname', 'avatar', 'province', 'city', 'unionid'] as const) {
|
||||
if (typeof account[field] !== 'string') throw wrap(key, `account.${field}`, 'expected string');
|
||||
}
|
||||
if (account.openid === '') throw wrap(key, 'account.openid', 'must not be empty');
|
||||
if (account.unionid === '') throw wrap(key, 'account.unionid', 'must not be empty');
|
||||
if (!Number.isInteger(account.sex)) throw wrap(key, 'account.sex', 'expected integer');
|
||||
if (account.sex !== 1 && account.sex !== 2) throw wrap(key, 'account.sex', 'expected 1 or 2');
|
||||
if (account.nickname === '' && account.avatar === '') throw wrap(key, 'account.nickname/avatar', 'must not both be empty');
|
||||
return Object.freeze(account as unknown as LoginAccountIdentity);
|
||||
}
|
||||
|
||||
function parseCached(raw: string, key: string): LoginAccountIdentity {
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(raw); } catch (cause) { throw wrap(key, '$', 'invalid JSON', cause); }
|
||||
const record = exactRecord(value, key, '$', ['schemaVersion', 'account']);
|
||||
if (record.schemaVersion !== 1) throw wrap(key, 'schemaVersion', 'expected 1');
|
||||
return validateAccount(record.account, key);
|
||||
}
|
||||
|
||||
function sample(random: () => number, key: string): number {
|
||||
return sampleLocalRandom(random, (reason, cause) => wrap(key, 'random', reason, cause));
|
||||
}
|
||||
|
||||
export function generate(random: () => number, key: string): LoginAccountIdentity {
|
||||
const id = Math.floor(sample(random, key) * 100000);
|
||||
const avatar = AVATARS[Math.floor(sample(random, key) * AVATARS.length)]!;
|
||||
const sex = Math.floor(sample(random, key) * 2) + 1;
|
||||
return validateAccount({ openid: `testopenid_${id}`, unionid: `ylgame${id}`,
|
||||
nickname: `${id}_游客`, avatar, sex, province: 'jiangxi', city: 'nanchang' }, key);
|
||||
}
|
||||
|
||||
function storageFailure(key: string, operation: 'read' | 'write', cause: unknown): Error {
|
||||
return wrap(key, '$', `storage ${operation} failed`, cause);
|
||||
}
|
||||
|
||||
export function createLocalVisitorProvider(options: Options): LocalVisitorProvider {
|
||||
assertLocalStartupContext(options.context);
|
||||
const key = localAccountKey(options.context);
|
||||
let pending: Promise<LoginAccountIdentity> | null = null;
|
||||
|
||||
async function getAccount(): Promise<LoginAccountIdentity> {
|
||||
if (pending) return pending;
|
||||
pending = (async () => {
|
||||
let raw: string | null;
|
||||
try { raw = options.storage.getItem(key); } catch (cause) { throw storageFailure(key, 'read', cause); }
|
||||
if (raw !== null) return parseCached(raw, key);
|
||||
const account = generate(options.random, key);
|
||||
try { options.storage.setItem(key, JSON.stringify({ schemaVersion: 1, account })); }
|
||||
catch (cause) { throw storageFailure(key, 'write', cause); }
|
||||
return account;
|
||||
})();
|
||||
try { return await pending; } finally { pending = null; }
|
||||
}
|
||||
|
||||
async function resetAccount(): Promise<LoginAccountIdentity> {
|
||||
if (options.isSessionActive()) throw new Error('Cannot reset local visitor account during an active session');
|
||||
if (pending) throw new Error('Cannot reset local visitor account while getAccount is pending');
|
||||
const account = generate(options.random, key);
|
||||
try { options.storage.setItem(key, JSON.stringify({ schemaVersion: 1, account })); }
|
||||
catch (cause) { throw storageFailure(key, 'write', cause); }
|
||||
return account;
|
||||
}
|
||||
|
||||
return Object.freeze({ getAccount, resetAccount });
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "bb7c9b58-c08f-4dc9-b8d1-57c2b20f9c28",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "8499ef07-6051-4d8f-aca6-11996e448b7f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
type ClipboardHost=
|
||||
|{kind:'settings';read:()=>unknown}
|
||||
|{kind:'bridge';call:(name:'gamepastetext',data:string,callback:(value:unknown)=>void)=>void}
|
||||
|{kind:'browser';read:()=>Promise<string>};
|
||||
|
||||
/** 05_Func: settings takes no arguments; WVJB takes "" and returns through its callback. */
|
||||
export async function readClipboardText(host:ClipboardHost):Promise<string>{
|
||||
const value=host.kind==='bridge'?await new Promise<unknown>((resolve,reject)=>{
|
||||
const timer=setTimeout(()=>reject(Error('读取剪贴板超时,请手动输入')),10000);
|
||||
try{host.call('gamepastetext','',value=>{clearTimeout(timer);resolve(value);});}
|
||||
catch(error){clearTimeout(timer);reject(error);}
|
||||
}):await host.read();
|
||||
if(typeof value!=='string')throw Error('Invalid clipboard text');
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function readInviteClipboard(host:ClipboardHost):Promise<string>{
|
||||
return (await readClipboardText(host)).replace(/[^0-9]/g,'');
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4aeb3de8-f982-4246-95be-7dca058bc99d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { logNativeCall } from '../../core/native-call-log.ts';
|
||||
type BrowserHost={kind:'settings';browser(url:string):void}|{kind:'bridge';call(name:'browser',data:string):void}|{kind:'h5';open(url:string):void};
|
||||
/** 12_Logic AppStart: uAgent_3 uses app_Launchtype, every other host reads Launchtype from the URL. */
|
||||
export function resolveLobbyLaunchMode(search:string,bridgeHost:boolean,appLaunchtype:unknown):0|1 {
|
||||
const value=bridgeHost?appLaunchtype:new URLSearchParams(search).get('Launchtype');
|
||||
const mode=Number(value); // Source URL absence is null and explicitly means from_self (0).
|
||||
if(mode!==0&&mode!==1)throw Error('Invalid Launchtype');
|
||||
return mode;
|
||||
}
|
||||
/** 05_Func.fixUrl + browser. The settings host alone appends timestamp. */
|
||||
export function openLobbyUrl(url:string,host:BrowserHost,now:()=>number):void {
|
||||
if(!url)return;
|
||||
const index=url.indexOf('?');
|
||||
const fixed=index<0?url:url.slice(0,index+1)+url.slice(index+1).replace(/\?/g,'&');
|
||||
if(host.kind==='settings'){const target=`${fixed}×tamp=${now()}`;logNativeCall('settings','browser',[target],true);host.browser(target);}
|
||||
else if(host.kind==='bridge')host.call('browser',fixed);
|
||||
else {logNativeCall('settings','browser',[fixed],false);host.open(fixed);}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "5953d513-a238-4d0f-a3e4-d8c7dbb1e4cb",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { LoginAccountIdentity, DeviceLoginMode } from '../../protocol/contracts/login-contract.ts';
|
||||
import type { LoginPolicy } from '../../presentation/login-policy.ts';
|
||||
import { generate } from '../local/visitor-account.ts';
|
||||
import { logNativeCall } from '../../core/native-call-log.ts';
|
||||
import { readPhoneCode } from './phone-code.ts';
|
||||
|
||||
export interface LoginNativeSettings {
|
||||
accreditlogin(provider: number): void;
|
||||
getcompareCode(): number | string;
|
||||
}
|
||||
interface Options {
|
||||
storage: {getItem(key:string):string|null;setItem(key:string,value:string):void;removeItem(key:string):void};
|
||||
settings?:LoginNativeSettings; random():number; now():number;
|
||||
gameid:string|number;agentid:string|number;policy:LoginPolicy;
|
||||
submit(account:LoginAccountIdentity,device:DeviceLoginMode):void;tip(message:string):void;
|
||||
}
|
||||
|
||||
/** MD5 is the legacy nickname derivation, not a security primitive. */
|
||||
export function nicknameHash(input:string):string {
|
||||
const bytes=Array.from(new TextEncoder().encode(input));const bitLength=bytes.length*8;
|
||||
bytes.push(128);while(bytes.length%64!==56)bytes.push(0);
|
||||
for(let i=0;i<8;i++)bytes.push(i<4?(bitLength>>>(8*i))&255:0);
|
||||
const shifts=[7,12,17,22,5,9,14,20,4,11,16,23,6,10,15,21];
|
||||
let state=[0x67452301,0xefcdab89,0x98badcfe,0x10325476];
|
||||
for(let offset=0;offset<bytes.length;offset+=64){
|
||||
const words=Array.from({length:16},(_,j)=>bytes[offset+j*4]|bytes[offset+j*4+1]<<8|bytes[offset+j*4+2]<<16|bytes[offset+j*4+3]<<24);
|
||||
let [a,b,c,d]=state;
|
||||
for(let i=0;i<64;i++){
|
||||
const round=Math.floor(i/16);let f:number,g:number;
|
||||
if(round===0){f=(b&c)|(~b&d);g=i;}else if(round===1){f=(d&b)|(~d&c);g=(5*i+1)%16;}
|
||||
else if(round===2){f=b^c^d;g=(3*i+5)%16;}else{f=c^(b|~d);g=(7*i)%16;}
|
||||
const sum=(a+f+Math.floor(Math.abs(Math.sin(i+1))*4294967296)+words[g])|0;
|
||||
const shift=shifts[round*4+i%4];const next=(b+((sum<<shift)|(sum>>>(32-shift))))|0;
|
||||
a=d;d=c;c=b;b=next;
|
||||
}
|
||||
state=state.map((v,i)=>(v+[a,b,c,d][i])|0);
|
||||
}
|
||||
return state.map(v=>[0,8,16,24].map(s=>((v>>>s)&255).toString(16).padStart(2,'0')).join('')).join('');
|
||||
}
|
||||
function record(value:unknown):Record<string,unknown>{
|
||||
if(typeof value==='string')value=JSON.parse(value);
|
||||
if(!value||typeof value!=='object'||Array.isArray(value))throw Error('Invalid sharelogin account');
|
||||
return value as Record<string,unknown>;
|
||||
}
|
||||
function accountFrom(value:unknown):LoginAccountIdentity {
|
||||
const data=record(value);
|
||||
for(const field of ['openid','headimgurl','nickname','city','Province'])if(typeof data[field]!=='string')throw Error(`Invalid sharelogin.${field}`);
|
||||
if(typeof data.unionid!=='string'&&typeof data.unionid!=='number')throw Error('Invalid sharelogin.unionid');
|
||||
const sex=data.sex?data.sex:1; // Func.sharelogin explicitly owns this default.
|
||||
if(typeof sex!=='number'||!Number.isFinite(sex))throw Error('Invalid sharelogin.sex');
|
||||
if(!data.nickname&&!data.headimgurl)throw Error('授权失败,请重新授权!');
|
||||
return {openid:data.openid as string,avatar:data.headimgurl as string,nickname:data.nickname as string,
|
||||
city:data.city as string,province:data.Province as string,unionid:data.unionid as string|number,sex};
|
||||
}
|
||||
|
||||
/** Exact native/cached account boundary; UI never invents account or server fields. */
|
||||
/** 账户统一入口:协议确认、缓存选择、原生授权及提交模式均由此处管理。 */
|
||||
export class LoginAccounts {
|
||||
readonly versionState:0|1;
|
||||
private hiddenClicks=0;
|
||||
/** 每次启动默认同意;按钮、缓存登录及原生回调读取同一份状态。 */
|
||||
private agreementAccepted=true;
|
||||
get agreed():boolean{return this.agreementAccepted;}
|
||||
setAgreement(accepted:boolean):void{
|
||||
if(accepted&&!this.agreementAccepted)this.options.tip('');
|
||||
this.agreementAccepted=accepted;
|
||||
}
|
||||
/** 未同意时只提示,不调用授权、不写缓存、不提交请求。 */
|
||||
requireAgreement():boolean{
|
||||
if(this.agreementAccepted)return true;
|
||||
this.options.tip('未同意协议');return false;
|
||||
}
|
||||
constructor(private readonly options:Options){
|
||||
// Browser source explicitly uses the legacy getVersionState default of zero.
|
||||
logNativeCall('settings','getcompareCode',[],!!options.settings);
|
||||
const state=options.settings?Number(options.settings.getcompareCode()):0;
|
||||
if(state!==0&&state!==1)throw Error('Invalid getcompareCode result');this.versionState=state;
|
||||
}
|
||||
private read(key:string,cookie:boolean):Record<string,unknown>|null {
|
||||
if(cookie&&this.versionState===0){
|
||||
const expiry=this.options.storage.getItem('tsgame_validtime');
|
||||
if(expiry===null)return null;
|
||||
if(!Number.isFinite(Number(expiry)))throw Error('Invalid tsgame_validtime');
|
||||
if(this.options.now()>=Number(expiry))return null;
|
||||
}
|
||||
const raw=this.options.storage.getItem(key);return raw===null?null:record(raw);
|
||||
}
|
||||
private save(account:LoginAccountIdentity,key:string,telphone?:string){
|
||||
const data={openid:account.openid,headimgurl:account.avatar,nickname:account.nickname,sex:account.sex,
|
||||
city:account.city,Province:account.province,unionid:account.unionid,...(telphone===undefined?{}:{telphone})};
|
||||
this.options.storage.setItem('tsgame_validtime',String(this.options.now()+48*60*60*1000));
|
||||
this.options.storage.setItem(key,JSON.stringify(data));
|
||||
}
|
||||
/** 保留原工程缓存优先级,游客开关先于手机模式判断。 */
|
||||
cached():LoginAccountIdentity|null {
|
||||
if(this.versionState!==0)return null;
|
||||
const policy=this.options.policy;
|
||||
const data=this.read(policy.showVisitor?'tsgame_visitorinfo':policy.deviceLogin?'tsgame_devicePlayer':'tsgame_wxinfo',true);
|
||||
return data===null?null:accountFrom(data);
|
||||
}
|
||||
autoLogin():void {
|
||||
if(!this.requireAgreement())return;
|
||||
const account=this.cached();if(account===null)throw Error('No cached login');
|
||||
// showVisitor selects the visitor cache above, even when the phone button is enabled.
|
||||
if(!this.options.policy.showVisitor&&this.options.policy.deviceLogin){
|
||||
const data=this.read('tsgame_devicePlayer',true)!;
|
||||
if(typeof data.telphone!=='string')throw Error('Cached device account missing telphone');
|
||||
this.options.submit(account,{enabled:true,telphone:data.telphone});
|
||||
}else this.options.submit(account,{enabled:false});
|
||||
}
|
||||
/** Invalidate all auto-login paths, including visitor precedence, before returning to login. */
|
||||
logout():void {
|
||||
// GameUI case 58 deletes wxinfo/devicePlayer. Keep credential cleanup here
|
||||
// so every caller invalidates the same automatic-login sources.
|
||||
this.options.storage.setItem('tsgame_validtime','0');
|
||||
this.options.storage.removeItem('tsgame_wxinfo');
|
||||
this.options.storage.removeItem('tsgame_devicePlayer');
|
||||
this.options.storage.setItem(`tsgame_playerid_${this.options.gameid}${this.options.agentid}`,'');
|
||||
this.options.storage.setItem('tsgame_dLogin','0');
|
||||
}
|
||||
authorize(provider:'wechat'|'qq'):void {
|
||||
if(!this.requireAgreement())return;
|
||||
logNativeCall('settings','accreditlogin',[provider==='wechat'?2:1],!!this.options.settings);
|
||||
if(!this.options.settings)return;
|
||||
if(provider==='wechat')this.options.storage.setItem(`tsgame_playerid_${this.options.gameid}${this.options.agentid}`,'');
|
||||
this.options.settings.accreditlogin(provider==='wechat'?2:1);
|
||||
}
|
||||
acceptNative(data:unknown,channel:'global'|'bridge'):void {
|
||||
if(!this.requireAgreement())return;
|
||||
const account=accountFrom(data);
|
||||
this.save(account,channel==='global'&&this.options.policy.showVisitor?'tsgame_visitorinfo':'tsgame_wxinfo');
|
||||
this.options.submit(account,{enabled:false});
|
||||
}
|
||||
/** Desk.login saves the server identity whenever sysConfig.deviceLogin is enabled. */
|
||||
loginSucceeded(value:unknown,mode:DeviceLoginMode):void {
|
||||
const data=record(value);
|
||||
if(data.state!==0)throw Error('Cannot cache an unsuccessful login');
|
||||
if(typeof data.playerid!=='string'&&(typeof data.playerid!=='number'||!Number.isFinite(data.playerid)))throw Error('Invalid login playerid');
|
||||
if(this.options.policy.deviceLogin){
|
||||
if((mode.enabled||data.tel!==undefined)&&typeof data.tel!=='string')throw Error('Device login response missing tel');
|
||||
const account=accountFrom({...data,headimgurl:data.avatar,Province:data.province});
|
||||
// Desk.login copies the server sex verbatim; sharelogin normalizes it only on use.
|
||||
if(typeof data.sex!=='number'||!Number.isFinite(data.sex))throw Error('Invalid login sex');
|
||||
this.save({...account,sex:data.sex},'tsgame_devicePlayer',data.tel as string|undefined);
|
||||
this.options.storage.setItem('tsgame_dLogin','1');
|
||||
}
|
||||
this.options.storage.setItem(`tsgame_playerid_${this.options.gameid}${this.options.agentid}`,String(data.playerid));
|
||||
}
|
||||
visitor():void {
|
||||
if(!this.requireAgreement())return;
|
||||
const cached=this.read('tsgame_visitorinfo',false);
|
||||
const account=cached===null?generate(this.options.random,'tsgame_visitorinfo'):accountFrom(cached);
|
||||
this.save(account,this.options.policy.showVisitor?'tsgame_visitorinfo':'tsgame_wxinfo');
|
||||
this.options.submit(account,{enabled:false});
|
||||
}
|
||||
hiddenFirst():void{this.hiddenClicks++;}
|
||||
phonePanelState(){return readPhoneCode(this.options.storage.getItem('sgame_phoneCode'),this.options.now());}
|
||||
hiddenSecond():void{if(this.hiddenClicks>=5&&this.options.policy.hiddenVisitor){this.hiddenClicks=0;this.visitor();}}
|
||||
private deviceAccount(phone?:string):LoginAccountIdentity {
|
||||
const cached=this.read('tsgame_devicePlayer',true);if(cached)return accountFrom(cached);
|
||||
let unionid:string;
|
||||
if(phone!==undefined)unionid=`phone_${phone}`;
|
||||
else {
|
||||
const info=this.read('tsgame_phoneInfo1',false);
|
||||
if(info&&typeof info.PhoneIMEI==='string'&&info.PhoneIMEI)unionid=info.PhoneIMEI;
|
||||
else {
|
||||
const sets=['0123456789','qwertyuioplkjhgfdsazxcvbnm','QWERTYUIOPLKJHGFDSAZXCVBNM'];
|
||||
unionid=Array.from({length:25},()=>{const chars=sets[Math.round(this.options.random()*2)];return chars[Math.ceil(this.options.random()*100000000)%chars.length];}).join('');
|
||||
this.options.storage.setItem('tsgame_phoneInfo1',JSON.stringify({...info,PhoneIMEI:unionid}));
|
||||
}
|
||||
}
|
||||
const {avatarUrl,avatarRange}=this.options.policy;
|
||||
return {openid:unionid,unionid,nickname:`User_${nicknameHash(unionid).slice(0,6)}`,
|
||||
avatar:avatarUrl?`${avatarUrl}${avatarRange[0]+Math.round(this.options.random()*(avatarRange[1]-avatarRange[0]))}.jpg`:'',
|
||||
sex:Math.floor(this.options.random()*2)+1,city:'China',province:phone===undefined?'HongKong':'ShangHai'};
|
||||
}
|
||||
device():void{if(!this.requireAgreement())return;this.options.submit(this.deviceAccount(),{enabled:false});}
|
||||
phone(phone:string,code:string):void {
|
||||
if(!this.requireAgreement())return;
|
||||
if(!phone||!code){this.options.tip('手机号与验证码均不能为空!');return;}
|
||||
const account=this.deviceAccount(phone);
|
||||
// Func.phoneLogin -> sharelogin saves visitorinfo before Net.Send_login when showVisitor is enabled.
|
||||
if(this.options.policy.showVisitor)this.save(account,'tsgame_visitorinfo',phone);
|
||||
this.options.submit(account,{enabled:true,telphone:phone,smmcode:code});
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "57d7618f-3e88-4036-83de-96a6e63be657",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/** External native handler names registered by the legacy H5 client. */
|
||||
export const REGISTERED_NATIVE_HANDLER_NAMES = [
|
||||
'getVideoinfo',
|
||||
'sharelogin',
|
||||
'gameui_play_voice',
|
||||
'gameui_stop_voice',
|
||||
'sharesuccess',
|
||||
'getphoneinfo',
|
||||
'getAddressBook',
|
||||
'phonestate',
|
||||
'appservice',
|
||||
'getaudiourl',
|
||||
'getBattery',
|
||||
'getwifiLevel',
|
||||
'getnetwork',
|
||||
'shakeEnd',
|
||||
'yPaytype',
|
||||
'getlocationinfo',
|
||||
'getWebdata',
|
||||
'PayuserPaytypePaystate',
|
||||
'setPostUrl',
|
||||
'getphoto',
|
||||
] as const;
|
||||
|
||||
/** External native methods called by the legacy H5 client. */
|
||||
export const NATIVE_CALL_NAMES = [
|
||||
'getphoto',
|
||||
'h5Webpay',
|
||||
'getVideoinfo',
|
||||
'getphoneInfo',
|
||||
'getAddressBook',
|
||||
'createRoom',
|
||||
'exitRoom',
|
||||
'accreditlogin',
|
||||
'cancellogin',
|
||||
'OpenurlTitleData',
|
||||
'getOther',
|
||||
'voicePlaying',
|
||||
'gameCopytext',
|
||||
'opencamera',
|
||||
'gamepastetext',
|
||||
'getGameinstall',
|
||||
'getGameplay',
|
||||
'getphonestate',
|
||||
'ypayType',
|
||||
'SwitchOverGameData',
|
||||
'backgameData',
|
||||
'startlocation',
|
||||
'getlocationinfo',
|
||||
'srcIsloop',
|
||||
'opensaoma',
|
||||
'startshake',
|
||||
'stopshake',
|
||||
'SwitchShake',
|
||||
'sharephotourl',
|
||||
'browser',
|
||||
'mediaTypeAudio',
|
||||
'prepareaudio',
|
||||
'vibrator',
|
||||
'repeatvibrator',
|
||||
'canclevibrator',
|
||||
'orientation',
|
||||
'notification',
|
||||
'friendsSharetypeUrlToptitleDescript',
|
||||
] as const;
|
||||
|
||||
export type RegisteredNativeHandlerName = (typeof REGISTERED_NATIVE_HANDLER_NAMES)[number];
|
||||
export type NativeCallName = (typeof NATIVE_CALL_NAMES)[number];
|
||||
export type NativeSettingName = 'agent' | 'gameconfig' | 'gameserver' | 'servertype';
|
||||
|
||||
/** Handler arguments remain opaque until a consuming feature defines its contract. */
|
||||
export type NativeHandler = (...args: unknown[]) => unknown;
|
||||
export type NativeResponseCallback = (...args: unknown[]) => void;
|
||||
|
||||
export interface NativeBridgeChannel {
|
||||
registerHandler(name: string, handler: NativeHandler): void;
|
||||
callHandler(name: string, data: unknown, callback?: NativeResponseCallback): void;
|
||||
}
|
||||
|
||||
export interface NativeSettingsPort {
|
||||
getOtherName(name: NativeSettingName): string | number;
|
||||
getChannelName(): string | number;
|
||||
getMarketName(): string | number;
|
||||
}
|
||||
|
||||
export interface NativeBridgePort {
|
||||
isReady(): boolean;
|
||||
register(name: RegisteredNativeHandlerName, handler: NativeHandler): void;
|
||||
call(name: NativeCallName, data: unknown, callback?: NativeResponseCallback): void;
|
||||
dispose(): void;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "27e9345c-5f75-4829-be9f-174141dec6e6",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type { NativeSettingName, NativeSettingsPort } from './native-contracts.ts';
|
||||
import { logNativeCall } from '../../core/native-call-log.ts';
|
||||
|
||||
export interface NativeSettingsChannel {
|
||||
getothername(name: NativeSettingName): string | number;
|
||||
getchannelName(): string | number;
|
||||
getmarketname(): string | number;
|
||||
}
|
||||
|
||||
/** Exact-name adapter over the object injected at window.settings. */
|
||||
export class NativeSettingsAdapter implements NativeSettingsPort {
|
||||
constructor(private readonly settings: NativeSettingsChannel) {}
|
||||
|
||||
getOtherName(name: NativeSettingName): string | number {
|
||||
logNativeCall('settings','getothername',[name],true);return this.settings.getothername(name);
|
||||
}
|
||||
|
||||
getChannelName(): string | number {
|
||||
logNativeCall('settings','getchannelName',[],true);return this.settings.getchannelName();
|
||||
}
|
||||
|
||||
getMarketName(): string | number {
|
||||
logNativeCall('settings','getmarketname',[],true);return this.settings.getmarketname();
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "df3fb4b3-bb92-40f7-b661-05a158dec617",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import {requireRecord} from '../../protocol/contracts/validation.ts';
|
||||
/** Logic.readRoomList/addRoomToList/removeRoomFromList. This is owner-return history, not recently played rooms. */
|
||||
export class OwnedRoomHistory {
|
||||
private readonly key:string;
|
||||
constructor(private readonly storage:Pick<Storage,'getItem'|'setItem'>,gameid:string|number,agentid:string|number){this.key='tsgame_roomlist_'+gameid+agentid;}
|
||||
read():readonly number[]{
|
||||
const text=this.storage.getItem(this.key);
|
||||
if(!text){this.save([]);return [];}
|
||||
const raw=requireRecord(JSON.parse(text),this.key);
|
||||
if(!Array.isArray(raw.data)||raw.data.some(n=>typeof n!=='number'||!Number.isSafeInteger(n)||n<=0))throw Error('Invalid '+this.key);
|
||||
return Object.freeze([...raw.data]);
|
||||
}
|
||||
private code(value:unknown):number {
|
||||
if(!(typeof value==='string'&&/^[0-9]+$/.test(value)||typeof value==='number'))throw Error('Invalid room history roomcode');
|
||||
const code=Number(value);if(!Number.isSafeInteger(code)||code<=0)throw Error('Invalid room history roomcode');return code;
|
||||
}
|
||||
private save(data:readonly number[]):void{this.storage.setItem(this.key,JSON.stringify({data}));}
|
||||
joined(raw:Readonly<Record<string,unknown>>):void{
|
||||
if(Number(raw.state)===99||Number(raw.state)===0&&Number(raw.isowner)===1){const code=this.code(raw.roomcode);this.save(this.read().filter(n=>n!==code));}
|
||||
}
|
||||
broken(raw:Readonly<Record<string,unknown>>):void{
|
||||
if(raw.roomcode===undefined)return;
|
||||
const code=this.code(raw.roomcode);this.save(this.read().filter(n=>n!==code));
|
||||
}
|
||||
exited(raw:Readonly<Record<string,unknown>>,infinite:number):void{
|
||||
if(Number(raw.isowner)!==1||infinite!==0)return;
|
||||
const code=this.code(raw.roomcode),list=this.read();if(!list.includes(code))this.save([code,...list]);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "ca074aba-afc9-428f-93ea-199d30dba6ef",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Logic.readPhoneCode: sgame_phoneCode owns the phone number and 30-second expiry. */
|
||||
export function readPhoneCode(raw:string|null,now:number):{phoneNumber:string;restTime:number}|null {
|
||||
if(raw===null)return null;
|
||||
const data=JSON.parse(raw);
|
||||
if(!data||typeof data!=='object'||typeof data.phoneNumber!=='string')throw Error('Invalid phoneCode.phoneNumber');
|
||||
if(!Object.prototype.hasOwnProperty.call(data,'codeTime')||!['string','number'].includes(typeof data.codeTime)||!Number.isFinite(Number(data.codeTime)))throw Error('Invalid phoneCode.codeTime');
|
||||
const elapsed=now-Number(data.codeTime);
|
||||
return {phoneNumber:data.phoneNumber,restTime:elapsed>30000?0:Math.trunc(30-elapsed/1000)};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "bb46066c-73af-4603-9f7a-5579a2ad030b",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {logNativeCall} from '../../core/native-call-log.ts';
|
||||
import type {NativeCallName} from './native-contracts.ts';
|
||||
|
||||
export type PlatformNativeHost=
|
||||
|{kind:'browser'}
|
||||
|{kind:'settings';settings:object}
|
||||
|{kind:'bridge';call(name:NativeCallName,data:unknown):void};
|
||||
|
||||
// 05_Func.startlocation/getlocation: the browser source owns this test fixture.
|
||||
// Native hosts always supply their own result through getlocationinfo.
|
||||
const BROWSER_LOCATION=Object.freeze({
|
||||
address:'江西省南昌市青云谱区施尧路靠近上海浦东发展银行(长天支行)',city:'南昌市',cityCode:'0791',country:'中国',
|
||||
district:'青云谱区',latitude:28.623546,longitude:115.900333,province:'江西省',street:'施尧路',errorCode:0,
|
||||
});
|
||||
|
||||
function invoke(host:PlatformNativeHost,name:NativeCallName,data:unknown,args:unknown[]):void {
|
||||
if(host.kind==='bridge'){host.call(name,data);return;}
|
||||
if(host.kind==='browser'){logNativeCall('browser',name,args,false);return;}
|
||||
const method=(host.settings as Record<string,unknown>)[name];
|
||||
logNativeCall('settings',name,args,typeof method==='function');
|
||||
if(typeof method!=='function')throw Error(`Native settings missing ${name}`);
|
||||
Reflect.apply(method,host.settings,args);
|
||||
}
|
||||
|
||||
export function startPlatformLocation(host:PlatformNativeHost,onLocation:(data:unknown)=>void):void {
|
||||
invoke(host,'startlocation',2,[2]);
|
||||
if(host.kind==='browser')onLocation(JSON.stringify(BROWSER_LOCATION));
|
||||
}
|
||||
|
||||
export function callPlatformSetting(host:PlatformNativeHost,name:'stopshake'):void;
|
||||
export function callPlatformSetting(host:PlatformNativeHost,name:'voicePlaying'|'SwitchShake',value:0|1):void;
|
||||
export function callPlatformSetting(host:PlatformNativeHost,name:'stopshake'|'voicePlaying'|'SwitchShake',value?:0|1):void {
|
||||
// Settings stopshake takes no args; WVJB explicitly takes an empty string.
|
||||
invoke(host,name,name==='stopshake'?'':value,name==='stopshake'?[]:[value]);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "41e1b7ed-8004-4b1e-b41d-3013e5716ac2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import {logNativeCall} from '../../core/native-call-log.ts';
|
||||
import type {NativeBridgePort, NativeHandler, RegisteredNativeHandlerName} from './native-contracts.ts';
|
||||
import type {PlatformNativeHost} from './platform-native.ts';
|
||||
|
||||
export interface RoomDeviceStatus {
|
||||
readonly battery:number|null;
|
||||
readonly network:1|2|3|null;
|
||||
readonly wifiLevel:number|null;
|
||||
readonly wifiFrame:1|2|3|4|null;
|
||||
readonly batteryFrame:2|3|null;
|
||||
}
|
||||
export interface RoomDeviceStatusOptions {
|
||||
host:PlatformNativeHost;
|
||||
subscribe(name:RegisteredNativeHandlerName,handler:NativeHandler):()=>void;
|
||||
}
|
||||
|
||||
// Browser fixture belongs to this source: 05_Func.getbattery/getnetwork return
|
||||
// 1 in plain H5; MainScene initially maps every non-WiFi network to frame 4.
|
||||
const BROWSER_STATUS:RoomDeviceStatus=Object.freeze({battery:1,network:1,wifiLevel:null,wifiFrame:4,batteryFrame:2});
|
||||
|
||||
/** Settings calls globals; WVJB registers the same names with different WiFi args. */
|
||||
export function createRoomDeviceStatusSubscriptions(
|
||||
host:PlatformNativeHost,globals:Record<string,unknown>,bridge?:Pick<NativeBridgePort,'register'>,
|
||||
):RoomDeviceStatusOptions['subscribe'] {
|
||||
if(host.kind==='bridge'&&!bridge)throw Error('Room device status bridge registration is required');
|
||||
return (name,handler)=>{
|
||||
if(name!=='getBattery'&&name!=='getwifiLevel'&&name!=='getnetwork')throw Error(`Unexpected room device status handler: ${name}`);
|
||||
let active=true;
|
||||
const guarded:NativeHandler=(...args)=>{if(active)return handler(...args);};
|
||||
if(host.kind==='bridge'){
|
||||
bridge!.register(name,guarded);
|
||||
// WVJB exposes no unregister contract; native-retained callbacks become inert.
|
||||
return ()=>{active=false;};
|
||||
}
|
||||
if(host.kind==='browser')return ()=>{active=false;};
|
||||
const previous=Object.getOwnPropertyDescriptor(globals,name);
|
||||
globals[name]=guarded;
|
||||
return ()=>{
|
||||
active=false;
|
||||
if(globals[name]!==guarded)return;
|
||||
if(previous)Object.defineProperty(globals,name,previous);
|
||||
else delete globals[name];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function sourceNumber(value:unknown,name:string,min:number,max:number,integer=false):number {
|
||||
if((typeof value!=='number'&&typeof value!=='string')||String(value).trim()==='')throw Error(`Invalid native ${name}`);
|
||||
const number=Number(value);
|
||||
if(!Number.isFinite(number)||number<min||number>max||(integer&&!Number.isInteger(number)))throw Error(`Invalid native ${name}: ${String(value)}`);
|
||||
return number;
|
||||
}
|
||||
|
||||
export class RoomDeviceStatusAdapter {
|
||||
private state:RoomDeviceStatus={battery:null,network:null,wifiLevel:null,wifiFrame:null,batteryFrame:null};
|
||||
private readonly listeners=new Set<(status:RoomDeviceStatus)=>void>();
|
||||
private readonly unsubscribers:Array<()=>void>=[];
|
||||
private disposed=false;
|
||||
private readonly options:RoomDeviceStatusOptions;
|
||||
|
||||
constructor(options:RoomDeviceStatusOptions) {
|
||||
this.options=options;
|
||||
try {
|
||||
this.unsubscribers.push(options.subscribe('getBattery',value=>{if(!this.disposed)this.receiveBattery(value);}));
|
||||
this.unsubscribers.push(options.subscribe('getnetwork',value=>{if(!this.disposed)this.receiveNetwork(value,false);}));
|
||||
this.unsubscribers.push(options.subscribe('getwifiLevel',(...args)=>{if(!this.disposed)this.receiveWifi(args);}));
|
||||
this.refresh();
|
||||
}catch(error){this.dispose();throw error;}
|
||||
}
|
||||
|
||||
snapshot():RoomDeviceStatus {return this.state;}
|
||||
|
||||
/** Changes only; use snapshot() for the initial render. */
|
||||
subscribe(listener:(status:RoomDeviceStatus)=>void):()=>void {
|
||||
this.assertUsable();this.listeners.add(listener);return()=>{this.listeners.delete(listener);};
|
||||
}
|
||||
|
||||
refresh():void {
|
||||
this.assertUsable();
|
||||
const host=this.options.host;
|
||||
if(host.kind==='browser'){this.publish(BROWSER_STATUS);return;}
|
||||
// Legacy WVJB Func getters return callback-populated app_* variables only.
|
||||
// There are no getnetwork/getbattery/getwifisignalLevel callHandler methods.
|
||||
if(host.kind==='bridge')return;
|
||||
this.receiveNetwork(this.setting('getnetwork'),true);
|
||||
this.receiveBattery(this.setting('getbattery'));
|
||||
}
|
||||
|
||||
dispose():void {
|
||||
if(this.disposed)return;
|
||||
this.disposed=true;this.listeners.clear();
|
||||
const errors:unknown[]=[];
|
||||
for(const off of this.unsubscribers.splice(0)){try{off();}catch(error){errors.push(error);}}
|
||||
if(errors.length)throw errors[0];
|
||||
}
|
||||
|
||||
private receiveBattery(value:unknown):void {
|
||||
const battery=sourceNumber(value,'battery',0,1);
|
||||
this.publish({...this.state,battery,batteryFrame:battery<=0.2?3:2});
|
||||
}
|
||||
|
||||
private receiveNetwork(value:unknown,entry:boolean):void {
|
||||
const network=sourceNumber(value,'network',1,3,true) as 1|2|3;
|
||||
// 05_Func.getnetwork callback type 1 preserves the displayed frame.
|
||||
const wifiFrame=network===2?3:network===3||entry?4:this.state.wifiFrame;
|
||||
this.publish({...this.state,network,wifiFrame});
|
||||
if(network===2&&this.options.host.kind==='settings')this.setting('getwifisignalLevel');
|
||||
}
|
||||
|
||||
private receiveWifi(args:unknown[]):void {
|
||||
let raw:unknown;
|
||||
if(this.options.host.kind==='settings')raw={ssidname:args[0],signalLevel:args[1]};
|
||||
else raw=typeof args[0]==='string'?JSON.parse(args[0]):args[0];
|
||||
if(!raw||typeof raw!=='object')throw Error('Invalid native wifi payload');
|
||||
const wifiLevel=sourceNumber((raw as Record<string,unknown>).signalLevel,'wifi signalLevel',0,4,true);
|
||||
this.publish({...this.state,wifiLevel,wifiFrame:wifiLevel===0?1:wifiLevel<=2?2:3});
|
||||
}
|
||||
|
||||
private setting(name:'getnetwork'|'getbattery'|'getwifisignalLevel'):unknown {
|
||||
const host=this.options.host;
|
||||
if(host.kind!=='settings')throw Error('Device status settings host required');
|
||||
const method=(host.settings as Record<string,unknown>)[name];
|
||||
logNativeCall('settings',name,[],typeof method==='function');
|
||||
if(typeof method!=='function')throw Error(`Native settings missing ${name}`);
|
||||
return Reflect.apply(method,host.settings,[]);
|
||||
}
|
||||
|
||||
private publish(state:RoomDeviceStatus):void {
|
||||
this.state=Object.freeze(state);
|
||||
for(const listener of this.listeners)listener(this.state);
|
||||
}
|
||||
|
||||
private assertUsable():void {if(this.disposed)throw Error('Room device status adapter is disposed');}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "e58a8fa9-83ef-4325-a40c-99e91f716092",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import {logNativeCall} from '../../core/native-call-log.ts';
|
||||
import type {NativeBridgePort, NativeHandler, RegisteredNativeHandlerName} from './native-contracts.ts';
|
||||
import type {PlatformNativeHost} from './platform-native.ts';
|
||||
|
||||
export interface RoomVoiceOptions {
|
||||
host:PlatformNativeHost;
|
||||
subscribe(name:RegisteredNativeHandlerName,handler:NativeHandler):()=>void;
|
||||
/** Preserve the native duration's wire type; the runtime owns the envelope. */
|
||||
sendVoice(voiceurl:string,time:string|number):void;
|
||||
onPlayback(user:number,playing:boolean):void;
|
||||
}
|
||||
|
||||
/** Settings calls top-level window functions; WVJB calls registered handlers. */
|
||||
export function createRoomVoiceSubscriptions(
|
||||
host:PlatformNativeHost,
|
||||
globals:Record<string,unknown>,
|
||||
bridge?:Pick<NativeBridgePort,'register'>,
|
||||
):RoomVoiceOptions['subscribe'] {
|
||||
if(host.kind==='bridge'&&!bridge)throw Error('Room voice bridge registration is required');
|
||||
return (name,handler)=>{
|
||||
if(name!=='getaudiourl'&&name!=='gameui_play_voice'&&name!=='gameui_stop_voice')throw Error(`Unexpected room voice handler: ${name}`);
|
||||
let active=true;
|
||||
const guarded:NativeHandler=(...args)=>{if(active)return handler(...args);};
|
||||
if(host.kind==='bridge'){
|
||||
bridge!.register(name,guarded);
|
||||
// WVJB has no unregister contract. Retained native callbacks become inert.
|
||||
return ()=>{active=false;};
|
||||
}
|
||||
if(host.kind==='browser')return ()=>{active=false;};
|
||||
const previous=Object.getOwnPropertyDescriptor(globals,name);
|
||||
globals[name]=guarded;
|
||||
return ()=>{
|
||||
active=false;
|
||||
if(globals[name]!==guarded)return;
|
||||
if(previous)Object.defineProperty(globals,name,previous);
|
||||
else delete globals[name];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Native owns recorder UI and upload. Legacy defines no native stop/cancel command. */
|
||||
export class RoomVoiceAdapter {
|
||||
private pending=false;
|
||||
private cancelled=false;
|
||||
private disposed=false;
|
||||
private readonly unsubscribers:Array<()=>void>=[];
|
||||
private readonly options:RoomVoiceOptions;
|
||||
|
||||
constructor(options:RoomVoiceOptions) {
|
||||
this.options=options;
|
||||
try {
|
||||
this.unsubscribers.push(options.subscribe('getaudiourl',(...args)=>this.uploaded(args)));
|
||||
for(const [name,playing] of [['gameui_play_voice',true],['gameui_stop_voice',false]] as const){
|
||||
this.unsubscribers.push(options.subscribe(name,(value)=>{
|
||||
if(this.disposed)return;
|
||||
if((typeof value!=='string'&&typeof value!=='number')||String(value).trim()===''||!Number.isInteger(Number(value)))throw Error('Invalid native voice user');
|
||||
options.onPlayback(Number(value),playing);
|
||||
}));
|
||||
}
|
||||
} catch(error) {this.dispose();throw error;}
|
||||
}
|
||||
|
||||
startRecording():void {
|
||||
this.assertUsable();
|
||||
if(this.pending)throw Error('Native voice recording is pending');
|
||||
this.pending=true;this.cancelled=false;
|
||||
try {this.invoke('prepareaudio','',[]);}catch(error){this.pending=false;throw error;}
|
||||
}
|
||||
|
||||
/** Pointer release does not end the native recorder; upload remains eligible. */
|
||||
stopRecording():void {this.assertUsable();}
|
||||
|
||||
/** Local abandonment only. Keep the pending slot until native completes. */
|
||||
cancelRecording():void {this.assertUsable();if(this.pending)this.cancelled=true;}
|
||||
|
||||
playVoice(audiourl:string,type:number,user:number):void {
|
||||
this.assertUsable();
|
||||
if(typeof audiourl!=='string'||audiourl.length===0)throw Error('Invalid native voice audiourl');
|
||||
if(!Number.isInteger(type)||!Number.isInteger(user))throw Error('Invalid native voice type/user');
|
||||
this.invoke('mediaTypeAudio',{audiourl,type,user},[audiourl,type,user]);
|
||||
}
|
||||
|
||||
dispose():void {
|
||||
if(this.disposed)return;
|
||||
this.disposed=true;this.pending=false;
|
||||
const errors:unknown[]=[];
|
||||
for(const unsubscribe of this.unsubscribers.splice(0)){try{unsubscribe();}catch(error){errors.push(error);}}
|
||||
if(errors.length)throw errors[0];
|
||||
}
|
||||
|
||||
private uploaded(args:unknown[]):void {
|
||||
if(this.disposed||!this.pending)return;
|
||||
this.pending=false;
|
||||
if(this.cancelled)return;
|
||||
let raw:unknown;
|
||||
if(this.options.host.kind==='settings')raw={audiourl:args[0],time:args[1]};
|
||||
else raw=typeof args[0]==='string'?JSON.parse(args[0]):args[0];
|
||||
if(!raw||typeof raw!=='object')throw Error('Invalid native voice upload');
|
||||
const {audiourl,time}=raw as Record<string,unknown>;
|
||||
if((typeof time!=='number'&&typeof time!=='string')||String(time).trim()===''||!Number.isFinite(Number(time)))throw Error('Invalid native voice time');
|
||||
// Legacy suppresses zero and negative duration callbacks.
|
||||
if(Number(time)<=0)return;
|
||||
if(typeof audiourl!=='string'||audiourl.length===0)throw Error('Invalid native voice audiourl');
|
||||
this.options.sendVoice(audiourl,time);
|
||||
}
|
||||
|
||||
private invoke(name:'prepareaudio'|'mediaTypeAudio',data:unknown,args:unknown[]):void {
|
||||
const host=this.options.host;
|
||||
if(host.kind==='bridge'){host.call(name,data);return;}
|
||||
if(host.kind==='browser'){
|
||||
logNativeCall('browser',name,args,false);
|
||||
throw Error('当前浏览器不支持原生语音录制或播放,请在客户端使用');
|
||||
}
|
||||
const method=(host.settings as Record<string,unknown>)[name];
|
||||
logNativeCall('settings',name,args,typeof method==='function');
|
||||
if(typeof method!=='function')throw Error(`Native settings missing ${name}`);
|
||||
Reflect.apply(method,host.settings,args);
|
||||
}
|
||||
|
||||
private assertUsable():void {if(this.disposed)throw Error('Room voice adapter is disposed');}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "17ae6470-6771-4c3a-9db6-409deca12dab",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type {VipCode} from '../../core/types/vip-room.ts';
|
||||
/** Same key and {data:[]} format as Utl.Config.vipRoom / Logic.saveVipRoom. */
|
||||
export class VipRoomHistory {
|
||||
private readonly key:string;
|
||||
constructor(private readonly storage:Pick<Storage,'getItem'|'setItem'>,gameid:string|number,agentid:string|number){this.key=`tsgame_vipRoom_${gameid}${agentid}`;}
|
||||
read():readonly VipCode[]{
|
||||
const text=this.storage.getItem(this.key);
|
||||
if(text===null){this.storage.setItem(this.key,JSON.stringify({data:[]}));return [];}
|
||||
const value=JSON.parse(text);
|
||||
if(!value||!Array.isArray(value.data)||value.data.some((v:unknown)=>!(typeof v==='string'&&v.length>0||typeof v==='number'&&Number.isFinite(v))))throw Error(`Invalid ${this.key}`);
|
||||
return value.data;
|
||||
}
|
||||
add(code:VipCode):void {this.storage.setItem(this.key,JSON.stringify({data:[code,...this.read().filter(v=>v!==code)]}));}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "1a646893-8674-4b0e-b9c5-115be014edb1",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { logNativeCall } from '../../core/native-call-log.ts';
|
||||
import {
|
||||
NATIVE_CALL_NAMES,
|
||||
REGISTERED_NATIVE_HANDLER_NAMES,
|
||||
type NativeBridgeChannel,
|
||||
type NativeBridgePort,
|
||||
type NativeCallName,
|
||||
type NativeHandler,
|
||||
type NativeResponseCallback,
|
||||
type RegisteredNativeHandlerName,
|
||||
} from './native-contracts.ts';
|
||||
|
||||
export interface BridgeLoaderElement {
|
||||
readonly tagName: string;
|
||||
readonly style: { display: string };
|
||||
src: string;
|
||||
}
|
||||
|
||||
export interface WebViewJavascriptBridgeEnvironment {
|
||||
WebViewJavascriptBridge?: NativeBridgeChannel;
|
||||
WVJBCallbacks?: Array<(bridge: NativeBridgeChannel) => void>;
|
||||
readonly document: {
|
||||
addEventListener(name: string, listener: () => void, capture: boolean): void;
|
||||
removeEventListener(name: string, listener: () => void, capture: boolean): void;
|
||||
createElement(tagName: 'iframe'): BridgeLoaderElement;
|
||||
readonly documentElement: {
|
||||
appendChild(element: BridgeLoaderElement): void;
|
||||
removeChild(element: BridgeLoaderElement): void;
|
||||
};
|
||||
};
|
||||
setTimeout(callback: () => void, delay: 0): unknown;
|
||||
}
|
||||
|
||||
export type DisposeBridgeSetup = () => void;
|
||||
|
||||
/**
|
||||
* Reproduces the legacy WVJB bootstrap ordering, including continuing after
|
||||
* an immediate bridge callback into the callback-queue/iframe block.
|
||||
*/
|
||||
export function setupWebViewJavascriptBridge(
|
||||
environment: WebViewJavascriptBridgeEnvironment,
|
||||
callback: (bridge: NativeBridgeChannel) => void,
|
||||
): DisposeBridgeSetup {
|
||||
let readyListener: (() => void) | undefined;
|
||||
|
||||
if (environment.WebViewJavascriptBridge) {
|
||||
callback(environment.WebViewJavascriptBridge);
|
||||
} else {
|
||||
readyListener = () => callback(environment.WebViewJavascriptBridge as NativeBridgeChannel);
|
||||
environment.document.addEventListener(
|
||||
'WebViewJavascriptBridgeReady',
|
||||
readyListener,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if (environment.WVJBCallbacks) {
|
||||
environment.WVJBCallbacks.push(callback);
|
||||
} else {
|
||||
environment.WVJBCallbacks = [callback];
|
||||
const loader = environment.document.createElement('iframe');
|
||||
loader.style.display = 'none';
|
||||
loader.src = 'wvjbscheme://__BRIDGE_LOADED__';
|
||||
environment.document.documentElement.appendChild(loader);
|
||||
environment.setTimeout(() => environment.document.documentElement.removeChild(loader), 0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (readyListener) {
|
||||
environment.document.removeEventListener(
|
||||
'WebViewJavascriptBridgeReady',
|
||||
readyListener,
|
||||
false,
|
||||
);
|
||||
}
|
||||
const callbackIndex = environment.WVJBCallbacks?.indexOf(callback) ?? -1;
|
||||
if (callbackIndex >= 0) environment.WVJBCallbacks?.splice(callbackIndex, 1);
|
||||
};
|
||||
}
|
||||
|
||||
function includesName<const Name extends string>(names: readonly Name[], name: string): name is Name {
|
||||
return (names as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
/** Per-instance facade over one WVJB channel. */
|
||||
export class WebViewJavascriptBridgeAdapter implements NativeBridgePort {
|
||||
private channel: NativeBridgeChannel | undefined;
|
||||
private readonly handlers = new Map<RegisteredNativeHandlerName, NativeHandler>();
|
||||
private disposed = false;
|
||||
private readonly disposeSetup: DisposeBridgeSetup;
|
||||
|
||||
constructor(environment: WebViewJavascriptBridgeEnvironment) {
|
||||
this.disposeSetup = setupWebViewJavascriptBridge(environment, channel => {
|
||||
if (this.disposed || this.channel) return;
|
||||
this.channel = channel;
|
||||
for (const [name, handler] of this.handlers) channel.registerHandler(name, handler);
|
||||
});
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
this.assertUsable();
|
||||
return this.channel !== undefined;
|
||||
}
|
||||
|
||||
register(name: RegisteredNativeHandlerName, handler: NativeHandler): void {
|
||||
this.assertUsable();
|
||||
if (!includesName(REGISTERED_NATIVE_HANDLER_NAMES, name)) {
|
||||
throw new Error(`Unknown registered native handler: ${String(name)}`);
|
||||
}
|
||||
this.handlers.set(name, handler);
|
||||
this.channel?.registerHandler(name, handler);
|
||||
}
|
||||
|
||||
call(name: NativeCallName, data: unknown, callback?: NativeResponseCallback): void {
|
||||
this.assertUsable();
|
||||
if (!includesName(NATIVE_CALL_NAMES, name)) {
|
||||
throw new Error(`Unknown native call: ${String(name)}`);
|
||||
}
|
||||
logNativeCall('WVJB',name,[data,callback],!!this.channel);
|
||||
if (!this.channel) throw new Error('Native bridge is not ready');
|
||||
this.channel.callHandler(name, data, callback);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposeSetup();
|
||||
this.handlers.clear();
|
||||
this.channel = undefined;
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
private assertUsable(): void {
|
||||
if (this.disposed) throw new Error('Native bridge adapter is disposed');
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "588b5341-c0f6-4eef-bf31-b47c815db2b0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -1,82 +1,51 @@
|
||||
import type { ChannelIdentity } from './identity.ts';
|
||||
import { resolveIdentity } from './identity.ts';
|
||||
import { BUILD_IDENTITY } from './sources/defaults.ts';
|
||||
import { queryStringSource } from './sources/query-string.ts';
|
||||
import { nativeSettingsSource, type NativeHost } from './sources/native-settings.ts';
|
||||
import { resolveActiveProfile, PROFILES, type DebugProfile, type DebugAccount } from './profiles.ts';
|
||||
import { resolveServers, type ConfigFetcher, type RemoteConfig, ConfigFetchError } from './remote-config.ts';
|
||||
import type { RuntimeMode } from './runtime-mode.ts';
|
||||
import type { DebugAccount } from './profiles.ts';
|
||||
import {
|
||||
resolveRuntimeConfig,
|
||||
type HostKind,
|
||||
type ResolveRuntimeConfigOptions,
|
||||
type RuntimeConfig,
|
||||
} from './runtime-config.ts';
|
||||
|
||||
export interface BootstrapOptions {
|
||||
mode: RuntimeMode; // 运行模式(由 resolveRuntimeMode 得到)
|
||||
win: NativeHost; // 原生注入宿主(≈ window)
|
||||
search: string; // location.search
|
||||
fetcher: ConfigFetcher; // 远程配置抓取
|
||||
isNative: boolean; // 环境判定(URL 含 index.html → true,由调用方算)
|
||||
cacheBust?: () => string; // 防缓存串注入(默认 Date.now)
|
||||
}
|
||||
type RuntimeOptionsWithoutHost = Omit<ResolveRuntimeConfigOptions, 'hostKind'>;
|
||||
|
||||
export interface BootstrapResult {
|
||||
mode: RuntimeMode;
|
||||
isDebugger: boolean; // = (mode === 'debug')
|
||||
identity: ChannelIdentity;
|
||||
account?: DebugAccount; // 调试登录账号(仅 debug 模式有值;release 恒 undefined)
|
||||
servers: string[];
|
||||
rawConfig: RemoteConfig | null;
|
||||
profile: DebugProfile;
|
||||
}
|
||||
/** 新调用方显式给 hostKind;isNative 分支仅保留现有 LoginFlow 的兼容入口。 */
|
||||
export type BootstrapOptions = RuntimeOptionsWithoutHost & (
|
||||
| { readonly hostKind: HostKind; readonly isNative?: never }
|
||||
| { readonly hostKind?: never; readonly isNative: boolean }
|
||||
);
|
||||
|
||||
/**
|
||||
* 启动编排:按运行模式解析身份 + 决定服务器,产出可喂 NetClient 的结果。
|
||||
* 数据源权威唯一、下游不兜底(CLAUDE.md 第二准则):服务器地址只来自 profile.server(直连)
|
||||
* 或远程配置解析;取不到一律显式抛错暴露,不猜默认、不降级。
|
||||
* 旧 StartupOrchestrator 的过渡外观。严格的新代码使用 RuntimeConfig;这里保留
|
||||
* 可写 servers 类型以及可选的新判别字段,直到旧启动编排迁移完成。
|
||||
*/
|
||||
export async function resolveBootstrap(opts: BootstrapOptions): Promise<BootstrapResult> {
|
||||
const isDebug = opts.mode === 'debug';
|
||||
export type BootstrapResult = Omit<RuntimeConfig, 'servers' | 'source' | 'gameserver'> & {
|
||||
readonly servers: string[];
|
||||
readonly source?: RuntimeConfig['source'];
|
||||
readonly gameserver?: RuntimeConfig['gameserver'];
|
||||
readonly account?: DebugAccount;
|
||||
readonly profile: Readonly<{ name: 'local' | 'prod' }>;
|
||||
};
|
||||
|
||||
// 模式决定 profile 与身份来源:release 强制 prod,且身份只走原生(绝不读 URL)。
|
||||
const profile = isDebug ? resolveActiveProfile(opts.search) : PROFILES.prod;
|
||||
const identity = isDebug
|
||||
? resolveIdentity([
|
||||
() => BUILD_IDENTITY,
|
||||
opts.isNative ? nativeSettingsSource(opts.win) : queryStringSource(opts.search),
|
||||
() => profile.identity ?? {},
|
||||
queryStringSource(opts.search), // 显式 URL 覆盖(最高)
|
||||
])
|
||||
: resolveIdentity([
|
||||
() => BUILD_IDENTITY,
|
||||
nativeSettingsSource(opts.win), // release:身份只取原生,URL 一律无效
|
||||
]);
|
||||
function bootstrapHostKind(options: BootstrapOptions): HostKind {
|
||||
if (options.hostKind) return options.hostKind;
|
||||
return options.isNative ? 'native-settings' : 'h5';
|
||||
}
|
||||
|
||||
// 调试账号只在 debug 模式从 profile 取;release 不读调试配置账号。
|
||||
const account = isDebug ? profile.account : undefined;
|
||||
const resultBase = { mode: opts.mode, isDebugger: isDebug, identity, account, profile };
|
||||
|
||||
// 服务器决策:profile 显式 server 直连捷径
|
||||
if (profile.server) {
|
||||
return { ...resultBase, servers: [profile.server], rawConfig: null };
|
||||
}
|
||||
|
||||
// profile 是服务器地址的权威来源:无 server 则必须有 gameserver,缺则配置错误,显式暴露。
|
||||
if (!profile.gameserver) {
|
||||
throw new ConfigFetchError(`profile '${profile.name}' 未配置 server 或 gameserver`);
|
||||
}
|
||||
|
||||
const bust = opts.cacheBust ? opts.cacheBust() : String(Date.now());
|
||||
const url = profile.gameserver + '?' + bust; // 防缓存,等价原 min_timestamp/ifast_random
|
||||
|
||||
let config: RemoteConfig;
|
||||
try {
|
||||
config = await opts.fetcher.fetch(url);
|
||||
} catch {
|
||||
throw new ConfigFetchError(`远程配置抓取失败: ${url}`);
|
||||
}
|
||||
|
||||
const resolved = resolveServers(config, identity);
|
||||
// debug 可把连接地址显式覆盖为本地测试服(仍走完整远程配置链路);release 一律用远程解析结果。
|
||||
const servers = isDebug && profile.serverOverride ? [profile.serverOverride] : resolved;
|
||||
if (!servers.length) {
|
||||
throw new ConfigFetchError(`远程配置未给出可用服务器地址: ${url}`);
|
||||
}
|
||||
return { ...resultBase, servers, rawConfig: config };
|
||||
/** 兼容启动编排外观;所有运行契约均由唯一的 RuntimeConfig 边界产出。 */
|
||||
export async function resolveBootstrap(options: BootstrapOptions): Promise<BootstrapResult> {
|
||||
const runtime = await resolveRuntimeConfig({
|
||||
buildIdentity: options.buildIdentity,
|
||||
startupConfig: options.startupConfig,
|
||||
hostKind: bootstrapHostKind(options),
|
||||
win: options.win,
|
||||
search: options.search,
|
||||
fetcher: options.fetcher,
|
||||
localFetcher: options.localFetcher,
|
||||
cacheBust: options.cacheBust,
|
||||
});
|
||||
return Object.freeze({
|
||||
...runtime,
|
||||
servers: [...runtime.servers],
|
||||
profile: Object.freeze({ name: runtime.startupConfig.useLocalServer ? 'local' as const : 'prod' as const }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,9 +14,34 @@ function hasValue(v: unknown): boolean {
|
||||
return v !== undefined && v !== null && v !== '';
|
||||
}
|
||||
|
||||
function validateIdentity(identity: Partial<ChannelIdentity>): ChannelIdentity {
|
||||
const missing = (['agentid', 'channelid', 'gameid', 'marketid', 'version'] as const)
|
||||
.filter((key) => !hasValue(identity[key]));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Invalid identity: missing ${missing.join(', ')}`);
|
||||
}
|
||||
for (const key of ['agentid', 'channelid', 'marketid'] as const) {
|
||||
const value = identity[key];
|
||||
if (typeof value !== 'string' && typeof value !== 'number') {
|
||||
throw new Error(`Invalid identity: ${key} type`);
|
||||
}
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) {
|
||||
throw new Error(`Invalid identity: ${key} must be finite`);
|
||||
}
|
||||
}
|
||||
if (typeof identity.gameid !== 'string') {
|
||||
throw new Error('Invalid identity: gameid type');
|
||||
}
|
||||
if (typeof identity.version !== 'number') {
|
||||
throw new Error('Invalid identity: version type');
|
||||
}
|
||||
if (!Number.isFinite(identity.version)) throw new Error('Invalid identity: version must be finite');
|
||||
return identity as ChannelIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按数组顺序合并身份,后者覆盖前者(仅覆盖有效值字段)。
|
||||
* 约定首个 source 为完整 defaults,故返回视为完整 ChannelIdentity。
|
||||
* 合并后逐字段校验;来源缺失必须在边界显式暴露,禁止不完整类型断言。
|
||||
*/
|
||||
export function resolveIdentity(sources: IdentitySource[]): ChannelIdentity {
|
||||
const out: Partial<ChannelIdentity> = {};
|
||||
@@ -27,5 +52,5 @@ export function resolveIdentity(sources: IdentitySource[]): ChannelIdentity {
|
||||
if (hasValue(v)) (out as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
return out as ChannelIdentity;
|
||||
return Object.freeze(validateIdentity(out));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { resolveIdentity } from './identity.ts';
|
||||
import { STARTUP_CONFIG, validateStartupConfig, validateLocalServer } from './profiles.ts';
|
||||
import {
|
||||
resolveRuntimeConfig,
|
||||
type ResolveRuntimeConfigOptions,
|
||||
type RuntimeConfig,
|
||||
} from './runtime-config.ts';
|
||||
|
||||
export interface LocalStartupContext {
|
||||
readonly hostKind: 'h5';
|
||||
readonly profile: 'local';
|
||||
readonly config: RuntimeConfig;
|
||||
}
|
||||
|
||||
const QUERY_CONTROLS = Object.freeze([
|
||||
'gameconfig',
|
||||
'agentid',
|
||||
'channelid',
|
||||
'marketid',
|
||||
'version',
|
||||
'gameid',
|
||||
] as const);
|
||||
|
||||
function fail(field: string, reason: string): never {
|
||||
throw new Error(`Invalid local startup ${field}: ${reason}`);
|
||||
}
|
||||
|
||||
function assertLocalQuery(search: string): void {
|
||||
const query = new URLSearchParams(search);
|
||||
for (const field of QUERY_CONTROLS) {
|
||||
if (query.getAll(field).length > 1) fail(field, 'duplicate query control');
|
||||
}
|
||||
|
||||
const gameconfigs = query.getAll('gameconfig');
|
||||
if (gameconfigs.length === 1 && gameconfigs[0] !== '') {
|
||||
fail('gameconfig', 'must be empty');
|
||||
}
|
||||
if (query.has('gameid')) fail('gameid', 'query override is forbidden');
|
||||
}
|
||||
|
||||
export async function resolveLocalStartup(
|
||||
options: ResolveRuntimeConfigOptions,
|
||||
): Promise<LocalStartupContext> {
|
||||
const startupConfig = validateStartupConfig(options.startupConfig === undefined ? STARTUP_CONFIG : options.startupConfig);
|
||||
if (startupConfig.mode !== 'debug') fail('mode', 'must be debug');
|
||||
if (!startupConfig.useLocalServer) fail('useLocalServer', 'must be true');
|
||||
if (options.hostKind !== 'h5') fail('hostKind', 'must be h5');
|
||||
assertLocalQuery(options.search);
|
||||
|
||||
const config = await resolveRuntimeConfig({...options, startupConfig});
|
||||
const context = Object.freeze({ hostKind: 'h5' as const, profile: 'local' as const, config });
|
||||
assertLocalStartupContext(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
export function assertLocalStartupContext(context: LocalStartupContext): void {
|
||||
if (context.hostKind !== 'h5') fail('hostKind', 'must be h5');
|
||||
if (context.profile !== 'local') fail('profile', 'must be local');
|
||||
|
||||
const { config } = context;
|
||||
const startupConfig = validateStartupConfig(config.startupConfig);
|
||||
if (startupConfig.mode !== 'debug') fail('mode', 'source must be debug');
|
||||
if (!startupConfig.useLocalServer) fail('useLocalServer', 'source must be true');
|
||||
if (config.mode !== 'debug') fail('mode', 'must be debug');
|
||||
if (config.isDebugger !== true) fail('isDebugger', 'must be true');
|
||||
if (config.source !== startupConfig.configSource) fail('source', 'must equal startupConfig.configSource');
|
||||
const configEntry = startupConfig.configSource === 'local' ? startupConfig.localConfigResource : startupConfig.gameserver;
|
||||
if (config.gameserver !== configEntry) fail('gameserver', 'must equal startupConfig selected entry');
|
||||
if (!config.remoteConfig || typeof config.remoteConfig.getValue !== 'function') fail('remoteConfig', 'must provide memory view');
|
||||
if (!config.rawConfig || config.rawConfig !== config.remoteConfig.raw) fail('rawConfig', 'must share remoteConfig.raw');
|
||||
|
||||
try {
|
||||
resolveIdentity([() => config.identity]);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : 'validation failed';
|
||||
const wrapped = new Error(`Invalid local startup identity: ${reason}`) as Error & {
|
||||
cause?: unknown;
|
||||
};
|
||||
wrapped.cause = error;
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
const authoritative = startupConfig.localServers;
|
||||
if (config.servers.length !== authoritative.length) {
|
||||
fail('servers', 'must equal startupConfig.localServers');
|
||||
}
|
||||
for (let index = 0; index < authoritative.length; index += 1) {
|
||||
const expected = authoritative[index];
|
||||
validateLocalServer(expected, `servers[${index}]`);
|
||||
if (config.servers[index] !== expected) {
|
||||
fail('servers', 'must equal startupConfig.localServers');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalConnectionTarget(context: LocalStartupContext, url: string): void {
|
||||
assertLocalStartupContext(context);
|
||||
validateLocalServer(url, 'url');
|
||||
if (!context.config.startupConfig.localServers.includes(url)) {
|
||||
fail('url', 'target must occur in startupConfig.localServers');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "1d920343-a60b-4b40-8eba-f29b4a7d53fa",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ChannelIdentity } from './identity.ts';
|
||||
import { resolveRuntimeMode, type RuntimeMode } from './runtime-mode.ts';
|
||||
|
||||
/** 调试用登录玩家档案(player_login 中除渠道身份外的字段)。仅 debug 模式生效。 */
|
||||
|
||||
/** 调试用登录玩家档案;值只能由测试或本地启动配置显式注入。 */
|
||||
export interface DebugAccount {
|
||||
openid: string; // 服务器据此标识玩家(必填)
|
||||
openid: string;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
sex?: number;
|
||||
@@ -11,39 +12,86 @@ export interface DebugAccount {
|
||||
unionid?: string | number;
|
||||
}
|
||||
|
||||
export interface DebugProfile {
|
||||
name: string;
|
||||
server?: string; // 显式 ws 调试服地址(给定则跳过远程抓取,纯直连)
|
||||
gameserver?: string; // 远程配置 txt URL(不含防缓存串)
|
||||
serverOverride?: string; // debug:抓远程配置走全链路后,把连接地址覆盖为此(联调本地游戏服用)
|
||||
identity?: Partial<ChannelIdentity>; // 调试用渠道身份覆盖
|
||||
account?: DebugAccount; // 调试用登录账号(仅 debug 模式生效)
|
||||
isDebugger?: boolean; // 收发包日志开关(替代旧 Game_Config.Debugger.isDebugger)
|
||||
export interface StartupConfig {
|
||||
/** 输出全部收发包、配置文件内容及连接状态/重连原因。 */
|
||||
readonly debugIO: boolean;
|
||||
readonly mode: RuntimeMode;
|
||||
/** 配置内容来源;local 仅允许 debug,独立于 WebSocket 服务器选择。 */
|
||||
readonly configSource: 'remote' | 'local';
|
||||
/** resources 下的 txt 路径,不含扩展名。 */
|
||||
readonly localConfigResource: string;
|
||||
readonly gameserver: string;
|
||||
readonly useLocalServer: boolean;
|
||||
readonly localServers: readonly string[];
|
||||
}
|
||||
|
||||
/** 正式 gameserver:原 00_SubGame_Config.js:11。 */
|
||||
export const DEFAULT_GAMESERVER = 'http://ylyxservice1.0791ts.cn/config/update_json.txt';
|
||||
/** 测试 gameserver:原 00_SubGame_Config.js:9 注释。 */
|
||||
const STAGING_GAMESERVER = 'https://tsgames.daoqi88.cn/config_test/beta_update_jsonv2.txt';
|
||||
/** 唯一发布配置入口;mode 与服务器选择互相独立。 */
|
||||
export const STARTUP_CONFIG: Readonly<StartupConfig> = Object.freeze({
|
||||
debugIO: true,
|
||||
mode: 'debug',
|
||||
// 开发时改为 local,编辑 assets/resources/dev-config/update_jsonv2.txt 后重新预览。
|
||||
configSource: 'local',
|
||||
localConfigResource: 'dev-config/update_jsonv2',
|
||||
gameserver: 'https://tsgames.daoqi88.cn/config_test/update_jsonv2.txt',
|
||||
useLocalServer: true,
|
||||
localServers: Object.freeze(['ws://127.0.0.1:3088']),
|
||||
});
|
||||
|
||||
export const PROFILES: Record<string, DebugProfile> = {
|
||||
prod: { name: 'prod', gameserver: DEFAULT_GAMESERVER, isDebugger: false },
|
||||
staging: {
|
||||
name: 'staging',
|
||||
gameserver: STAGING_GAMESERVER,
|
||||
serverOverride: 'ws://127.0.0.1:3088', // 走 staging 配置全链路,但游戏服连本地测试服
|
||||
isDebugger: true,
|
||||
// 调试登录账号:联调前替换为真实测试账号(openid 必填)。仅 debug 模式生效。
|
||||
account: { openid: 'REPLACE_ME_test_openid', nickname: '测试号', avatar: 'https://gips3.baidu.com/it/u=4028841722,3177937756&fm=3074&app=3074&f=PNG?w=2048&h=2048', sex: 0, province: 'testprovince', city: 'testcity', unionid: 'test_unionid' },
|
||||
},
|
||||
local: { name: 'local', server: 'ws://127.0.0.1:3088', isDebugger: true },
|
||||
};
|
||||
function invalid(field: string, reason: string): never {
|
||||
throw new Error('Invalid startup config ' + field + ': ' + reason);
|
||||
}
|
||||
|
||||
/** 发布只需保持 'prod'。 */
|
||||
export const ACTIVE_PROFILE = 'prod';
|
||||
export function validateLocalServer(value: string, field = 'localServers'): void {
|
||||
if (typeof value !== 'string' || value.trim() !== value) invalid(field, 'must be an absolute URL');
|
||||
let url: URL;
|
||||
try { url = new URL(value); } catch { invalid(field, 'must be an absolute URL'); }
|
||||
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') invalid(field, 'protocol must be ws: or wss:');
|
||||
if (url.username || url.password || value.includes('#')) invalid(field, 'credentials and fragments are forbidden');
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (host !== 'localhost' && host !== '[::1]' && !/^127(?:\.[0-9]{1,3}){3}$/.test(host)) {
|
||||
invalid(field, 'hostname must be loopback');
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析当前 profile:ACTIVE_PROFILE 为基线,?profile=<name> 临时切换;未知名回退 ACTIVE_PROFILE。 */
|
||||
export function resolveActiveProfile(search: string): DebugProfile {
|
||||
const name = new URLSearchParams(search).get('profile') || ACTIVE_PROFILE;
|
||||
return PROFILES[name] ?? PROFILES[ACTIVE_PROFILE];
|
||||
/** 验证后复制为不可变快照,异步抓取期间调用方不能改变配置。 */
|
||||
export function validateStartupConfig(source: Readonly<StartupConfig>): Readonly<StartupConfig> {
|
||||
if (!source || typeof source !== 'object') invalid('source', 'must be an object');
|
||||
const mode = resolveRuntimeMode(source.mode);
|
||||
if (typeof source.debugIO !== 'boolean') invalid('debugIO', 'must be boolean');
|
||||
if (source.configSource !== 'remote' && source.configSource !== 'local') invalid('configSource', 'must be remote or local');
|
||||
if (source.configSource === 'local' && mode !== 'debug') invalid('configSource', 'local is only allowed in debug mode');
|
||||
if (typeof source.localConfigResource !== 'string' || !/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(source.localConfigResource)) {
|
||||
invalid('localConfigResource', 'must be a resources path without extension or traversal');
|
||||
}
|
||||
if (typeof source.useLocalServer !== 'boolean') invalid('useLocalServer', 'must be boolean');
|
||||
if (typeof source.gameserver !== 'string' || source.gameserver.trim() !== source.gameserver) invalid('gameserver', 'must be http(s) URL');
|
||||
let url: URL;
|
||||
try { url = new URL(source.gameserver); } catch { invalid('gameserver', 'must be http(s) URL'); }
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') invalid('gameserver', 'must be http(s) URL');
|
||||
if (!Array.isArray(source.localServers) || source.localServers.length === 0) invalid('localServers', 'must be a nonempty array');
|
||||
for (let index = 0; index < source.localServers.length; index++) validateLocalServer(source.localServers[index], 'localServers[' + index + ']');
|
||||
return Object.freeze({mode, debugIO:source.debugIO, configSource:source.configSource, localConfigResource:source.localConfigResource, gameserver:source.gameserver, useLocalServer:source.useLocalServer, localServers:Object.freeze([...source.localServers])});
|
||||
}
|
||||
|
||||
/** 忠实旧 min_replaceAll 链:- → /,# → :,再包成 http://...txt。 */
|
||||
export function decodeGameConfig(value: string): string {
|
||||
if (!value) throw new Error('gameconfig 必须是非空字符串');
|
||||
const decoded = value.split('-').join('/').split('#').join(':');
|
||||
return `http://${decoded}.txt`;
|
||||
}
|
||||
|
||||
export interface ResolveGameServerOptions {
|
||||
readonly configured: string;
|
||||
readonly injectedGameConfig: unknown;
|
||||
}
|
||||
|
||||
/** 注入的 gameconfig 非空时是唯一覆盖源;否则只使用 启动配置源。 */
|
||||
export function resolveGameServer(options: ResolveGameServerOptions): string {
|
||||
const injected = options.injectedGameConfig;
|
||||
if (injected === '' || injected === undefined || injected === null) {
|
||||
if (!options.configured) throw new Error('启动配置未配置 gameserver');
|
||||
return options.configured;
|
||||
}
|
||||
if (typeof injected !== 'string') throw new Error('gameconfig 必须是字符串');
|
||||
return decodeGameConfig(injected);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import type { ConfigFetcher, RemoteConfig } from './remote-config.ts';
|
||||
import { ConfigParseError } from './remote-config.ts';
|
||||
import type { ConfigFetcher } from './remote-config.ts';
|
||||
import { STARTUP_CONFIG } from './profiles.ts';
|
||||
import { ConfigFetchError, ConfigParseError } from './remote-config.ts';
|
||||
|
||||
/** 生产用 ConfigFetcher:全局 fetch 抓取远程配置 txt 并 JSON.parse。 */
|
||||
type FetchImplementation = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/** 生产 fetcher:逐字复刻 get_config 的 POST、空 body、无条件 ? cache bust。 */
|
||||
export class HttpConfigFetcher implements ConfigFetcher {
|
||||
async fetch(url: string): Promise<RemoteConfig> {
|
||||
const resp = await fetch(url, { method: 'GET' });
|
||||
const text = await resp.text();
|
||||
constructor(private readonly fetchImpl: FetchImplementation = globalThis.fetch.bind(globalThis)) {}
|
||||
|
||||
async fetch(gameserver: string, cacheBust: () => string = () => String(Date.now())): Promise<unknown> {
|
||||
const url = `${gameserver}?${cacheBust()}`;
|
||||
const response = await this.fetchImpl(url, { method: 'POST', body: '' });
|
||||
if (response.status !== 200) {
|
||||
throw new ConfigFetchError(`远程配置 HTTP 状态错误: status ${response.status}, url: ${url}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
try {
|
||||
return JSON.parse(text) as RemoteConfig;
|
||||
const data: unknown = JSON.parse(text);
|
||||
if (STARTUP_CONFIG.debugIO) console.log('[Config REMOTE]', url, data);
|
||||
return data;
|
||||
} catch {
|
||||
if (STARTUP_CONFIG.debugIO) console.log('[Config REMOTE]', url, { parseError: 'Invalid JSON', raw: text });
|
||||
throw new ConfigParseError(`远程配置 JSON 解析失败: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,127 @@
|
||||
import type { ChannelIdentity } from './identity.ts';
|
||||
import { DEFAULT_LAUNCH_WAIT_TIME_MS } from './sources/defaults.ts';
|
||||
|
||||
/** 远程配置内层数据(键名逐字一致原 get_paravalue / getConfig_Succ)。 */
|
||||
export interface MarketEntry { marketid: string | number; [k: string]: unknown; }
|
||||
export interface ChannelEntry { channelid: string | number; marketlist?: MarketEntry[]; [k: string]: unknown; }
|
||||
export interface GameEntry { gameid: string | number; channellist?: ChannelEntry[]; [k: string]: unknown; }
|
||||
export interface AgentEntry { agentid: string | number; gamelist?: GameEntry[]; [k: string]: unknown; }
|
||||
export interface RemoteConfigData { agentlist?: AgentEntry[]; [k: string]: unknown; }
|
||||
export interface RemoteConfig { data?: RemoteConfigData; }
|
||||
/** One in-memory view over the JSON root received by legacy getConfig_Succ. */
|
||||
export interface RemoteConfig {
|
||||
readonly raw: Readonly<Record<string, unknown>>;
|
||||
readonly getValue: (name: string) => unknown;
|
||||
}
|
||||
|
||||
/** 远程配置抓取抽象(生产注入 HttpConfigFetcher,测试注入 fake)。 */
|
||||
export interface ConfigFetcher { fetch(url: string): Promise<RemoteConfig>; }
|
||||
export interface ConfigFetcher {
|
||||
fetch(gameserver: string, cacheBust?: () => string): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Legacy initial value -> hall_config merge -> scoped top-level override. */
|
||||
export function resolveLaunchWaitTime(remote: RemoteConfig): number {
|
||||
let value: unknown = DEFAULT_LAUNCH_WAIT_TIME_MS;
|
||||
const hall = remote.getValue('hall_config');
|
||||
if (typeof hall === 'object' && hall !== null && Object.prototype.hasOwnProperty.call(hall, 'launchWaitTime')) {
|
||||
value = (hall as Record<string, unknown>).launchWaitTime;
|
||||
}
|
||||
const override = remote.getValue('launchWaitTime');
|
||||
if (override !== null && override !== undefined) value = override;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error('Invalid launchWaitTime: expected finite nonnegative milliseconds');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class ConfigFetchError extends Error {}
|
||||
export class ConfigParseError extends Error {}
|
||||
|
||||
/**
|
||||
* 分层取参,忠实原 get_paravalue:顶层 data[key] 为默认,逐层进入匹配项;
|
||||
* 某层有该 key(truthy)则更新返回值;匹配链中断则返回当前最具体值。
|
||||
*/
|
||||
export function getParam(config: RemoteConfig, key: string, id: ChannelIdentity): unknown {
|
||||
const data = config.data;
|
||||
if (!data) return null;
|
||||
let value: unknown = data[key] ? data[key] : null;
|
||||
const SCOPES = [
|
||||
['agentlist', 'agentid'], ['gamelist', 'gameid'],
|
||||
['channellist', 'channelid'], ['marketlist', 'marketid'],
|
||||
] as const;
|
||||
|
||||
const descend = <T extends Record<string, unknown>>(
|
||||
list: T[] | undefined, idKey: string, idVal: unknown,
|
||||
): T | null => {
|
||||
if (!list) return null;
|
||||
for (const item of list) {
|
||||
if (item[idKey] == idVal) { // 宽松比较,id 可能 string/number
|
||||
if (item[key]) value = item[key];
|
||||
return item;
|
||||
type ConfigObject = Record<string, unknown>;
|
||||
function requireObject(value: unknown, path: string): ConfigObject {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new ConfigParseError(`Invalid remote config ${path}: expected object`);
|
||||
}
|
||||
return value as ConfigObject;
|
||||
}
|
||||
|
||||
function validateHierarchy(scope: ConfigObject, depth: number): void {
|
||||
if (depth === SCOPES.length) return;
|
||||
const [listName] = SCOPES[depth];
|
||||
if (!Object.prototype.hasOwnProperty.call(scope, listName)) return;
|
||||
const list = scope[listName];
|
||||
if (!Array.isArray(list)) throw new ConfigParseError(`Invalid remote config ${listName}: expected array`);
|
||||
for (const entry of list) validateHierarchy(requireObject(entry, listName), depth + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of Game_Surface_3/12_Logic.js get_paravalue. Only GameData globals are
|
||||
* replaced with the resolved identity argument; lookup order and truthiness stay exact.
|
||||
* createRemoteConfig validates the downloaded JSON before exposing this reader.
|
||||
*/
|
||||
export function get_paravalue(
|
||||
o_config: { readonly data?: Readonly<ConfigObject> | null },
|
||||
paraname: string,
|
||||
identity: Readonly<ChannelIdentity>,
|
||||
): unknown {
|
||||
const {agentid, gameid, channelid, marketid} = identity;
|
||||
let paravalue: unknown = null;
|
||||
const do_get_para = (arylist: unknown, keyname: string, keyvalue: string | number): ConfigObject | null => {
|
||||
if (arylist && keyname && keyvalue) {
|
||||
const list = arylist as ConfigObject[];
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i][keyname] == keyvalue) {
|
||||
if (list[i][paraname]) paravalue = list[i][paraname];
|
||||
return list[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const agent = descend(data.agentlist, 'agentid', id.agentid);
|
||||
if (!agent) return value;
|
||||
const game = descend(agent.gamelist, 'gameid', id.gameid);
|
||||
if (!game) return value;
|
||||
const channel = descend(game.channellist, 'channelid', id.channelid);
|
||||
if (!channel) return value;
|
||||
descend(channel.marketlist, 'marketid', id.marketid);
|
||||
return value;
|
||||
if (!o_config.data) return paravalue;
|
||||
if (o_config.data[paraname]) paravalue = o_config.data[paraname];
|
||||
const _o_agent = do_get_para(o_config.data.agentlist, 'agentid', agentid);
|
||||
if (!_o_agent) return paravalue;
|
||||
const _o_game = do_get_para(_o_agent.gamelist, 'gameid', gameid);
|
||||
if (!_o_game) return paravalue;
|
||||
const _o_channel = do_get_para(_o_game.channellist, 'channelid', channelid);
|
||||
if (!_o_channel) return paravalue;
|
||||
const _o_market = do_get_para(_o_channel.marketlist, 'marketid', marketid);
|
||||
if (!_o_market) return paravalue;
|
||||
return paravalue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接相关参数键(键名逐字一致原 getConfig_Succ)。优先级即数组顺序。
|
||||
* 仅取 *_server_tcp:YouleNexus 为 WebSocket-only(原 netType==0 分支);
|
||||
* 原工程的 *_server_http 属 HTTP 传输模式(netType==1),本期不在范围,故有意省略。
|
||||
*/
|
||||
const SERVER_KEYS = ['player_server_tcp', 'visitor_server_tcp', 'game_server_tcp'] as const;
|
||||
|
||||
function toWs(addr: string): string {
|
||||
return (addr.startsWith('ws://') || addr.startsWith('wss://')) ? addr : 'ws://' + addr;
|
||||
/** All consumers share get_paravalue; no separate hierarchy or override rules. */
|
||||
export function createRemoteConfig(input: unknown, identity: Readonly<ChannelIdentity>): RemoteConfig {
|
||||
const raw = requireObject(input, 'root');
|
||||
validateHierarchy(raw, 0);
|
||||
const wrapper = {data: raw};
|
||||
return Object.freeze({
|
||||
raw,
|
||||
getValue(name: string): unknown {
|
||||
return get_paravalue(wrapper, name, identity);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 解析 ws 候选服务器列表(按 SERVER_KEYS 优先级,去重)。 */
|
||||
export function resolveServers(config: RemoteConfig, id: ChannelIdentity): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of SERVER_KEYS) {
|
||||
const v = getParam(config, key, id);
|
||||
if (typeof v === 'string' && v) {
|
||||
const ws = toWs(v);
|
||||
if (!out.includes(ws)) out.push(ws);
|
||||
}
|
||||
function normalizeWebSocketUrl(value: string): string {
|
||||
const url = value.startsWith('ws://') || value.startsWith('wss://') ? value : `ws://${value}`;
|
||||
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value) && !/^wss?:\/\//.test(value)) {
|
||||
throw new ConfigParseError('Invalid remote server: unsupported scheme');
|
||||
}
|
||||
return out;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!value || /\s/.test(value) || url.includes('#') || !parsed.hostname || parsed.username || parsed.password
|
||||
|| (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:')) throw new Error('invalid');
|
||||
} catch {
|
||||
throw new ConfigParseError('Invalid remote server: malformed WebSocket URL');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Legacy getConfig_Succ selects game_server_tcp when truthy, otherwise player_server_tcp. */
|
||||
export function parseUrlServers(config: RemoteConfig): readonly string[] {
|
||||
const raw = config.getValue('game_server_tcp') || config.getValue('player_server_tcp');
|
||||
const values = typeof raw === 'string' ? [raw] : raw;
|
||||
if (!Array.isArray(values) || values.length === 0 || values.some(value => typeof value !== 'string' || !value)) {
|
||||
throw new ConfigParseError('Remote game_server_tcp/player_server_tcp must be a nonempty string or string array');
|
||||
}
|
||||
return Object.freeze(values.map(normalizeWebSocketUrl));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Explicit client lifecycle budget; no protocol fields or game rules are inferred. */
|
||||
export const ROOM_LIFECYCLE_CONFIG=Object.freeze({capacity:256,maxWaitMs:30000});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "57882ffe-3e00-46d9-ab47-362e6dd4467d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ChannelIdentity } from './identity.ts';
|
||||
import { resolveIdentity } from './identity.ts';
|
||||
import {
|
||||
STARTUP_CONFIG,
|
||||
validateStartupConfig,
|
||||
resolveGameServer,
|
||||
type StartupConfig,
|
||||
} from './profiles.ts';
|
||||
import {
|
||||
ConfigFetchError,
|
||||
ConfigParseError,
|
||||
createRemoteConfig,
|
||||
parseUrlServers,
|
||||
type ConfigFetcher,
|
||||
type RemoteConfig,
|
||||
} from './remote-config.ts';
|
||||
import type { RuntimeMode } from './runtime-mode.ts';
|
||||
import {
|
||||
getothername,
|
||||
nativeIdentitySource,
|
||||
type NativeHost,
|
||||
type NativeHostKind,
|
||||
} from './sources/native-settings.ts';
|
||||
import { queryStringSource } from './sources/query-string.ts';
|
||||
|
||||
export type HostKind = 'h5' | NativeHostKind;
|
||||
|
||||
export interface RuntimeConfig {
|
||||
readonly startupConfig: Readonly<StartupConfig>;
|
||||
readonly mode: RuntimeMode;
|
||||
readonly isDebugger: boolean;
|
||||
readonly identity: Readonly<ChannelIdentity>;
|
||||
readonly servers: readonly string[];
|
||||
readonly source: 'remote' | 'local';
|
||||
/** 实际读取入口:remote 为最终 URL,local 为 resources 资源路径。 */
|
||||
readonly gameserver: string;
|
||||
readonly rawConfig: RemoteConfig['raw'];
|
||||
readonly remoteConfig: RemoteConfig;
|
||||
}
|
||||
|
||||
export interface ResolveRuntimeConfigOptions {
|
||||
readonly buildIdentity: Readonly<ChannelIdentity>;
|
||||
readonly startupConfig?: Readonly<StartupConfig>;
|
||||
readonly hostKind: HostKind;
|
||||
readonly win: NativeHost;
|
||||
readonly search: string;
|
||||
readonly fetcher: ConfigFetcher;
|
||||
/** 由引擎层注入项目 txt 读取器;仅 local 模式需要,缺失直接报错。 */
|
||||
readonly localFetcher?: ConfigFetcher;
|
||||
readonly cacheBust?: () => string;
|
||||
}
|
||||
|
||||
function resolveHostIdentity(options: ResolveRuntimeConfigOptions): Readonly<ChannelIdentity> {
|
||||
if (!options.buildIdentity || typeof options.buildIdentity !== 'object') throw Error('Missing buildIdentity from application composition');
|
||||
const buildIdentity = resolveIdentity([() => options.buildIdentity]);
|
||||
if (options.hostKind === 'h5') {
|
||||
return resolveIdentity([() => buildIdentity, queryStringSource(options.search)]);
|
||||
}
|
||||
return resolveIdentity([
|
||||
() => ({gameid:buildIdentity.gameid,version:buildIdentity.version}),
|
||||
nativeIdentitySource(options.win, options.hostKind),
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveInjectedGameConfig(options: ResolveRuntimeConfigOptions): unknown {
|
||||
if (options.hostKind === 'h5') {
|
||||
return new URLSearchParams(options.search).get('gameconfig') ?? '';
|
||||
}
|
||||
return getothername(options.win, options.hostKind, 'gameconfig');
|
||||
}
|
||||
|
||||
function freezeResult(result: RuntimeConfig): RuntimeConfig {
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
export async function resolveRuntimeConfig(
|
||||
options: ResolveRuntimeConfigOptions,
|
||||
): Promise<RuntimeConfig> {
|
||||
const startupConfig = validateStartupConfig(options.startupConfig === undefined ? STARTUP_CONFIG : options.startupConfig);
|
||||
const identity = resolveHostIdentity(options);
|
||||
|
||||
// 先选择唯一来源;本地模式不读取或应用宿主的远程 gameconfig 覆盖。
|
||||
const local = startupConfig.configSource === 'local';
|
||||
const gameserver = local ? startupConfig.localConfigResource : resolveGameServer({
|
||||
configured: startupConfig.gameserver,
|
||||
injectedGameConfig: resolveInjectedGameConfig(options),
|
||||
});
|
||||
|
||||
const fetcher = local ? options.localFetcher : options.fetcher;
|
||||
if (!fetcher) throw new ConfigFetchError('本地配置缺少 localFetcher');
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await fetcher.fetch(gameserver, options.cacheBust);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigFetchError || error instanceof ConfigParseError) throw error;
|
||||
throw new ConfigFetchError(`${local ? '本地配置读取失败' : '远程配置抓取失败'}: ${gameserver}`);
|
||||
}
|
||||
const remoteConfig = createRemoteConfig(raw, identity);
|
||||
const servers = startupConfig.useLocalServer ? startupConfig.localServers : parseUrlServers(remoteConfig);
|
||||
|
||||
return freezeResult({
|
||||
startupConfig,
|
||||
mode: startupConfig.mode,
|
||||
isDebugger: startupConfig.mode === 'debug',
|
||||
identity,
|
||||
servers,
|
||||
source: startupConfig.configSource,
|
||||
gameserver,
|
||||
rawConfig: remoteConfig.raw,
|
||||
remoteConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4e90f0c8-b271-40a4-9909-68aa8f7d5974",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -1,20 +1,7 @@
|
||||
export type RuntimeMode = 'debug' | 'release';
|
||||
|
||||
/**
|
||||
* 手动覆盖运行模式。
|
||||
* null = 自动跟随构建类型(推荐,发布前保持 null)。
|
||||
* 'debug' / 'release' = 强制该模式(release 包里临时调试,或编辑器预览里验证正式行为)。
|
||||
*/
|
||||
export const MODE_OVERRIDE: RuntimeMode | null = null;
|
||||
|
||||
/**
|
||||
* 解析运行模式:override 优先,否则由是否 debug 构建决定。
|
||||
* isDebugBuild 由调用方读 Cocos 全局 DEBUG 传入(typeof DEBUG !== 'undefined' ? DEBUG : true)。
|
||||
*/
|
||||
export function resolveRuntimeMode(
|
||||
isDebugBuild: boolean,
|
||||
override: RuntimeMode | null = MODE_OVERRIDE,
|
||||
): RuntimeMode {
|
||||
if (override) return override;
|
||||
return isDebugBuild ? 'debug' : 'release';
|
||||
/** 应用模式仅来自显式配置,不读取 URL 或 Creator 编译标志。 */
|
||||
export function resolveRuntimeMode(mode: RuntimeMode): RuntimeMode {
|
||||
if (mode !== 'debug' && mode !== 'release') throw new Error('Invalid startup config mode: must be debug or release');
|
||||
return mode;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,2 @@
|
||||
import type { ChannelIdentity } from '../identity.ts';
|
||||
|
||||
/**
|
||||
* 构建期默认渠道身份(旧 version.js 等价物)。
|
||||
* gameid 每子游戏固定写死;agentid/channelid/marketid/version 为默认,运行期由 query/native/profile 覆盖。
|
||||
*/
|
||||
export const BUILD_IDENTITY: ChannelIdentity = {
|
||||
agentid: 'veRa0qrBf0df2K1G4de2tgfmVxB2jxpv',
|
||||
channelid: 'FtJf073aa0d6rI1xD8J1Y42fINTm0ziK',
|
||||
gameid: 'jzWe0kzmN0tAcZ5MLks3rFWcxNecogyu',
|
||||
marketid: 4,
|
||||
version: 10000, // 数字 versionCode(联调时改成本地服要求的版本号)
|
||||
};
|
||||
/** Legacy launchWaitTime, in milliseconds. */
|
||||
export const DEFAULT_LAUNCH_WAIT_TIME_MS = 10;
|
||||
|
||||
+51
-21
@@ -1,40 +1,70 @@
|
||||
import type { ChannelIdentity, IdentitySource } from '../identity.ts';
|
||||
import type { IdentitySource } from '../identity.ts';
|
||||
import { logNativeCall } from '../../core/native-call-log.ts';
|
||||
|
||||
/** 原生注入宿主(≈ window)。settings 由原生注入;全局 app_* 为 uAgent_3 路径回退。 */
|
||||
export type NativeHostKind = 'native-settings' | 'uAgent_3';
|
||||
|
||||
/** window.settings 与 app_* 均由原生注入,名称不得改写。 */
|
||||
export interface NativeHost {
|
||||
settings?: {
|
||||
getothername?(name: string): string | number;
|
||||
getchannelName?(): string | number;
|
||||
getmarketname?(): string | number;
|
||||
};
|
||||
[global: string]: unknown; // app_agent / app_market 等
|
||||
[global: string]: unknown;
|
||||
}
|
||||
|
||||
function tryRead(primary: () => unknown, fallback: () => unknown): unknown {
|
||||
function hasValue(value: unknown): boolean {
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
}
|
||||
|
||||
/** 忠实 Func.getothername:非 uAgent_3 的调用异常在源边界变成空串。 */
|
||||
export function getothername(win: NativeHost, hostKind: NativeHostKind, name: string): unknown {
|
||||
if (hostKind === 'uAgent_3') return win[`app_${name}`];
|
||||
logNativeCall('settings','getothername',[name],!!win.settings?.getothername);
|
||||
try {
|
||||
const v = primary();
|
||||
if (v !== undefined && v !== null && v !== '') return v;
|
||||
} catch { /* 原生桥不存在,走回退 */ }
|
||||
try { return fallback(); } catch { return undefined; }
|
||||
return win.settings!.getothername!(name);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function has(v: unknown): boolean { return v !== undefined && v !== null && v !== ''; }
|
||||
/** 原生 getchannelName 的既有缺省语义只存在于此适配器。 */
|
||||
export function getchannelName(win: NativeHost, hostKind: NativeHostKind): unknown {
|
||||
logNativeCall('settings','getchannelName',[],!!win.settings?.getchannelName);
|
||||
try {
|
||||
return win.settings!.getchannelName!() || '';
|
||||
} catch {
|
||||
return hostKind === 'uAgent_3' ? win.app_channel : '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 原生 getmarketname 的既有缺省语义只存在于此适配器。 */
|
||||
export function getmarketname(win: NativeHost, hostKind: NativeHostKind): unknown {
|
||||
logNativeCall('settings','getmarketname',[],!!win.settings?.getmarketname);
|
||||
try {
|
||||
return win.settings!.getmarketname!();
|
||||
} catch {
|
||||
return hostKind === 'uAgent_3' ? win.app_market : '4';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原生模式:同步读取渠道身份。逐字对齐原 05_Func.js:
|
||||
* agent → window.settings.getothername('agent') | window.app_agent
|
||||
* channel → window.settings.getchannelName()
|
||||
* market → window.settings.getmarketname() | window.app_market
|
||||
* 只产出宿主负责的 agent/channel/market;game/version 由构建身份源负责。
|
||||
* uAgent_3 全部走 app_<name>,不触碰 settings。
|
||||
*/
|
||||
export function nativeSettingsSource(win: NativeHost): IdentitySource {
|
||||
export function nativeIdentitySource(win: NativeHost, hostKind: NativeHostKind): IdentitySource {
|
||||
return () => {
|
||||
const out: Partial<ChannelIdentity> = {};
|
||||
const agentid = tryRead(() => win.settings?.getothername?.('agent'), () => win['app_agent']);
|
||||
if (has(agentid)) out.agentid = agentid as string | number;
|
||||
const channelid = tryRead(() => win.settings?.getchannelName?.(), () => undefined);
|
||||
if (has(channelid)) out.channelid = channelid as string | number;
|
||||
const marketid = tryRead(() => win.settings?.getmarketname?.(), () => win['app_market']);
|
||||
if (has(marketid)) out.marketid = marketid as string | number;
|
||||
const agentid = getothername(win, hostKind, 'agent');
|
||||
const channelid = hostKind === 'uAgent_3'
|
||||
? getothername(win, hostKind, 'channel')
|
||||
: getchannelName(win, hostKind);
|
||||
const marketid = hostKind === 'uAgent_3'
|
||||
? getothername(win, hostKind, 'market')
|
||||
: getmarketname(win, hostKind);
|
||||
const out: Record<string, unknown> = {};
|
||||
if (hasValue(agentid)) out.agentid = agentid;
|
||||
if (hasValue(channelid)) out.channelid = channelid;
|
||||
if (hasValue(marketid)) out.marketid = marketid;
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Keep the full argument list (including undefined and callbacks) visible in browser devtools. */
|
||||
export function logNativeCall(channel:string,method:string,args:readonly unknown[],available:boolean):void {
|
||||
console.info('[NativeCall]',{channel,method,args:[...args],available});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "0fa0b492-08ce-49dc-9e19-ef0a57a49889",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Fields consumed from the legacy get_player_task response. */
|
||||
export interface TaskData {readonly taskid:number|string;readonly title:string;readonly award:number;readonly finish:number;readonly total:number;readonly state:number;}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f8664184-a773-44c3-85cd-dacfef19adbc",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type {Roomtype} from '../../sdk/contracts/roomtype.ts';
|
||||
export type VipCode = string | number;
|
||||
/** min_ary_indexof in the legacy source compares short codes with ==. */
|
||||
export const sameVipCode=(left:VipCode,right:VipCode):boolean=>left==right;
|
||||
export interface VipRoom {
|
||||
readonly shortCode: VipCode;
|
||||
readonly title: string;
|
||||
readonly owner: string;
|
||||
readonly roomcodes: { readonly roomtype: Roomtype; readonly roomlist: readonly VipCode[] } | null;
|
||||
readonly playerList: readonly (readonly unknown[])[];
|
||||
readonly deskState: readonly unknown[];
|
||||
readonly seatCount: readonly VipCode[];
|
||||
readonly forbidSelect: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "3bbb5831-3303-4a17-accf-4cd18dae1797",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -1,4 +1,24 @@
|
||||
import type { Transport } from './transport.ts';
|
||||
import { STARTUP_CONFIG } from '../config/profiles.ts';
|
||||
|
||||
function logConnection(state:string,ws:WebSocket,detail:Record<string,unknown>={}):void{
|
||||
if(STARTUP_CONFIG.debugIO)console.log(`[WebSocket] ${state}`,{state,url:ws.url,readyState:ws.readyState,time:new Date().toISOString(),...detail});
|
||||
}
|
||||
|
||||
/** 日志解析不改变传输内容;非法 JSON 保留原始帧供协议层报错。 */
|
||||
function logFrame(tag: string, url: string, frame: unknown): void {
|
||||
if (!STARTUP_CONFIG.debugIO) return;
|
||||
let data = frame;
|
||||
if (typeof frame === 'string') {
|
||||
try { data = JSON.parse(frame); }
|
||||
catch { data = { parseError: 'Invalid JSON', raw: frame }; }
|
||||
}
|
||||
const packet = data !== null && typeof data === 'object' ? data as Record<string, unknown> : null;
|
||||
const route = packet && typeof packet.route === 'string' ? packet.route : '';
|
||||
const rpc = packet && typeof packet.rpc === 'string' ? packet.rpc : '';
|
||||
const com = packet && typeof packet.com === 'string' ? packet.com : '';
|
||||
// console.log(`${tag}${route || rpc ? ` ${route}/${rpc}` : com ? ` ${com}` : ''}`, url, data);
|
||||
}
|
||||
|
||||
/** 生产用 Transport:包装运行时全局 WebSocket(Cocos 原生/浏览器)。docs/protocol/01 §2。 */
|
||||
export class CocosWebSocketTransport implements Transport {
|
||||
@@ -10,13 +30,21 @@ export class CocosWebSocketTransport implements Transport {
|
||||
connect(url: string): void {
|
||||
const ws = new WebSocket(url); // url 形如 ws://ip:port
|
||||
this.ws = ws;
|
||||
ws.onopen = () => this.openCb?.();
|
||||
ws.onmessage = (ev: MessageEvent) => this.msgCb?.(typeof ev.data === 'string' ? ev.data : String(ev.data));
|
||||
ws.onclose = () => this.closeCb?.();
|
||||
ws.onerror = () => { try { ws.close(); } catch { /* ignore */ } };
|
||||
logConnection('连接中',ws);
|
||||
ws.onopen = () => {logConnection('已连接',ws);this.openCb?.();};
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
logFrame('[收包 S→C]', url, ev.data);
|
||||
this.msgCb?.(typeof ev.data === 'string' ? ev.data : String(ev.data));
|
||||
};
|
||||
ws.onclose = (event:CloseEvent) => {logConnection('已断开',ws,{code:event.code,reason:event.reason,wasClean:event.wasClean});this.closeCb?.();};
|
||||
ws.onerror = (event:Event) => {logConnection('连接错误',ws,{eventType:event.type});try { ws.close(); } catch { /* ignore */ } };
|
||||
}
|
||||
send(frame: string): void { this.ws?.send(frame); }
|
||||
close(): void { try { this.ws?.close(); } catch { /* ignore */ } }
|
||||
send(frame: string): void {
|
||||
if (!this.ws) return;
|
||||
this.ws.send(frame);
|
||||
logFrame('[发包 C→S]', this.ws.url, frame);
|
||||
}
|
||||
close(): void { if(this.ws)logConnection('客户端请求关闭',this.ws);try { this.ws?.close(); } catch { /* ignore */ } }
|
||||
onOpen(cb: () => void): void { this.openCb = cb; }
|
||||
onMessage(cb: (frame: string) => void): void { this.msgCb = cb; }
|
||||
onClose(cb: () => void): void { this.closeCb = cb; }
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import {
|
||||
APP,
|
||||
RECONNECT_INTERVAL_MS,
|
||||
RECV_TIMEOUT_MS,
|
||||
SERVER_ROTATE_EVERY,
|
||||
} from '../core/constants.ts';
|
||||
import type { InboundMessage, OutboundEnvelope } from '../core/types/envelope.ts';
|
||||
import { decodeFrame } from './envelope-codec.ts';
|
||||
import { HeartbeatWatchdog, realClock, type Clock } from './heartbeat.ts';
|
||||
import { ReconnectPolicy } from './reconnect.ts';
|
||||
import type { Transport } from './transport.ts';
|
||||
import { STARTUP_CONFIG } from '../config/profiles.ts';
|
||||
|
||||
export type WireEvent =
|
||||
| { readonly type: 'open'; readonly server: string }
|
||||
| { readonly type: 'message'; readonly message: InboundMessage }
|
||||
| { readonly type: 'slow' }
|
||||
| { readonly type: 'reconnecting'; readonly server: string }
|
||||
| { readonly type: 'close' };
|
||||
|
||||
export interface WireClientOptions {
|
||||
readonly servers: readonly string[];
|
||||
readonly transportFactory: () => Transport;
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
type WireListener = (event: WireEvent) => void;
|
||||
|
||||
function assertSwitchTarget(value: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error('switchServer requires a valid WebSocket URL');
|
||||
}
|
||||
if ((parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') || parsed.host.length === 0) {
|
||||
throw new Error('switchServer requires a valid WebSocket URL');
|
||||
}
|
||||
}
|
||||
|
||||
function invalidEnvelope(path: string, reason: string): never {
|
||||
throw new Error(`invalid outbound envelope at ${path}: ${reason}`);
|
||||
}
|
||||
|
||||
function childPath(parent: string, key: string): string {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
|
||||
? `${parent}.${key}`
|
||||
: `${parent}[${JSON.stringify(key)}]`;
|
||||
}
|
||||
|
||||
interface CapturedProperty {
|
||||
readonly key: string | symbol;
|
||||
readonly descriptor: PropertyDescriptor;
|
||||
}
|
||||
|
||||
interface CapturedNode {
|
||||
readonly source: object;
|
||||
readonly path: string;
|
||||
readonly prototype: object | null;
|
||||
readonly properties: readonly CapturedProperty[];
|
||||
}
|
||||
|
||||
function propertyPath(parent: string, key: string | symbol): string {
|
||||
return typeof key === 'string' ? childPath(parent, key) : parent;
|
||||
}
|
||||
|
||||
function assertNoInheritedToJson(prototype: object | null, path: string): void {
|
||||
const visited = new Set<object>();
|
||||
let current = prototype;
|
||||
while (current !== null) {
|
||||
if (visited.has(current)) invalidEnvelope(path, 'prototype cycle is not allowed');
|
||||
visited.add(current);
|
||||
if (Object.getOwnPropertyDescriptor(current, 'toJSON')) {
|
||||
invalidEnvelope(path, 'inherited toJSON is not allowed');
|
||||
}
|
||||
current = Object.getPrototypeOf(current);
|
||||
}
|
||||
}
|
||||
|
||||
function copyDescriptor(descriptor: PropertyDescriptor): PropertyDescriptor {
|
||||
if (Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
||||
return {
|
||||
configurable: descriptor.configurable,
|
||||
enumerable: descriptor.enumerable,
|
||||
value: descriptor.value,
|
||||
writable: descriptor.writable,
|
||||
};
|
||||
}
|
||||
return {
|
||||
configurable: descriptor.configurable,
|
||||
enumerable: descriptor.enumerable,
|
||||
get: descriptor.get,
|
||||
set: descriptor.set,
|
||||
};
|
||||
}
|
||||
|
||||
function captureNode(source: object, path: string, audits: CapturedNode[]): CapturedNode {
|
||||
const prototype = Object.getPrototypeOf(source);
|
||||
assertNoInheritedToJson(prototype, path);
|
||||
const keys = Reflect.ownKeys(source);
|
||||
const properties = keys.map((key): CapturedProperty => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor) invalidEnvelope(propertyPath(path, key), 'own property disappeared during validation');
|
||||
return { key, descriptor: copyDescriptor(descriptor) };
|
||||
});
|
||||
const captured = { source, path, prototype, properties };
|
||||
audits.push(captured);
|
||||
return captured;
|
||||
}
|
||||
|
||||
function capturedDataValue(
|
||||
captured: CapturedNode,
|
||||
key: string,
|
||||
path: string,
|
||||
requireEnumerable: boolean,
|
||||
): unknown {
|
||||
const property = captured.properties.find((candidate) => candidate.key === key);
|
||||
if (!property) return invalidEnvelope(path, 'missing own data property');
|
||||
const { descriptor } = property;
|
||||
if (requireEnumerable && !descriptor.enumerable) {
|
||||
return invalidEnvelope(path, 'property must be enumerable');
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
||||
return invalidEnvelope(path, 'accessor properties are not allowed');
|
||||
}
|
||||
return descriptor.value;
|
||||
}
|
||||
|
||||
function descriptorMatches(before: PropertyDescriptor, after: PropertyDescriptor): boolean {
|
||||
if (before.configurable !== after.configurable || before.enumerable !== after.enumerable) {
|
||||
return false;
|
||||
}
|
||||
const beforeIsData = Object.prototype.hasOwnProperty.call(before, 'value');
|
||||
const afterIsData = Object.prototype.hasOwnProperty.call(after, 'value');
|
||||
if (beforeIsData !== afterIsData) return false;
|
||||
if (beforeIsData) {
|
||||
return before.writable === after.writable && Object.is(before.value, after.value);
|
||||
}
|
||||
return before.get === after.get && before.set === after.set;
|
||||
}
|
||||
|
||||
function verifyStableGraph(audits: readonly CapturedNode[]): void {
|
||||
for (const captured of audits) {
|
||||
const prototype = Object.getPrototypeOf(captured.source);
|
||||
if (!Object.is(prototype, captured.prototype)) {
|
||||
invalidEnvelope(captured.path, 'prototype changed during validation');
|
||||
}
|
||||
assertNoInheritedToJson(prototype, captured.path);
|
||||
|
||||
const keys = Reflect.ownKeys(captured.source);
|
||||
if (
|
||||
keys.length !== captured.properties.length
|
||||
|| keys.some((key, index) => !Object.is(key, captured.properties[index]!.key))
|
||||
) {
|
||||
invalidEnvelope(captured.path, 'own keys changed during validation');
|
||||
}
|
||||
for (let index = 0; index < keys.length; index++) {
|
||||
const key = keys[index]!;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(captured.source, key);
|
||||
if (!descriptor || !descriptorMatches(captured.properties[index]!.descriptor, descriptor)) {
|
||||
invalidEnvelope(propertyPath(captured.path, key), 'property descriptor changed during validation');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotJsonValue(
|
||||
value: unknown,
|
||||
path: string,
|
||||
ancestors: Set<object>,
|
||||
audits: CapturedNode[],
|
||||
snapshots: Map<object, unknown>,
|
||||
): unknown {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) invalidEnvelope(path, 'expected finite number');
|
||||
if (Object.is(value, -0)) invalidEnvelope(path, 'negative zero is not protocol JSON data');
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== 'object') {
|
||||
invalidEnvelope(path, `${typeof value} is not protocol JSON data`);
|
||||
}
|
||||
|
||||
const objectValue = value as object;
|
||||
if (ancestors.has(objectValue)) invalidEnvelope(path, 'cycle is not protocol JSON data');
|
||||
if (snapshots.has(objectValue)) return snapshots.get(objectValue);
|
||||
ancestors.add(objectValue);
|
||||
try {
|
||||
const captured = captureNode(objectValue, path, audits);
|
||||
if (Array.isArray(objectValue)) {
|
||||
if (captured.prototype !== Array.prototype) {
|
||||
invalidEnvelope(path, 'expected plain array');
|
||||
}
|
||||
const length = capturedDataValue(captured, 'length', childPath(path, 'length'), false);
|
||||
if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 0) {
|
||||
invalidEnvelope(childPath(path, 'length'), 'expected valid array length');
|
||||
}
|
||||
const allowedKeys = new Set<string>(['length']);
|
||||
const snapshot: unknown[] = [];
|
||||
Object.setPrototypeOf(snapshot, null);
|
||||
snapshots.set(objectValue, snapshot);
|
||||
for (let index = 0; index < (length as number); index++) {
|
||||
const key = String(index);
|
||||
const itemPath = `${path}[${index}]`;
|
||||
allowedKeys.add(key);
|
||||
if (!captured.properties.some((property) => property.key === key)) {
|
||||
invalidEnvelope(itemPath, 'array hole is not protocol JSON data');
|
||||
}
|
||||
const itemSnapshot = snapshotJsonValue(
|
||||
capturedDataValue(captured, key, itemPath, false),
|
||||
itemPath,
|
||||
ancestors,
|
||||
audits,
|
||||
snapshots,
|
||||
);
|
||||
Object.defineProperty(snapshot, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: itemSnapshot,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
for (const { key } of captured.properties) {
|
||||
if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed');
|
||||
if (!allowedKeys.has(key)) {
|
||||
invalidEnvelope(childPath(path, key), 'extra array property is not allowed');
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
if (captured.prototype !== Object.prototype && captured.prototype !== null) {
|
||||
invalidEnvelope(path, 'expected plain object or null-prototype object');
|
||||
}
|
||||
const snapshot = Object.create(null) as Record<string, unknown>;
|
||||
snapshots.set(objectValue, snapshot);
|
||||
for (const { key } of captured.properties) {
|
||||
if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed');
|
||||
const nestedPath = childPath(path, key);
|
||||
const nestedSnapshot = snapshotJsonValue(
|
||||
capturedDataValue(captured, key, nestedPath, true),
|
||||
nestedPath,
|
||||
ancestors,
|
||||
audits,
|
||||
snapshots,
|
||||
);
|
||||
Object.defineProperty(snapshot, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: nestedSnapshot,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
return snapshot;
|
||||
} finally {
|
||||
ancestors.delete(objectValue);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeOutbound(envelope: OutboundEnvelope): string {
|
||||
if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) {
|
||||
return invalidEnvelope('$', 'expected object');
|
||||
}
|
||||
const audits: CapturedNode[] = [];
|
||||
const snapshots = new Map<object, unknown>();
|
||||
const captured = captureNode(envelope, '$', audits);
|
||||
if (captured.prototype !== Object.prototype && captured.prototype !== null) {
|
||||
return invalidEnvelope('$', 'expected plain object or null-prototype object');
|
||||
}
|
||||
const required = new Set(['app', 'route', 'rpc', 'data']);
|
||||
for (const { key } of captured.properties) {
|
||||
if (typeof key === 'symbol') return invalidEnvelope('$', 'top-level symbol key is not allowed');
|
||||
if (!required.has(key)) return invalidEnvelope(childPath('$', key), 'extra top-level property');
|
||||
}
|
||||
const app = capturedDataValue(captured, 'app', '$.app', true);
|
||||
const route = capturedDataValue(captured, 'route', '$.route', true);
|
||||
const rpc = capturedDataValue(captured, 'rpc', '$.rpc', true);
|
||||
const data = capturedDataValue(captured, 'data', '$.data', true);
|
||||
if (app !== APP) invalidEnvelope('$.app', `expected ${APP}`);
|
||||
if (typeof route !== 'string' || route.length === 0) {
|
||||
invalidEnvelope('$.route', 'expected non-empty string');
|
||||
}
|
||||
if (typeof rpc !== 'string' || rpc.length === 0) {
|
||||
invalidEnvelope('$.rpc', 'expected non-empty string');
|
||||
}
|
||||
const snapshot = Object.create(null) as Record<string, unknown>;
|
||||
snapshots.set(envelope, snapshot);
|
||||
const ancestors = new Set<object>([envelope]);
|
||||
let dataSnapshot: unknown;
|
||||
try {
|
||||
dataSnapshot = snapshotJsonValue(data, '$.data', ancestors, audits, snapshots);
|
||||
} finally {
|
||||
ancestors.delete(envelope);
|
||||
}
|
||||
const snapshotValues = new Map<string, unknown>([
|
||||
['app', app],
|
||||
['route', route],
|
||||
['rpc', rpc],
|
||||
['data', dataSnapshot],
|
||||
]);
|
||||
for (const { key } of captured.properties) {
|
||||
Object.defineProperty(snapshot, key as string, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: snapshotValues.get(key as string),
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
verifyStableGraph(audits);
|
||||
const frame = JSON.stringify(snapshot);
|
||||
if (typeof frame !== 'string') return invalidEnvelope('$', 'not JSON serializable');
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只拥有 WebSocket 生命周期、帧编解码、收包看门狗和重连策略。
|
||||
* 登录、踢人、切服 RPC 等业务意图均作为 message 原样交给唯一上层编排。
|
||||
*/
|
||||
export class WireClient {
|
||||
private readonly transportFactory: () => Transport;
|
||||
private readonly clock: Clock;
|
||||
private readonly policy: ReconnectPolicy;
|
||||
private readonly watchdog: HeartbeatWatchdog;
|
||||
private readonly listeners = new Set<WireListener>();
|
||||
|
||||
private transport: Transport | null = null;
|
||||
private reconnectTimer: unknown = null;
|
||||
private reconnectTarget: string | null = null;
|
||||
private reconnectToken = 0;
|
||||
private generation = 0;
|
||||
private started = false;
|
||||
private stopped = false;
|
||||
private open = false;
|
||||
|
||||
constructor(options: WireClientOptions) {
|
||||
if (!Array.isArray(options.servers) || options.servers.length === 0) {
|
||||
throw new Error('servers must be a non-empty validated list');
|
||||
}
|
||||
this.transportFactory = options.transportFactory;
|
||||
this.clock = options.clock ?? realClock;
|
||||
this.policy = new ReconnectPolicy([...options.servers], SERVER_ROTATE_EVERY);
|
||||
this.watchdog = new HeartbeatWatchdog(
|
||||
RECV_TIMEOUT_MS,
|
||||
() => this.onReceiveTimeout(),
|
||||
this.clock,
|
||||
);
|
||||
}
|
||||
|
||||
subscribe(listener: WireListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started) throw new Error('WireClient already started');
|
||||
if (this.stopped) throw new Error('WireClient already stopped');
|
||||
this.started = true;
|
||||
this.connect(this.policy.current());
|
||||
}
|
||||
|
||||
send(envelope: OutboundEnvelope): void {
|
||||
if (!this.open || this.stopped || this.transport === null) {
|
||||
throw new Error('connection is not open');
|
||||
}
|
||||
const frame = serializeOutbound(envelope);
|
||||
this.logPacket('发送', frame);
|
||||
this.transport.send(frame);
|
||||
}
|
||||
|
||||
switchServer(url: string): void {
|
||||
assertSwitchTarget(url);
|
||||
this.assertRunning();
|
||||
this.policy.setCurrent(url);
|
||||
this.disconnectAndSchedule(this.policy.current());
|
||||
}
|
||||
|
||||
reconnectCurrent(): void {
|
||||
this.assertRunning();
|
||||
const server = this.policy.current();
|
||||
if (this.reconnectTimer !== null && this.reconnectTarget === server) return;
|
||||
this.disconnectAndSchedule(server);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
this.watchdog.stop();
|
||||
this.clearReconnectTimer();
|
||||
|
||||
const transport = this.transport;
|
||||
const hadTransport = transport !== null;
|
||||
this.transport = null;
|
||||
this.open = false;
|
||||
this.generation++;
|
||||
transport?.close();
|
||||
if (hadTransport) this.emit({ type: 'close' });
|
||||
}
|
||||
|
||||
private assertRunning(): void {
|
||||
if (!this.started || this.stopped) throw new Error('WireClient is not running');
|
||||
}
|
||||
|
||||
private emit(event: WireEvent): void {
|
||||
if(STARTUP_CONFIG.debugIO&&event.type!=='message'){
|
||||
const states={open:'已连接',close:'已断开',slow:'收包超时',reconnecting:'正在重连'};
|
||||
console.log(`[连接状态] ${states[event.type]}`,{event:event.type,server:this.policy.current(),connected:this.open,stopped:this.stopped,
|
||||
time:new Date().toISOString(),...(event.type==='slow'?{timeoutMs:RECV_TIMEOUT_MS}:{}),...(event.type==='reconnecting'?{retryAfterMs:RECONNECT_INTERVAL_MS}:{})});
|
||||
}
|
||||
for (const listener of [...this.listeners]) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error('[WireClient] listener error:', error, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private connect(server: string): void {
|
||||
if (this.stopped) return;
|
||||
const generation = ++this.generation;
|
||||
this.transport = null;
|
||||
this.open = false;
|
||||
|
||||
let transport: Transport;
|
||||
try {
|
||||
transport = this.transportFactory();
|
||||
} catch {
|
||||
this.onConnectionFailure(generation, null, false);
|
||||
return;
|
||||
}
|
||||
this.transport = transport;
|
||||
|
||||
transport.onOpen(() => {
|
||||
if (!this.isCurrent(generation, transport)) return;
|
||||
this.open = true;
|
||||
this.emit({ type: 'open', server });
|
||||
});
|
||||
transport.onMessage((frame) => {
|
||||
if (!this.isCurrent(generation, transport) || !this.open) return;
|
||||
this.onMessage(frame);
|
||||
});
|
||||
transport.onClose(() => {
|
||||
if (!this.isCurrent(generation, transport)) return;
|
||||
this.onConnectionFailure(generation, transport, false);
|
||||
});
|
||||
try {
|
||||
transport.connect(server);
|
||||
} catch {
|
||||
this.onConnectionFailure(generation, transport, true);
|
||||
}
|
||||
}
|
||||
|
||||
private isCurrent(generation: number, transport: Transport): boolean {
|
||||
return !this.stopped && generation === this.generation && transport === this.transport;
|
||||
}
|
||||
|
||||
private onMessage(frame: string): void {
|
||||
const decoded = decodeFrame(frame);
|
||||
if (decoded.kind !== 'heartbeat') this.logPacket('接收', frame);
|
||||
if (decoded.kind === 'ignore') return;
|
||||
this.watchdog.feed();
|
||||
if (decoded.kind !== 'message') return;
|
||||
this.emit({ type: 'message', message: decoded.message });
|
||||
}
|
||||
|
||||
private logPacket(direction: '发送' | '接收', frame: string): void {
|
||||
if (STARTUP_CONFIG.debugIO) {
|
||||
console.log(`[收发包] ${direction}`, {server:this.policy.current(),time:new Date().toISOString()}, frame);
|
||||
}
|
||||
}
|
||||
|
||||
private onReceiveTimeout(): void {
|
||||
if (!this.open || this.stopped) return;
|
||||
this.emit({ type: 'slow' });
|
||||
if (!this.open || this.stopped) return;
|
||||
this.disconnectAndSchedule(this.policy.onFailure());
|
||||
}
|
||||
|
||||
private onConnectionFailure(
|
||||
generation: number,
|
||||
transport: Transport | null,
|
||||
closeTransport: boolean,
|
||||
): void {
|
||||
if (this.stopped || generation !== this.generation || transport !== this.transport) return;
|
||||
this.transport = null;
|
||||
this.open = false;
|
||||
const failureGeneration = ++this.generation;
|
||||
this.watchdog.stop();
|
||||
if (closeTransport && transport) transport.close();
|
||||
this.emit({ type: 'close' });
|
||||
if (this.stopped || failureGeneration !== this.generation) return;
|
||||
this.scheduleReconnect(this.policy.onFailure());
|
||||
}
|
||||
|
||||
private disconnectAndSchedule(server: string): void {
|
||||
this.clearReconnectTimer();
|
||||
const transport = this.transport;
|
||||
const hadTransport = transport !== null;
|
||||
this.transport = null;
|
||||
this.open = false;
|
||||
const disconnectGeneration = ++this.generation;
|
||||
this.watchdog.stop();
|
||||
transport?.close();
|
||||
if (hadTransport) this.emit({ type: 'close' });
|
||||
if (
|
||||
this.stopped
|
||||
|| disconnectGeneration !== this.generation
|
||||
|| this.policy.current() !== server
|
||||
) return;
|
||||
this.scheduleReconnect(server);
|
||||
}
|
||||
|
||||
private scheduleReconnect(server: string): void {
|
||||
this.clearReconnectTimer();
|
||||
if (this.stopped || this.policy.current() !== server) return;
|
||||
const token = this.reconnectToken;
|
||||
let handle: unknown = null;
|
||||
handle = this.clock.setTimeout(() => {
|
||||
if (
|
||||
this.stopped
|
||||
|| token !== this.reconnectToken
|
||||
|| this.policy.current() !== server
|
||||
|| this.reconnectTarget !== server
|
||||
|| this.reconnectTimer !== handle
|
||||
) return;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectTarget = null;
|
||||
this.reconnectToken++;
|
||||
this.connect(server);
|
||||
}, RECONNECT_INTERVAL_MS);
|
||||
this.reconnectTimer = handle;
|
||||
this.reconnectTarget = server;
|
||||
this.emit({ type: 'reconnecting', server });
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
this.reconnectToken++;
|
||||
if (this.reconnectTimer !== null) this.clock.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectTarget = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "613b0457-be57-44e3-a1fb-e5d086b69468",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { RuntimeConfig } from '../config/runtime-config.ts';
|
||||
import type {GameRoomProjection} from '../sdk/contracts/game-room-projection.ts';
|
||||
import {gameAllowsRoomAction} from '../sdk/room-action-policy.ts';
|
||||
import { buildFirstSliceEnvelope } from '../protocol/first-slice-routes.ts';
|
||||
import type { OutboundEnvelope } from '../core/types/envelope.ts';
|
||||
import {
|
||||
buildCreateRoomRequest,
|
||||
type CreateRoomEnvironment,
|
||||
buildExitRoomRequest,
|
||||
buildJoinRoomRequest,
|
||||
buildPrepareRequest,
|
||||
type JoinRoomEnvironment,
|
||||
type RoomIdentity,
|
||||
} from '../protocol/contracts/index.ts';
|
||||
import { cloneAndFreezeRoomtype, PlatformStore } from './stores/platform-store.ts';
|
||||
import type { InsideRoomState, PlatformState } from './stores/platform-types.ts';
|
||||
|
||||
/** The complete-envelope send capability used by platform command adapters. */
|
||||
export interface OutboundEnvelopeSendPort {
|
||||
send(envelope: OutboundEnvelope): void;
|
||||
}
|
||||
|
||||
export interface PlatformCommandsOptions {
|
||||
/** Omitted/null explicitly means the game provides no additional restrictions. */
|
||||
readonly getRoomProjection?:()=>GameRoomProjection|null;
|
||||
readonly runtimeConfig: RuntimeConfig;
|
||||
readonly store: PlatformStore;
|
||||
readonly send: OutboundEnvelopeSendPort;
|
||||
}
|
||||
|
||||
export interface JoinRoomCommand extends JoinRoomEnvironment {
|
||||
readonly roomcode: string | number;
|
||||
}
|
||||
|
||||
export interface CreateRoomCommand extends CreateRoomEnvironment {}
|
||||
|
||||
export class UnsupportedGameHostCommandError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'UnsupportedGameHostCommandError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds platform-owned room requests exclusively through the first-slice contracts. */
|
||||
export class PlatformCommands {
|
||||
constructor(private readonly options: PlatformCommandsOptions) {}
|
||||
|
||||
getPlayerInvitecode():void {
|
||||
const playerid=this.taskPlayerId(),player=this.options.store.getState().players.entities[playerid];
|
||||
const {openid,unionid}=player;
|
||||
if(typeof openid!=='string'||!(typeof unionid==='string'||typeof unionid==='number'&&Number.isFinite(unionid)))throw Error('Invite query requires login openid and unionid');
|
||||
this.options.send.send(buildFirstSliceEnvelope('get_player_invitecode',{
|
||||
agentid:this.options.runtimeConfig.identity.agentid,playerid,unionid,openid,
|
||||
}));
|
||||
}
|
||||
|
||||
bindInvitecode(invitecode:string):void {
|
||||
const playerid=this.taskPlayerId();
|
||||
if(typeof invitecode!=='string')throw Error('invitecode must be a string');
|
||||
this.options.send.send(buildFirstSliceEnvelope('binding_invitecode',{
|
||||
agentid:this.options.runtimeConfig.identity.agentid,playerid,invitecode,
|
||||
}));
|
||||
}
|
||||
|
||||
getVipRooms(shortcodeList:readonly (string|number)[]):void {
|
||||
this.taskPlayerId();
|
||||
const {agentid,gameid}=this.options.runtimeConfig.identity;
|
||||
this.options.send.send(buildFirstSliceEnvelope('getInfoByShortCode',{agentid,gameid,shortcodeList:[...shortcodeList]}));
|
||||
}
|
||||
|
||||
joinVipRoom(roomcode:string|number,location:unknown):void {
|
||||
const playerid=this.taskPlayerId(),state=this.options.store.getState();
|
||||
const ip=state.players.entities[playerid].ip;
|
||||
if(typeof ip!=='string')throw Error('VIP join requires player login ip');
|
||||
const {agentid,gameid}=this.options.runtimeConfig.identity;
|
||||
this.options.send.send(buildFirstSliceEnvelope('self_join_room',{agentid,playerid,gameid,roomcode,joinmode:0,location,ip}));
|
||||
}
|
||||
|
||||
getPlayerTasks(): void {
|
||||
const playerid=this.taskPlayerId();
|
||||
const {agentid,gameid}=this.options.runtimeConfig.identity;
|
||||
this.options.send.send(buildFirstSliceEnvelope('get_player_task',{agentid,playerid,gameid}));
|
||||
}
|
||||
|
||||
getVipRankList(limit:number):void {
|
||||
this.taskPlayerId();if(!Number.isInteger(limit)||limit<=0)throw Error('Invalid vipRankLimit');
|
||||
this.options.send.send(buildFirstSliceEnvelope('getVipRankList',{agentid:this.options.runtimeConfig.identity.agentid,limit}));
|
||||
}
|
||||
joinOwnedRoom(roomcode:number,location:unknown):void {
|
||||
const playerid=this.taskPlayerId(),state=this.options.store.getState();
|
||||
if(!Number.isSafeInteger(roomcode)||roomcode<=0)throw Error('Invalid owned roomcode');
|
||||
const ip=state.players.entities[playerid].ip;if(typeof ip!=='string')throw Error('Owned room join requires player login ip');
|
||||
const {agentid,gameid}=this.options.runtimeConfig.identity;
|
||||
this.options.send.send(buildFirstSliceEnvelope('self_join_room',{agentid,playerid,gameid,roomcode,joinmode:1,location,ip}));
|
||||
}
|
||||
getTreasureList():void {
|
||||
this.taskPlayerId();const {agentid,gameid}=this.options.runtimeConfig.identity;
|
||||
this.options.send.send(buildFirstSliceEnvelope('get_treasurelist',{agentid,gameid}));
|
||||
}
|
||||
redeemCard(cardno:string):void {
|
||||
const playerid=this.taskPlayerId();
|
||||
if(typeof cardno!=='string'||cardno===''||/^[ ]+$/.test(cardno))throw Error('Invalid cardno');
|
||||
this.options.send.send(buildFirstSliceEnvelope('topup_card',{agentid:this.options.runtimeConfig.identity.agentid,playerid,cardno}));
|
||||
}
|
||||
|
||||
claimTask(taskid:number|string):void {
|
||||
const playerid=this.taskPlayerId();
|
||||
const task=this.options.store.getState().tasks?.find(task=>String(task.taskid)===String(taskid));
|
||||
if(!task||task.state!==1)throw Error('Task is not claimable');
|
||||
// 11_GameUI claim click sends these three fields; Net._SendData adds none.
|
||||
this.options.send.send(buildFirstSliceEnvelope('get_task_award',{
|
||||
agentid:this.options.runtimeConfig.identity.agentid,playerid,taskid:task.taskid,
|
||||
}));
|
||||
}
|
||||
|
||||
private taskPlayerId():number {
|
||||
const state=this.options.store.getState();
|
||||
if(state.app.phase!=='logged-in'||state.room.kind!=='outside')throw Error('Task command requires logged-in lobby');
|
||||
return this.requireAuthenticatedPlayerId(state);
|
||||
}
|
||||
|
||||
prepare(): void {
|
||||
const state = this.options.store.getState();
|
||||
this.requireInsideRoom(state);
|
||||
this.requireGameAction('prepare');
|
||||
this.options.send.send(buildPrepareRequest(
|
||||
this.options.runtimeConfig,
|
||||
this.roomIdentity(state, this.requireInsideRoom(state)),
|
||||
));
|
||||
}
|
||||
|
||||
sendRoomText(text:string,announcement=false):void {
|
||||
if(typeof text!=='string'||text.length===0)throw Error('room text must be nonempty');
|
||||
this.sendSocial('send_text',{text,type:announcement?1:0});
|
||||
}
|
||||
sendRoomPhrase(text:string):void {
|
||||
if(typeof text!=='string'||text.length===0)throw Error('room phrase must be nonempty');
|
||||
this.sendSocial('send_text',{text});
|
||||
}
|
||||
sendRoomVoice(voiceurl:string,time:string|number):void {
|
||||
if(typeof voiceurl!=='string'||voiceurl.length===0||!(typeof time==='number'||typeof time==='string'&&/^[0-9]+(?:\.[0-9]+)?$/.test(time))||!Number.isFinite(Number(time))||Number(time)<=0)throw Error('room voice requires uploaded URL and positive duration');
|
||||
this.sendSocial('send_voice',{voiceurl,time});
|
||||
}
|
||||
sendRoomExpression(id:number):void {
|
||||
if(!Number.isSafeInteger(id)||id<=0)throw Error('room expression requires positive ID');
|
||||
this.sendSocial('send_phiz',{text:id});
|
||||
}
|
||||
breakRoom():void {
|
||||
const room=this.requireInsideRoom(this.options.store.getState());
|
||||
if(room.stage!==0||room.status!==1)throw Error('breakRoom requires pregame owner');
|
||||
this.sendSocial('self_break_room',{});
|
||||
}
|
||||
surrenderRoom(count:number):void {
|
||||
if(!Number.isFinite(count)||count<=0)throw Error('surrender count must be positive');
|
||||
this.sendSocial('beanroom_surrender',{count});
|
||||
}
|
||||
changeRoom():void {this.sendSocial('change_room',{});}
|
||||
private sendSocial(rpc:'send_text'|'send_voice'|'send_phiz'|'self_break_room'|'change_room'|'beanroom_surrender',data:Readonly<Record<string,unknown>>):void {
|
||||
const state=this.options.store.getState(),room=this.requireInsideRoom(state);
|
||||
if(state.app.phase!=='logged-in')throw Error('room command requires logged-in connection');
|
||||
const {agentid,gameid}=this.options.runtimeConfig.identity;
|
||||
this.options.send.send(buildFirstSliceEnvelope(rpc,{agentid,gameid,...this.roomIdentity(state,room),...data}));
|
||||
}
|
||||
|
||||
applyDissolution():void {this.sendDissolution('self_apply_free_room');}
|
||||
agreeDissolution():void {this.sendDissolution('self_agree_free_room');}
|
||||
refuseDissolution():void {this.sendDissolution('self_refuse_free_room');}
|
||||
|
||||
private sendDissolution(rpc:'self_apply_free_room'|'self_agree_free_room'|'self_refuse_free_room'):void {
|
||||
const state=this.options.store.getState(),room=this.requireInsideRoom(state);
|
||||
if(state.app.phase!=='logged-in')throw Error('dissolution requires logged-in connection');
|
||||
if(rpc==='self_apply_free_room'){
|
||||
if(room.stage!==1)throw Error('dissolution application requires stage=1');
|
||||
if(state.dissolution)throw Error('dissolution vote or result is already present');
|
||||
this.requireGameAction('exit');
|
||||
}else{
|
||||
const vote=state.dissolution;
|
||||
if(vote?.kind!=='active'||vote.participants.find(player=>player.seat===room.selfSeat)?.state!==-1)throw Error('dissolution response requires own waiting vote');
|
||||
}
|
||||
const {agentid,gameid}=this.options.runtimeConfig.identity;
|
||||
this.options.send.send(buildFirstSliceEnvelope(rpc,{agentid,gameid,...this.roomIdentity(state,room)}));
|
||||
}
|
||||
|
||||
exitRoom(): void {
|
||||
const state = this.options.store.getState();
|
||||
const room = this.requireInsideRoom(state);
|
||||
if (room.stage !== 0 && room.infinite !== 1) {
|
||||
throw new UnsupportedGameHostCommandError('room.exit requires stage=0 unless infinite=1');
|
||||
}
|
||||
this.requireGameAction('exit');
|
||||
this.options.send.send(buildExitRoomRequest(
|
||||
this.options.runtimeConfig,
|
||||
this.roomIdentity(state, room),
|
||||
));
|
||||
}
|
||||
|
||||
createRoom(command: CreateRoomCommand): void {
|
||||
const playerid = this.taskPlayerId();
|
||||
const roomtype = cloneAndFreezeRoomtype(command.roomtype);
|
||||
this.options.send.send(buildCreateRoomRequest(this.options.runtimeConfig, playerid, {...command, roomtype}));
|
||||
}
|
||||
|
||||
joinRoom(command: JoinRoomCommand): void {
|
||||
const state = this.options.store.getState();
|
||||
if (state.room.kind !== 'outside') {
|
||||
throw new UnsupportedGameHostCommandError('room.join requires player to be outside a room');
|
||||
}
|
||||
const playerid = this.requireAuthenticatedPlayerId(state);
|
||||
this.options.send.send(buildJoinRoomRequest(
|
||||
this.options.runtimeConfig,
|
||||
{ playerid, roomcode: command.roomcode },
|
||||
command,
|
||||
));
|
||||
}
|
||||
|
||||
private requireInsideRoom(state: PlatformState): InsideRoomState {
|
||||
if (state.room.kind !== 'inside') {
|
||||
throw new UnsupportedGameHostCommandError('room command requires player to be inside a room');
|
||||
}
|
||||
return state.room;
|
||||
}
|
||||
|
||||
private requireGameAction(action:'prepare'|'exit'):void {
|
||||
const projection=this.options.getRoomProjection===undefined?null:this.options.getRoomProjection();
|
||||
if(!gameAllowsRoomAction(true,projection,action))throw new UnsupportedGameHostCommandError(`Game currently disallows room.${action}`);
|
||||
}
|
||||
|
||||
private requireAuthenticatedPlayerId(state: PlatformState): number {
|
||||
const playerid = state.players.selfPlayerId;
|
||||
if (playerid === null) {
|
||||
throw new UnsupportedGameHostCommandError('room command requires an authenticated player');
|
||||
}
|
||||
if (state.players.entities[playerid] === undefined) {
|
||||
throw new Error('players.entities: missing authenticated player');
|
||||
}
|
||||
return playerid;
|
||||
}
|
||||
|
||||
private roomIdentity(state: PlatformState, room: InsideRoomState): RoomIdentity {
|
||||
return {
|
||||
playerid: this.requireAuthenticatedPlayerId(state),
|
||||
roomcode: room.roomcode,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4098ba4c-c444-459f-9427-0412f1a15bb6",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { OutboundEnvelope } from '../core/types/envelope.ts';
|
||||
|
||||
export type OutboundMessage = OutboundEnvelope;
|
||||
|
||||
export type ConnectionIntent =
|
||||
| { readonly type: 'none' }
|
||||
| { readonly type: 'login'; readonly envelope: OutboundMessage }
|
||||
| { readonly type: 'switch'; readonly target: string; readonly resend: OutboundMessage };
|
||||
|
||||
export interface ConnectionControlPort {
|
||||
switchServer(target: string, resend: OutboundMessage): void;
|
||||
stopForKick(data: unknown): void;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "1a6fd8e5-5d0c-4fbb-a035-ef3a4f90badd",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import type {NumericToolsPort,RoomCapabilities,InputResult,InputCancelReason,FeedbackOwnerPort,OwnerHandle} from '../sdk/contracts/game-capabilities.ts';
|
||||
export type {NumericToolsPort} from '../sdk/contracts/game-capabilities.ts';
|
||||
import {roomExitAction} from '../sdk/room-exit-action.ts';
|
||||
import {requireRoomtype} from '../protocol/contracts/validation.ts';
|
||||
import type {Roomtype} from '../sdk/contracts/roomtype.ts';
|
||||
import { APP } from '../core/constants.ts';
|
||||
import type { OutboundEnvelope } from '../core/types/envelope.ts';
|
||||
import type {
|
||||
GameEntry,
|
||||
GameHost,
|
||||
GameHostCommand,
|
||||
GameSeatMapper,
|
||||
PlatformGameSnapshot,
|
||||
} from '../sdk/contracts/index.ts';
|
||||
import { assertGameRoute } from '../sdk/runtime/game-session-host.ts';
|
||||
import {
|
||||
PlatformCommands,
|
||||
UnsupportedGameHostCommandError,
|
||||
type OutboundEnvelopeSendPort,
|
||||
} from './commands.ts';
|
||||
import { PlatformStore } from './stores/platform-store.ts';
|
||||
import { selectGameSnapshot } from './stores/selectors.ts';
|
||||
import type { PlatformState } from './stores/platform-types.ts';
|
||||
|
||||
export class GameSessionDisposedError extends Error {
|
||||
constructor() {
|
||||
super('GameHost session lease is disposed');
|
||||
this.name = 'GameSessionDisposedError';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export interface CreateGameHostAdapterOptions {
|
||||
readonly numericTools?:NumericToolsPort;
|
||||
readonly feedback?:FeedbackOwnerPort;
|
||||
readonly gameEntry: GameEntry;
|
||||
readonly store: PlatformStore;
|
||||
readonly commands: PlatformCommands;
|
||||
readonly send: OutboundEnvelopeSendPort;
|
||||
}
|
||||
|
||||
/** Structurally matches RuntimeSession's per-open GameHostLeasePort. */
|
||||
export interface GameHostLease {
|
||||
readonly host: GameHost;
|
||||
invalidate(): void;
|
||||
}
|
||||
|
||||
interface SubscriptionRecord {
|
||||
active: boolean;
|
||||
unsubscribeStore: (() => void) | null;
|
||||
}
|
||||
|
||||
type SubscriptionReleaseResult =
|
||||
| { readonly threw: false }
|
||||
| { readonly threw: true; readonly error: unknown };
|
||||
|
||||
interface BoundRoomGeneration {
|
||||
readonly roomcode: string;
|
||||
readonly roomtype: Roomtype;
|
||||
readonly selfSeat: number;
|
||||
readonly seatCount: number;
|
||||
}
|
||||
|
||||
class GameHostInvalidationError extends Error {
|
||||
readonly errors: readonly unknown[];
|
||||
|
||||
constructor(errors: readonly unknown[]) {
|
||||
super('GameHost subscription cleanup failed');
|
||||
this.name = 'GameHostInvalidationError';
|
||||
this.errors = Object.freeze([...errors]);
|
||||
}
|
||||
}
|
||||
|
||||
class SessionBoundGameHost {
|
||||
readonly host: GameHost;
|
||||
|
||||
private store: PlatformStore | null;
|
||||
private commands: PlatformCommands | null;
|
||||
private sendPort: OutboundEnvelopeSendPort | null;
|
||||
private route: string | null;
|
||||
private roomGeneration: BoundRoomGeneration | null;
|
||||
private readonly subscriptions = new Set<SubscriptionRecord>();
|
||||
private invalidationErrors: unknown[] | null = null;
|
||||
private disposed = false;
|
||||
private numericTools:NumericToolsPort|null;
|
||||
private feedback:FeedbackOwnerPort|null;
|
||||
private numericToolsUsed=false;
|
||||
private inputGeneration=0;
|
||||
private multiplierGeneration=0;
|
||||
private readonly cleanups=new Set<()=>void>();
|
||||
private readonly pendingInputs=new Map<'input'|'multiplier',{cancel(reason:InputCancelReason):void}>();
|
||||
|
||||
constructor(options: CreateGameHostAdapterOptions) {
|
||||
this.numericTools=options.numericTools??null;this.feedback=options.feedback??null;
|
||||
const route = options.gameEntry.route;
|
||||
assertGameRoute(route);
|
||||
const state = options.store.getState();
|
||||
if (state.room.kind !== 'inside') {
|
||||
throw new Error('GameHost requires player to be inside a room');
|
||||
}
|
||||
const seatCount = options.gameEntry.resolveSeatCount(state.room.roomtype);
|
||||
requireSeatCount(seatCount, state.room.seatPlayerIds.length);
|
||||
const selfSeat = requireSeat(state.room.selfSeat, seatCount, 'self seat');
|
||||
|
||||
this.store = options.store;
|
||||
this.commands = options.commands;
|
||||
this.sendPort = options.send;
|
||||
this.route = route;
|
||||
this.roomGeneration = {
|
||||
roomcode: state.room.roomcode,
|
||||
roomtype: state.room.roomtype,
|
||||
selfSeat,
|
||||
seatCount,
|
||||
};
|
||||
|
||||
const seat: GameSeatMapper = Object.freeze({
|
||||
toView: (serverSeat: number): number => {
|
||||
this.requireCurrentRoom();
|
||||
const validated = requireSeat(serverSeat, seatCount, 'server seat');
|
||||
return (validated - selfSeat + seatCount) % seatCount;
|
||||
},
|
||||
toServer: (viewSeat: number): number => {
|
||||
this.requireCurrentRoom();
|
||||
const validated = requireSeat(viewSeat, seatCount, 'view seat');
|
||||
return (validated + selfSeat) % seatCount;
|
||||
},
|
||||
});
|
||||
|
||||
const capabilities:RoomCapabilities=Object.freeze({
|
||||
room:Object.freeze({seat,getSnapshot:()=>this.getSnapshot(),subscribe:(listener:(snapshot:PlatformGameSnapshot)=>void)=>this.subscribe(listener),
|
||||
prepare:()=>this.execute({type:'room.prepare'}),requestExit:()=>this.execute({type:roomExitAction(this.getSnapshot().room).command}),
|
||||
applyDissolution:()=>this.execute({type:'room.apply-dissolution'})}),
|
||||
messages:Object.freeze({send:(rpc:string,data:import('../sdk/contracts/roomtype.ts').JsonValue)=>{this.requireCurrentRoom();requireRoomtype(data,'$.game.data');this.sendGameMessage(rpc,data);}}),
|
||||
ui:Object.freeze({beginLoading:()=>this.ownFeedback(port=>port.beginLoading()),showBusinessTip:(message:string,time:number)=>this.ownFeedback(port=>port.showBusinessTip(message,time)),requestNumber:(request:{message:string})=>this.requestNumber<number>(request.message,false),
|
||||
requestDigitText:(request:{message:string})=>this.requestNumber<string>(request.message,true),
|
||||
requestMultiplier:(request:{values:readonly number[]})=>this.requestMultiplier(request.values),
|
||||
cancelNumber:()=>this.execute({type:'ui.close-number-input'}),cancelMultiplier:()=>this.execute({type:'ui.hide-multiplier'})}),
|
||||
scope:Object.freeze({get active(){return owner.isActiveRoom();},defer:(cleanup:()=>void)=>this.defer(cleanup)}),
|
||||
});
|
||||
const owner=this;
|
||||
this.host = Object.freeze({
|
||||
capabilities,
|
||||
seat,
|
||||
getSnapshot: (): PlatformGameSnapshot => this.getSnapshot(),
|
||||
subscribe: (listener: (snapshot: PlatformGameSnapshot) => void): (() => void) => (
|
||||
this.subscribe(listener)
|
||||
),
|
||||
sendGameMessage: (rpc: string, data: unknown): void => this.sendGameMessage(rpc, data),
|
||||
execute: (command: GameHostCommand): void => this.execute(command),
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
|
||||
const subscriptions = [...this.subscriptions];
|
||||
this.subscriptions.clear();
|
||||
this.store = null;
|
||||
this.commands = null;
|
||||
this.sendPort = null;
|
||||
this.route = null;
|
||||
this.feedback=null;
|
||||
this.roomGeneration = null;
|
||||
|
||||
const errors: unknown[] = [];
|
||||
for(const pending of this.pendingInputs.values())pending.cancel('scope-ended');
|
||||
this.pendingInputs.clear();
|
||||
for(const cleanup of [...this.cleanups].reverse()){this.cleanups.delete(cleanup);try{cleanup();}catch(error){errors.push(error);}}
|
||||
const numericTools=this.numericTools;
|
||||
this.numericTools=null;
|
||||
this.inputGeneration++;this.multiplierGeneration++;
|
||||
if(this.numericToolsUsed&&numericTools!==null){
|
||||
try{numericTools.close();}catch(error){errors.push(error);}
|
||||
}
|
||||
this.invalidationErrors = errors;
|
||||
try {
|
||||
for (const record of subscriptions) {
|
||||
const result = this.releaseSubscription(record);
|
||||
if (result.threw) errors.push(result.error);
|
||||
}
|
||||
} finally {
|
||||
this.invalidationErrors = null;
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new GameHostInvalidationError(errors);
|
||||
}
|
||||
|
||||
private getSnapshot(): PlatformGameSnapshot {
|
||||
return selectGameSnapshot(this.requireCurrentRoom());
|
||||
}
|
||||
|
||||
private subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void {
|
||||
this.requireActive();
|
||||
if (typeof listener !== 'function') {
|
||||
throw new TypeError('GameHost subscribe listener must be a function');
|
||||
}
|
||||
const store = this.requireStore();
|
||||
let current = selectGameSnapshot(this.requireCurrentRoom());
|
||||
const record: SubscriptionRecord = { active: true, unsubscribeStore: null };
|
||||
const bridge = (state: PlatformState): void => {
|
||||
if (this.disposed || !record.active || !this.isCurrentRoom(state)) return;
|
||||
const next = selectGameSnapshot(state);
|
||||
if (samePublicSnapshot(current, next)) return;
|
||||
current = next;
|
||||
listener(next);
|
||||
};
|
||||
record.unsubscribeStore = store.subscribe(bridge);
|
||||
this.subscriptions.add(record);
|
||||
|
||||
try {
|
||||
listener(current);
|
||||
} catch (error) {
|
||||
this.releaseSubscription(record);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
try {
|
||||
const result = this.releaseSubscription(record);
|
||||
if (result.threw && this.invalidationErrors !== null) {
|
||||
this.invalidationErrors.push(result.error);
|
||||
}
|
||||
} catch {
|
||||
// The public unsubscribe contract remains non-throwing, including after invalidation.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sendGameMessage(rpc: string, data: unknown): void {
|
||||
this.requireCurrentRoom();
|
||||
if (typeof rpc !== 'string' || rpc.trim() === '') {
|
||||
throw new TypeError('GameHost rpc must be a non-empty string');
|
||||
}
|
||||
const sendPort = this.sendPort;
|
||||
const route = this.route;
|
||||
if (sendPort === null || route === null) throw new GameSessionDisposedError();
|
||||
const envelope: OutboundEnvelope = { app: APP, route, rpc, data };
|
||||
sendPort.send(envelope);
|
||||
}
|
||||
|
||||
private execute(command: GameHostCommand): void {
|
||||
this.requireCurrentRoom();
|
||||
const commands = this.commands;
|
||||
if (commands === null) throw new GameSessionDisposedError();
|
||||
if (command !== null && typeof command === 'object') {
|
||||
if(command.type==='ui.open-number-input'||command.type==='ui.close-number-input'
|
||||
||command.type==='ui.show-multiplier'||command.type==='ui.hide-multiplier'){
|
||||
const tools=this.numericTools;
|
||||
if(tools===null)throw new UnsupportedGameHostCommandError('Numeric tools presentation is unavailable');
|
||||
this.numericToolsUsed=true;
|
||||
if(command.type==='ui.close-number-input'){this.cancelInput('input','user');this.inputGeneration++;tools.closeNumberInput();return;}
|
||||
if(command.type==='ui.hide-multiplier'){this.cancelInput('multiplier','user');this.multiplierGeneration++;tools.hideMultiplier();return;}
|
||||
if(typeof command.onConfirm!=='function')throw TypeError('Numeric tools require a confirmation callback');
|
||||
const current=():boolean=>!this.disposed&&this.store!==null&&this.isCurrentRoom(this.store.getState());
|
||||
if(command.type==='ui.open-number-input'){
|
||||
this.cancelInput('input','replaced');
|
||||
const generation=++this.inputGeneration;
|
||||
tools.openNumberInput({message:command.message,begin0:command.begin0,onConfirm:value=>{
|
||||
if(current()&&generation===this.inputGeneration)command.onConfirm(value);
|
||||
}});
|
||||
}else{
|
||||
this.cancelInput('multiplier','replaced');
|
||||
const generation=++this.multiplierGeneration;
|
||||
tools.showMultiplier({values:[...command.values],onConfirm:values=>{
|
||||
if(current()&&generation===this.multiplierGeneration)command.onConfirm(values);
|
||||
}});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command.type === 'room.prepare') {
|
||||
commands.prepare();
|
||||
return;
|
||||
}
|
||||
if(command.type==='room.apply-dissolution'){
|
||||
commands.applyDissolution();
|
||||
return;
|
||||
}
|
||||
if (command.type === 'room.exit') {
|
||||
commands.exitRoom();
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new UnsupportedGameHostCommandError('unsupported GameHost command');
|
||||
}
|
||||
|
||||
private ownFeedback(acquire:(port:FeedbackOwnerPort)=>OwnerHandle):OwnerHandle {
|
||||
this.requireCurrentRoom();const port=this.feedback;if(!port)throw new UnsupportedGameHostCommandError('Owned feedback presentation is unavailable');
|
||||
const handle=acquire(port);if(!handle||typeof handle.close!=='function')throw TypeError('Feedback must return an owner handle');
|
||||
if(!this.isActiveRoom()){handle.close();throw new GameSessionDisposedError();}
|
||||
let closed=false;let unregister=()=>{};
|
||||
const owned=Object.freeze({close:()=>{if(closed)return;closed=true;unregister();handle.close();}});
|
||||
unregister=this.defer(()=>owned.close());return owned;
|
||||
}
|
||||
|
||||
private isActiveRoom():boolean {return !this.disposed&&this.store!==null&&this.isCurrentRoom(this.store.getState());}
|
||||
|
||||
private defer(cleanup:()=>void):()=>void {
|
||||
this.requireCurrentRoom();if(typeof cleanup!=='function')throw TypeError('Scope cleanup must be a function');
|
||||
const registered=()=>cleanup();this.cleanups.add(registered);return ()=>{this.cleanups.delete(registered);};
|
||||
}
|
||||
|
||||
private cancelInput(kind:'input'|'multiplier',reason:InputCancelReason):void {this.pendingInputs.get(kind)?.cancel(reason);}
|
||||
|
||||
private requestNumber<T extends number|string>(message:string,text:boolean):Promise<InputResult<T>> {
|
||||
return this.requestInput<T>('input',(tools,confirm,cancel,fail)=>{
|
||||
if(typeof message!=='string')throw TypeError('Number input message must be a string');
|
||||
tools.openNumberInput({message,begin0:text,onCancel:cancel,onConfirm:value=>{
|
||||
if(typeof value!==(text?'string':'number')||(typeof value==='number'&&!Number.isFinite(value))){fail(TypeError('Number input returned the wrong result type'));return;}
|
||||
confirm(value as T);
|
||||
}});
|
||||
});
|
||||
}
|
||||
|
||||
private requestMultiplier(values:readonly number[]):Promise<InputResult<readonly number[]>> {
|
||||
return this.requestInput<readonly number[]>('multiplier',(tools,confirm,cancel,fail)=>{
|
||||
if(!Array.isArray(values)||!values.every(Number.isFinite))throw TypeError('Multiplier requires finite values');
|
||||
tools.showMultiplier({values:Object.freeze([...values]),onCancel:cancel,onConfirm:result=>{
|
||||
if(!Array.isArray(result)||!result.every(Number.isFinite)){fail(TypeError('Multiplier returned non-finite values'));return;}
|
||||
confirm(Object.freeze([...result]));
|
||||
}});
|
||||
});
|
||||
}
|
||||
|
||||
private requestInput<T>(kind:'input'|'multiplier',open:(tools:NumericToolsPort,confirm:(value:T)=>void,cancel:()=>void,fail:(error:unknown)=>void)=>void):Promise<InputResult<T>> {
|
||||
return new Promise((resolve,reject)=>{
|
||||
try{
|
||||
this.requireCurrentRoom();
|
||||
if(this.pendingInputs.has(kind))throw Error('An input request of this kind is already pending');
|
||||
const tools=this.numericTools;if(tools===null)throw new UnsupportedGameHostCommandError('Numeric tools presentation is unavailable');
|
||||
this.numericToolsUsed=true;
|
||||
if(kind==='input')this.inputGeneration++;else this.multiplierGeneration++;
|
||||
let settled=false;
|
||||
const finish=(result:InputResult<T>):void=>{if(settled)return;settled=true;this.pendingInputs.delete(kind);resolve(Object.freeze(result));};
|
||||
this.pendingInputs.set(kind,{cancel:reason=>finish({kind:'cancelled',reason})});
|
||||
const fail=(error:unknown):void=>{
|
||||
if(settled)return;settled=true;this.pendingInputs.delete(kind);
|
||||
try{if(kind==='input')tools.closeNumberInput();else tools.hideMultiplier();}
|
||||
catch(cleanup){reject(Object.assign(new Error('Input request and cleanup failed'),{errors:Object.freeze([error,cleanup])}));return;}
|
||||
reject(error);
|
||||
};
|
||||
try{open(tools,value=>{
|
||||
if(settled)return;
|
||||
if(!this.isActiveRoom()){finish({kind:'cancelled',reason:'scope-ended'});return;}
|
||||
settled=true;this.pendingInputs.delete(kind);
|
||||
try{if(kind==='input')tools.closeNumberInput();else tools.hideMultiplier();resolve(Object.freeze({kind:'confirmed',value}));}catch(error){reject(error);}
|
||||
},()=>finish({kind:'cancelled',reason:'user'}),fail);}
|
||||
catch(error){fail(error);}
|
||||
}catch(error){reject(error);}
|
||||
});
|
||||
}
|
||||
|
||||
private requireStore(): PlatformStore {
|
||||
this.requireActive();
|
||||
const store = this.store;
|
||||
if (store === null) throw new GameSessionDisposedError();
|
||||
return store;
|
||||
}
|
||||
|
||||
private requireCurrentRoom(): PlatformState {
|
||||
const state = this.requireStore().getState();
|
||||
if (!this.isCurrentRoom(state)) throw new GameSessionDisposedError();
|
||||
return state;
|
||||
}
|
||||
|
||||
private isCurrentRoom(state: PlatformState): boolean {
|
||||
const generation = this.roomGeneration;
|
||||
return generation !== null
|
||||
&& state.room.kind === 'inside'
|
||||
&& state.room.roomcode === generation.roomcode
|
||||
&& state.room.roomtype === generation.roomtype
|
||||
&& state.room.selfSeat === generation.selfSeat
|
||||
&& state.room.seatPlayerIds.length === generation.seatCount;
|
||||
}
|
||||
|
||||
private requireActive(): void {
|
||||
if (this.disposed) throw new GameSessionDisposedError();
|
||||
}
|
||||
|
||||
private releaseSubscription(record: SubscriptionRecord): SubscriptionReleaseResult {
|
||||
if (!record.active) return { threw: false };
|
||||
record.active = false;
|
||||
this.subscriptions.delete(record);
|
||||
const unsubscribe = record.unsubscribeStore;
|
||||
record.unsubscribeStore = null;
|
||||
if (unsubscribe === null) return { threw: false };
|
||||
try {
|
||||
unsubscribe();
|
||||
return { threw: false };
|
||||
} catch (error) {
|
||||
return { threw: true, error };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createGameHostAdapter(options: CreateGameHostAdapterOptions): GameHostLease {
|
||||
const session = new SessionBoundGameHost(options);
|
||||
return Object.freeze({
|
||||
host: session.host,
|
||||
invalidate: (): void => { session.invalidate(); },
|
||||
});
|
||||
}
|
||||
|
||||
function requireSeatCount(seatCount: unknown, roomSeatCount: number): asserts seatCount is number {
|
||||
if (!Number.isInteger(seatCount) || (seatCount as number) <= 0) {
|
||||
throw new RangeError(`GameEntry seat count must be a positive integer: ${String(seatCount)}`);
|
||||
}
|
||||
if (seatCount !== roomSeatCount) {
|
||||
throw new RangeError(
|
||||
`GameEntry seat count ${seatCount} does not match room seat count ${roomSeatCount}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requireSeat(value: unknown, seatCount: number, label: string): number {
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new TypeError(`${label}: expected integer seat, received ${String(value)}`);
|
||||
}
|
||||
const seat = value as number;
|
||||
if (seat < 0 || seat >= seatCount) {
|
||||
throw new RangeError(`${label}: expected seat range 0..${seatCount - 1}, received ${seat}`);
|
||||
}
|
||||
return seat;
|
||||
}
|
||||
|
||||
function samePublicSnapshot(first: PlatformGameSnapshot, second: PlatformGameSnapshot): boolean {
|
||||
if (first === second) return true;
|
||||
if (
|
||||
first.connection.phase !== second.connection.phase
|
||||
|| first.self.playerId !== second.self.playerId
|
||||
|| first.self.seat !== second.self.seat
|
||||
|| first.room.roomcode !== second.room.roomcode
|
||||
|| first.room.roomtype !== second.room.roomtype
|
||||
|| first.room.stage !== second.room.stage
|
||||
|| first.room.needprepare !== second.room.needprepare
|
||||
|| first.room.infinite !== second.room.infinite
|
||||
|| first.seats.length !== second.seats.length
|
||||
) return false;
|
||||
for (let index = 0; index < first.seats.length; index++) {
|
||||
const left = first.seats[index]!;
|
||||
const right = second.seats[index]!;
|
||||
if (
|
||||
left.seat !== right.seat
|
||||
|| left.playerId !== right.playerId
|
||||
|| left.nickname !== right.nickname
|
||||
|| left.avatar !== right.avatar
|
||||
|| left.online !== right.online
|
||||
|| left.ready !== right.ready
|
||||
) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "92bf4247-2b4b-460d-8992-8922baf78df2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type ReadyGateName =
|
||||
| 'resources'
|
||||
| 'config'
|
||||
| 'socket'
|
||||
| 'minimum-display';
|
||||
|
||||
const REQUIRED_GATES: readonly ReadyGateName[] = Object.freeze([
|
||||
'resources',
|
||||
'config',
|
||||
'socket',
|
||||
'minimum-display',
|
||||
]);
|
||||
|
||||
/** One-way startup latch: readiness is reached only after all four signals arrive. */
|
||||
export class ReadyGate {
|
||||
private readonly marked = new Set<ReadyGateName>();
|
||||
|
||||
get ready(): boolean {
|
||||
return REQUIRED_GATES.every((name) => this.marked.has(name));
|
||||
}
|
||||
|
||||
mark(name: ReadyGateName): void {
|
||||
this.marked.add(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "ac23132b-03ac-47c4-b6f8-93602c94f080",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { EventBus } from '../core/events.ts';
|
||||
import type { NetClientEvents } from '../net/net-client.ts';
|
||||
import type { InboundMessage } from '../core/types/envelope.ts';
|
||||
import { roomHandlers } from '../protocol/room-handlers.ts';
|
||||
import type { PlatformSession } from './session.ts';
|
||||
|
||||
/**
|
||||
* RoomRPCBus:把 NetClient 的 'message' 事件按 route 分发到 room handlers。
|
||||
*
|
||||
* 监听 NetClient 'message' → 过滤 route==='room' → 查 roomHandlers[rpc](ctx, data)。
|
||||
*
|
||||
* 业务侧在 PlatformSession 构造后立即创建 RoomRPCBus 并 start(),
|
||||
* 后续 stop() 退订(进/退房切换清理)。
|
||||
*/
|
||||
export class RoomRPCBus {
|
||||
private handler: ((msg: InboundMessage) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly bus: EventBus<NetClientEvents>,
|
||||
private readonly session: PlatformSession,
|
||||
) {}
|
||||
|
||||
/** 开始监听 NetClient 消息事件 */
|
||||
start(): void {
|
||||
if (this.handler) return; // 幂等
|
||||
this.handler = (msg) => this.dispatch(msg);
|
||||
this.bus.on('message', this.handler);
|
||||
}
|
||||
|
||||
/** 停止监听(切房/退出时调用) */
|
||||
stop(): void {
|
||||
if (this.handler) { this.bus.off('message', this.handler); this.handler = null; }
|
||||
}
|
||||
|
||||
private dispatch(msg: InboundMessage): void {
|
||||
if (msg.route !== 'room') return; // 平台三层 + 对局 route 由 Router 单独处理
|
||||
const handler = (roomHandlers as unknown as Record<string, ((ctx: any, data: any) => void) | undefined>)[msg.rpc];
|
||||
if (!handler) {
|
||||
// 未注册 rpc:roomHandlers 的 Object.freeze 已确保此处为 undefined
|
||||
// 不静默吞包:抛错便于真机端到端发现协议漂移
|
||||
throw new Error(
|
||||
`RoomRPCBus: 未注册的 room rpc "${msg.rpc}"(roomHandlers 不覆盖)`,
|
||||
);
|
||||
}
|
||||
handler(
|
||||
{ room: this.session.room, player: this.session.player },
|
||||
msg.data as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b2f4361c-64f4-4a29-a5db-a4bd45b42f84",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "366456c6-a70b-47c0-8ad8-ffe01393a51e",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {RoomScope,type RoomDisposer} from './room-scope.ts';
|
||||
import {RoomMailbox,assertRoomMailboxOptions,type RoomMailboxOptions} from './room-mailbox.ts';
|
||||
|
||||
export type RoomCoordinatorState='outside'|'entering'|'active'|'failed';
|
||||
export type RoomMountResult=void|RoomDisposer;
|
||||
export interface RoomEntryOptions<T> {
|
||||
readonly invalidate:RoomDisposer;
|
||||
readonly mount:(scope:RoomScope)=>RoomMountResult|PromiseLike<RoomMountResult>;
|
||||
readonly ready:()=>void;
|
||||
readonly handle:(message:T)=>void;
|
||||
}
|
||||
export class RoomCoordinatorFailure extends Error {
|
||||
readonly errors:readonly unknown[];
|
||||
constructor(readonly generation:number,errors:readonly unknown[]){
|
||||
super(`Room generation ${generation} failed`);this.name='RoomCoordinatorFailure';this.errors=Object.freeze([...errors]);
|
||||
}
|
||||
}
|
||||
export interface RoomCoordinatorOptions extends RoomMailboxOptions {
|
||||
readonly onFailure:(failure:RoomCoordinatorFailure)=>void;
|
||||
}
|
||||
interface Session<T>{scope:RoomScope;mailbox:RoomMailbox<T>;entry:RoomEntryOptions<T>;draining:boolean;}
|
||||
|
||||
/** Serializes room input across async scene mounting and revokes stale generations immediately. */
|
||||
export class RoomCoordinator<T> {
|
||||
private current:Session<T>|null=null;
|
||||
private nextGeneration=0;
|
||||
private status:RoomCoordinatorState='outside';
|
||||
private lastFailure:RoomCoordinatorFailure|null=null;
|
||||
private lastDiagnostic:RoomCoordinatorFailure|null=null;
|
||||
constructor(private readonly options:RoomCoordinatorOptions){assertRoomMailboxOptions(options);if(typeof options.onFailure!=='function')throw Error('Room coordinator requires a diagnostic failure handler');}
|
||||
get state():RoomCoordinatorState{return this.status;}
|
||||
get scope():RoomScope|null{return this.current?.scope??null;}
|
||||
get generation():number{return this.nextGeneration;}
|
||||
get failure():RoomCoordinatorFailure|null{return this.lastFailure;}
|
||||
get diagnosticFailure():RoomCoordinatorFailure|null{return this.lastDiagnostic;}
|
||||
enter(entry:RoomEntryOptions<T>):RoomScope{
|
||||
const cleanup=this.cancel();
|
||||
const scope=new RoomScope(++this.nextGeneration,entry.invalidate);
|
||||
const session:Session<T>={scope,entry,draining:false,mailbox:new RoomMailbox<T>(this.options,error=>this.fail(session,error))};
|
||||
scope.own(()=>session.mailbox.dispose());
|
||||
this.current=session;this.status='entering';this.lastFailure=null;
|
||||
if(cleanup.length){this.fail(session,...cleanup);return scope;}
|
||||
try{
|
||||
const result=entry.mount(scope);
|
||||
if(result!==undefined&&typeof result!=='function'&&typeof result.then==='function'){
|
||||
void Promise.resolve(result).then(resource=>this.mounted(session,resource),error=>this.mountRejected(session,error));
|
||||
}else this.mounted(session,result as RoomMountResult);
|
||||
}catch(error){if(this.isCurrent(session))this.fail(session,error);else throw error;}
|
||||
return scope;
|
||||
}
|
||||
dispatch(message:T):void{
|
||||
const session=this.current;
|
||||
if(!session||!session.scope.active)throw Error(`Cannot dispatch room input while ${this.status}`);
|
||||
try{
|
||||
session.mailbox.enqueue(message);
|
||||
if(this.status==='active')this.drain(session);
|
||||
}catch(error){if(this.isCurrent(session))this.fail(session,error);else throw error;}
|
||||
}
|
||||
/** Callers aggregate explicit cancellation cleanup errors with their existing teardown errors. */
|
||||
cancel():readonly unknown[]{
|
||||
const session=this.current;this.current=null;this.status='outside';
|
||||
return session?.scope.dispose()??[];
|
||||
}
|
||||
private isCurrent(session:Session<T>):boolean{return this.current===session&&session.scope.active;}
|
||||
private mounted(session:Session<T>,resource:RoomMountResult):void{
|
||||
try{
|
||||
if(resource!==undefined)session.scope.own(resource);
|
||||
if(!this.isCurrent(session))return;
|
||||
session.entry.ready();
|
||||
if(!this.isCurrent(session))return;
|
||||
this.status='active';this.drain(session);
|
||||
}catch(error){if(this.isCurrent(session))this.fail(session,error);else this.report(new RoomCoordinatorFailure(session.scope.generation,[error]));}
|
||||
}
|
||||
private mountRejected(session:Session<T>,error:unknown):void{
|
||||
if(this.isCurrent(session))this.fail(session,error);
|
||||
// Rejected stale loads own no mount resource; their superseded scope was already released.
|
||||
}
|
||||
private drain(session:Session<T>):void{
|
||||
if(session.draining)return;
|
||||
session.draining=true;
|
||||
try{session.mailbox.drain(session.entry.handle,()=>this.isCurrent(session));}
|
||||
finally{session.draining=false;}
|
||||
}
|
||||
private fail(session:Session<T>,...errors:unknown[]):void{
|
||||
if(!this.isCurrent(session))return;
|
||||
this.current=null;this.status='failed';
|
||||
const failure=new RoomCoordinatorFailure(session.scope.generation,[...errors,...session.scope.dispose()]);
|
||||
this.lastFailure=failure;this.report(failure);
|
||||
}
|
||||
private report(failure:RoomCoordinatorFailure):void{
|
||||
this.lastDiagnostic=failure;
|
||||
try{this.options.onFailure(failure);}catch(error){
|
||||
// Preserve diagnostic callback faults without creating an unhandled async rejection.
|
||||
const diagnostic=new RoomCoordinatorFailure(failure.generation,[...failure.errors,error]);
|
||||
this.lastDiagnostic=diagnostic;
|
||||
if(this.nextGeneration===failure.generation)this.lastFailure=diagnostic;
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "e56782fe-72cd-4748-a2b4-8907281be052",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface RoomClock {
|
||||
setTimeout(callback:()=>void,milliseconds:number):unknown;
|
||||
clearTimeout(handle:unknown):void;
|
||||
}
|
||||
export interface RoomMailboxOptions {
|
||||
readonly capacity:number;
|
||||
readonly maxWaitMs:number;
|
||||
readonly clock:RoomClock;
|
||||
}
|
||||
export function assertRoomMailboxOptions(options:RoomMailboxOptions):void{
|
||||
if(!Number.isSafeInteger(options.capacity)||options.capacity<1)throw Error('Room mailbox capacity must be a positive safe integer');
|
||||
if(!Number.isFinite(options.maxWaitMs)||options.maxWaitMs<=0)throw Error('Room mailbox maxWaitMs must be positive and finite');
|
||||
if(typeof options.clock?.setTimeout!=='function'||typeof options.clock?.clearTimeout!=='function')throw Error('Room mailbox requires a clock');
|
||||
}
|
||||
/** FIFO queue owned by a room scope; its first pending message starts the wait deadline. */
|
||||
export class RoomMailbox<T> {
|
||||
private readonly messages:T[]=[];
|
||||
private timer:{handle:unknown}|null=null;
|
||||
private disposed=false;
|
||||
constructor(private readonly options:RoomMailboxOptions,private readonly onTimeout:(error:Error)=>void){assertRoomMailboxOptions(options);}
|
||||
get size():number{return this.messages.length;}
|
||||
enqueue(message:T):void{
|
||||
if(this.disposed)throw Error('Room mailbox is disposed');
|
||||
if(this.messages.length>=this.options.capacity)throw Error(`Room mailbox capacity exceeded (${this.options.capacity})`);
|
||||
this.messages.push(message);
|
||||
if(this.timer===null){this.timer={handle:this.options.clock.setTimeout(()=>{
|
||||
this.timer=null;
|
||||
if(!this.disposed&&this.messages.length>0)this.onTimeout(Error(`Room mailbox timed out after ${this.options.maxWaitMs} ms`));
|
||||
},this.options.maxWaitMs)};}
|
||||
}
|
||||
drain(handle:(message:T)=>void,active:()=>boolean):void{
|
||||
while(!this.disposed&&active()&&this.messages.length>0){
|
||||
const message=this.messages.shift() as T;
|
||||
if(this.messages.length===0)this.clearTimer();
|
||||
handle(message);
|
||||
}
|
||||
}
|
||||
dispose():void{this.disposed=true;this.messages.length=0;this.clearTimer();}
|
||||
private clearTimer():void{
|
||||
const timer=this.timer;this.timer=null;
|
||||
if(timer!==null)this.options.clock.clearTimeout(timer.handle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "ca845f16-3594-4150-b744-315e56d38814",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type RoomDisposer=()=>void;
|
||||
|
||||
/** Owns one room generation; the injected invalidator revokes the existing host lease. */
|
||||
export class RoomScope {
|
||||
private live=true;
|
||||
private readonly disposers=new Set<RoomDisposer>();
|
||||
constructor(readonly generation:number,private readonly invalidate:RoomDisposer){
|
||||
if(!Number.isSafeInteger(generation)||generation<1)throw Error('Invalid room scope generation');
|
||||
if(typeof invalidate!=='function')throw Error('Room scope requires a lease invalidator');
|
||||
}
|
||||
get active():boolean{return this.live;}
|
||||
get resourceCount():number{return this.disposers.size;}
|
||||
/** The returned release is idempotent and unregisters before invoking user cleanup. */
|
||||
own(dispose:RoomDisposer):RoomDisposer{
|
||||
if(typeof dispose!=='function')throw Error('Room scope requires a disposer');
|
||||
let owned=true;
|
||||
const release=()=>{if(!owned)return;owned=false;this.disposers.delete(release);dispose();};
|
||||
if(this.live)this.disposers.add(release);else release();
|
||||
return release;
|
||||
}
|
||||
dispose():readonly unknown[]{
|
||||
if(!this.live)return [];
|
||||
this.live=false;
|
||||
const errors:unknown[]=[];
|
||||
try{this.invalidate();}catch(error){errors.push(error);}
|
||||
for(const release of [...this.disposers].reverse())try{release();}catch(error){errors.push(error);}
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "11761871-986b-4241-b53b-1f277f35ce1d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user