Compare commits
101
Commits
b15825325b
..
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 |
@@ -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)。改这部分前先核对上述源码位置,不要臆测接口名或数据格式。
|
||||
@@ -17,6 +17,12 @@ yarn-error.log*
|
||||
# ---------- Node 依赖 ----------
|
||||
**/node_modules/
|
||||
|
||||
# pnpm 本地内容寻址缓存(机器生成,可由依赖清单重建)
|
||||
/.pnpm-store/
|
||||
|
||||
# Codex / Git 隔离开发工作树(机器本地)
|
||||
/.worktrees/
|
||||
|
||||
# ---------- Cocos Creator 工程生成物 ----------
|
||||
# 由各工程自己的 .gitignore 用锚定路径负责(YouleNexus/.gitignore、templates/game-seed/.gitignore)。
|
||||
# 此处**不得**再写 **/library/ 这类无锚点模式——它会连 assets/framework 下的
|
||||
|
||||
@@ -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/` 是权威文档,改任何网络层代码前先读对应章节,**不要凭本文件的转述臆测**。
|
||||
|
||||
@@ -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')]
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "e4d143eb-ff94-4bcb-9e87-077e09c544e3",
|
||||
"uuid": "647a7b22-853c-4083-b6e9-c287ccc6babc",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "1ea6ed41-6740-4317-8367-c6148c90d599",
|
||||
"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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2eb9409f-115d-4a7d-bdc4-30a333b57a28",
|
||||
"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})]);
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d8981b12-16b5-49a7-95d6-a51eb735462a",
|
||||
"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 = 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt';
|
||||
/** 测试 gameserver:原 00_SubGame_Config.js:9 注释。 */
|
||||
const STAGING_GAMESERVER = 'https://tsgames.daoqi88.cn/config/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,32 +1,7 @@
|
||||
// 1. 先 export type
|
||||
export type RuntimeMode = 'debug' | 'release';
|
||||
|
||||
declare const DEBUG: boolean | undefined;
|
||||
|
||||
// 2. 函数引用 RuntimeMode(此时 type 已 export)
|
||||
function readModeFromUrl(): RuntimeMode | null {
|
||||
if (typeof window === 'undefined' || !window.location) return null;
|
||||
const m = new URLSearchParams(window.location.search).get('mode');
|
||||
if (m === 'debug' || m === 'release') return m;
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectDebugBuild(): boolean {
|
||||
if (typeof DEBUG !== 'undefined') return DEBUG;
|
||||
return true; // Cocos 编辑器内 dev workflow 默认;生产 build 改 false
|
||||
}
|
||||
|
||||
// 3. 导出常量
|
||||
export const MODE_OVERRIDE: RuntimeMode | null =
|
||||
readModeFromUrl() ?? (detectDebugBuild() ? 'debug' : 'release');
|
||||
|
||||
/**
|
||||
* 解析运行模式:override 优先,否则由是否 debug 构建决定。
|
||||
*/
|
||||
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: 'J9r4022t80pxlb01zOK76DN1WD4QewFl',
|
||||
marketid: 4,
|
||||
version: 1, // 数字 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": {}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* 原生↔H5 异步桥(WebViewJavascriptBridge,WVJB)+ window.settings 同步取值。
|
||||
*
|
||||
* 严格对齐原 gameabc 项目(05_Func.js:2627 起)的外部契约:
|
||||
* - 14 个 handler 名白名单(与原生 app 逐字对齐)
|
||||
* - `window.WVJBCallbacks` / `WebViewJavascriptBridgeReady` 事件名
|
||||
* - `window.settings.getothername(name)` 同步取值 API
|
||||
* - 回调签名 `(data, responseCallback)` 与数据结构
|
||||
*
|
||||
* 内部命名(接口/函数/常量)走现代专业风格,与上述外部契约清晰隔离。
|
||||
*
|
||||
* 架构边界:本模块只暴露受限 facade,不允许 framework 业务代码直接读
|
||||
* window/bridge 内部。
|
||||
*/
|
||||
|
||||
/** window 上的原生注入对象(05_Func.js:2467-2471 同步取值)。 */
|
||||
declare global {
|
||||
interface Window {
|
||||
settings?: {
|
||||
/** 同步取一个原生配置项,无则返回空串 */
|
||||
getothername(name: string): string;
|
||||
};
|
||||
/** WVJB 启动前的全局回调队列(外部契约,不能改名) */
|
||||
WVJBCallbacks?: Array<(bridge: NativeBridgeChannel) => void>;
|
||||
/** WebView 注入 WVJB 的事件名(外部契约) */
|
||||
WebViewJavascriptBridgeReady?: Array<() => void>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原生桥通道契约 —— WVJB bridge 实例的最小子集。
|
||||
* 内部命名为 "Channel" 以避免缩写、明确"双向通信通道"语义。
|
||||
*/
|
||||
export interface NativeBridgeChannel {
|
||||
/** H5 注册供原生侧调用的 handler */
|
||||
registerHandler(name: string, handler: NativeHandler): void;
|
||||
/** H5 主动调用原生侧 */
|
||||
callHandler(name: string, data: unknown, responseCallback?: (resp: unknown) => void): void;
|
||||
}
|
||||
|
||||
/** H5 注册供原生调用的 handler 函数签名(外部 WVJB 协议约定) */
|
||||
export type NativeHandler = (data: unknown, responseCallback: NativeResponseCallback) => unknown;
|
||||
|
||||
/** 响应回调:原生侧通过此把结果回传给 H5 */
|
||||
export type NativeResponseCallback = (responseData: unknown) => void;
|
||||
|
||||
/**
|
||||
* 14 个已注册 handler 名白名单(05_Func.js:2651+ 原项目实装)。
|
||||
* 与原生 app 端逐字对齐 —— app 按这些名字调,改名 app 也要改。
|
||||
*
|
||||
* 字面量联合类型:registerHandler/callHandler 入参必须在此集合内,
|
||||
* 拼写错误编译期就发现,集合外名字运行时显式抛错(第二准则:不静默兜底)。
|
||||
*/
|
||||
export const NATIVE_HANDLER_NAMES = [
|
||||
'getVideoinfo',
|
||||
'sharelogin',
|
||||
'sharesuccess',
|
||||
'gameui_play_voice',
|
||||
'gameui_stop_voice',
|
||||
'getphoneinfo',
|
||||
'getAddressBook',
|
||||
'phonestate',
|
||||
'appservice',
|
||||
'getaudiourl',
|
||||
'getBattery',
|
||||
'getwifiLevel',
|
||||
'getnetwork',
|
||||
'shakeEnd',
|
||||
] as const;
|
||||
|
||||
export type NativeHandlerName = (typeof NATIVE_HANDLER_NAMES)[number];
|
||||
|
||||
function isNativeHandlerName(name: string): name is NativeHandlerName {
|
||||
return (NATIVE_HANDLER_NAMES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
/** createBridge 初始化选项 */
|
||||
export interface BridgeOptions {
|
||||
/** 通道就绪回调(原生侧 WebView bridgeReady 时触发一次) */
|
||||
onReady(channel: NativeBridgeChannel): void;
|
||||
/**
|
||||
* 初始化超时的兜底(可省略)。
|
||||
* 原生侧可能永不触发 WebViewJavascriptBridgeReady,业务可在此降级。
|
||||
*/
|
||||
onTimeout?(reason: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 受限 Bridge facade —— 框架业务访问原生能力的唯一入口。
|
||||
*
|
||||
* 创建后立即尝试初始化:
|
||||
* 1. 把 setup 回调 push 到 window.WVJBCallbacks(原生侧约定的入口)
|
||||
* 2. 原生侧 WebView bridgeReady 时遍历 WVJBCallbacks 调用每个 cb → onReady 被触发
|
||||
*
|
||||
* 一旦通道就绪,onReady 被调用一次。
|
||||
*/
|
||||
export interface Bridge {
|
||||
/** 当前通道是否已就绪 */
|
||||
isReady(): boolean;
|
||||
/** H5 注册供原生侧调用(白名单校验) */
|
||||
registerHandler(name: NativeHandlerName, handler: NativeHandler): void;
|
||||
/** H5 调用原生侧(白名单校验) */
|
||||
callHandler(name: NativeHandlerName, data: unknown, cb?: NativeResponseCallback): void;
|
||||
}
|
||||
|
||||
export function createBridge(opts: BridgeOptions): Bridge {
|
||||
const g = globalThis as any;
|
||||
// 外部契约:把 setup cb push 到 window.WVJBCallbacks
|
||||
if (!g.WVJBCallbacks) g.WVJBCallbacks = [];
|
||||
let ready = false;
|
||||
g.WVJBCallbacks.push((channel: NativeBridgeChannel) => {
|
||||
ready = true;
|
||||
// 缓存通道供 callHandler 委托(避免 onReady 闭包外传)
|
||||
g.__activeChannel = channel;
|
||||
opts.onReady(channel);
|
||||
});
|
||||
|
||||
return {
|
||||
isReady() { return ready; },
|
||||
registerHandler(name, handler) {
|
||||
if (!isNativeHandlerName(name)) {
|
||||
throw new Error(`registerHandler: handler "${name}" 不在白名单(第二准则:不静默兜底)`);
|
||||
}
|
||||
if (!ready) throw new Error('NativeBridge not ready: 通道未初始化,请先等 onReady 回调');
|
||||
g.__activeChannel.registerHandler(name, handler);
|
||||
},
|
||||
callHandler(name, data, cb) {
|
||||
if (!isNativeHandlerName(name)) {
|
||||
throw new Error(`callHandler: handler "${name}" 不在白名单(第二准则:不静默兜底)`);
|
||||
}
|
||||
if (!ready) throw new Error('NativeBridge not ready: 通道未初始化,请先等 onReady 回调');
|
||||
g.__activeChannel.callHandler(name, data, cb);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 window 取原生注入的配置项(05_Func.js:2467-2471 的 getothername)。
|
||||
*
|
||||
* @param name 配置项名(渠道/包信息/gameserver 覆盖等)
|
||||
* @returns 原生侧返回值;缺失时返回空串(对齐原项目:无则空字符串而非 undefined)
|
||||
* @throws 原生侧未注入 window.settings 时显式抛错(不静默返回空串——避免下游误用)
|
||||
*/
|
||||
export function getNativeSetting(name: string): string {
|
||||
const settings = (globalThis as any).window?.settings;
|
||||
if (!settings || typeof settings.getothername !== 'function') {
|
||||
throw new Error(
|
||||
`getNativeSetting("${name}"): window.settings 未注入(原生侧未加载或非 WebView 环境)`,
|
||||
);
|
||||
}
|
||||
return settings.getothername(name);
|
||||
}
|
||||
@@ -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,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": {}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {GameEntry,GameHost,PlatformToGameEvent,GameServerMessage} from '../../sdk/contracts/index.ts';
|
||||
import {GameSessionHost} from '../../sdk/runtime/game-session-host.ts';
|
||||
import type {ScenePort} from '../scene-port.ts';
|
||||
import {RoomCoordinator,type RoomCoordinatorOptions} from './room-coordinator.ts';
|
||||
|
||||
/** Module attachment precedes view binding; all room input waits for the bound view. */
|
||||
export class SceneOrderedGameSessionHost extends GameSessionHost {
|
||||
private readonly coordinator:RoomCoordinator<()=>void>;
|
||||
private synchronousErrors:unknown[]|null=null;
|
||||
private packetDepth=0;
|
||||
constructor(entry:GameEntry,private readonly scene:ScenePort,options:RoomCoordinatorOptions,
|
||||
private readonly invalidateHost:(host:GameHost)=>void){
|
||||
super(entry);
|
||||
this.coordinator=new RoomCoordinator({...options,onFailure:failure=>{
|
||||
if(this.synchronousErrors!==null)this.synchronousErrors.push(...failure.errors);
|
||||
else options.onFailure(failure);
|
||||
}});
|
||||
}
|
||||
open(host:GameHost):void{
|
||||
super.open(host);
|
||||
if(this.state!=='active')return;
|
||||
this.synchronous(()=>this.coordinator.enter({invalidate:()=>this.invalidateHost(host),
|
||||
mount:scope=>this.scene.showRoom(scope),ready:()=>{},handle:action=>action()}));
|
||||
}
|
||||
publish(event:PlatformToGameEvent):void{this.dispatch(()=>super.publish(event));}
|
||||
dispatchGameMessage(route:string,message:GameServerMessage):void{this.dispatch(()=>super.dispatchGameMessage(route,message));}
|
||||
restore(deskinfo:unknown):void{this.dispatch(()=>super.restore(deskinfo));}
|
||||
get isRoomScopeAvailable():boolean{return this.coordinator.scope?.active===true;}
|
||||
/** Preserve a complete packet's Store commit and game callbacks as one FIFO transaction. */
|
||||
queueRoomPacket(action:()=>void):void{
|
||||
this.synchronous(()=>this.coordinator.dispatch(()=>{
|
||||
this.packetDepth++;
|
||||
try{action();}finally{this.packetDepth--;}
|
||||
}));
|
||||
}
|
||||
showRoomAfterOpen():void{
|
||||
if(this.coordinator.state!=='entering'&&this.coordinator.state!=='active'&&this.state!=='disposed')throw Error('Cannot show room without a live room scope');
|
||||
}
|
||||
/** Connection loss cancels slow mount immediately; an already mounted settlement remains owned. */
|
||||
cancelPendingMount():void{if(this.coordinator.state==='entering')this.close();}
|
||||
close():void{this.release(false);}
|
||||
dispose():void{this.release(true);}
|
||||
private release(terminal:boolean):void{
|
||||
const errors=[...this.coordinator.cancel()];
|
||||
try{if(terminal)super.dispose();else super.close();}catch(error){errors.push(error);}
|
||||
this.throwErrors(errors);
|
||||
}
|
||||
private dispatch(action:()=>void):void{
|
||||
if(this.state==='disposed')throw Error('Cannot dispatch to a disposed game session');
|
||||
if(this.packetDepth>0){action();return;}
|
||||
this.synchronous(()=>this.coordinator.dispatch(action));
|
||||
}
|
||||
/** Wire callbacks remain synchronous faults; only deferred work uses onFailure. */
|
||||
private synchronous(action:()=>unknown):void{
|
||||
const previous=this.synchronousErrors,errors:unknown[]=[];this.synchronousErrors=errors;
|
||||
try{action();}catch(error){errors.push(error);}finally{this.synchronousErrors=previous;}
|
||||
this.throwErrors(errors);
|
||||
}
|
||||
private throwErrors(errors:readonly unknown[]):void{
|
||||
if(errors.length===1)throw errors[0];
|
||||
if(errors.length>1)throw Object.assign(Error('Room scope operation failed'),{errors});
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "19c227d9-98e6-42f2-9c1e-9c6021eb44b7",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type {RoomEntryInput,RoomStartMessage} from '../sdk/contracts/platform-events.ts';
|
||||
import {requireRecord} from '../protocol/contracts/validation.ts';
|
||||
import type { GameHost } from '../sdk/contracts/index.ts';
|
||||
import {
|
||||
extractGameSessionOpenFaultErrors,
|
||||
GameSessionHost,
|
||||
} from '../sdk/runtime/game-session-host.ts';
|
||||
import {
|
||||
parseCreateRoomResponse,
|
||||
parseLoginResponse,
|
||||
parseSelfJoinRoomResponse,
|
||||
type ParsedSelfExitPayload,
|
||||
type ReconnectSnapshot,
|
||||
} from '../protocol/contracts/index.ts';
|
||||
import { cloneAndFreezeRoomtype, PlatformStore } from './stores/platform-store.ts';
|
||||
import type { PlatformState } from './stores/platform-types.ts';
|
||||
import type {DissolutionResult} from './stores/platform-types.ts';
|
||||
import type {LoginAccountIdentity} from '../protocol/contracts/login-contract.ts';
|
||||
|
||||
export interface GameHostLeasePort {
|
||||
readonly host: GameHost;
|
||||
invalidate(): void;
|
||||
}
|
||||
|
||||
export type GameHostLeaseFactory = () => GameHostLeasePort;
|
||||
|
||||
/** A valid nonzero server outcome, delivered without changing canonical state. */
|
||||
export interface ServerDenial {
|
||||
readonly rpc: 'player_login' | 'self_join_room' | 'create_room';
|
||||
readonly state: number;
|
||||
readonly data: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface RuntimeSessionOptions {
|
||||
readonly roomHistory?: import('../adapters/native/owned-room-history.ts').OwnedRoomHistory;
|
||||
readonly store: PlatformStore;
|
||||
readonly gameSession: GameSessionHost;
|
||||
readonly createGameHostLease: GameHostLeaseFactory;
|
||||
readonly onServerDenial: (outcome: ServerDenial) => void;
|
||||
}
|
||||
|
||||
export type RuntimeSessionState = 'operational' | 'faulted' | 'kicked';
|
||||
|
||||
export class RuntimeSessionFault extends Error {
|
||||
readonly errors: readonly unknown[];
|
||||
|
||||
constructor(operation: string, errors: readonly unknown[]) {
|
||||
super(`RuntimeSession fault during ${operation}: ${errors.map(error => String(error)).join('; ')}`);
|
||||
this.name = 'RuntimeSessionFault';
|
||||
this.errors = Object.freeze([...errors]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Coordinates the canonical platform snapshot with the one active game session. */
|
||||
export class RuntimeSession {
|
||||
private loginIsPending = false;
|
||||
private lastJoinedRoomcode: string | null = null;
|
||||
/** Non-device login owns identity in authorization; device login owns it in the response. */
|
||||
private authorizedIdentity:Pick<LoginAccountIdentity,'openid'|'unionid'>|undefined;
|
||||
private wasKicked = false;
|
||||
private terminalFault: RuntimeSessionFault | null = null;
|
||||
private activeLease: GameHostLeasePort | null = null;
|
||||
|
||||
constructor(private readonly options: RuntimeSessionOptions) {}
|
||||
|
||||
get loginPending(): boolean { return this.loginIsPending; }
|
||||
|
||||
get kicked(): boolean { return this.wasKicked; }
|
||||
|
||||
get state(): RuntimeSessionState {
|
||||
if (this.wasKicked) return 'kicked';
|
||||
return this.terminalFault !== null ? 'faulted' : 'operational';
|
||||
}
|
||||
|
||||
get fault(): Error | null { return this.terminalFault; }
|
||||
|
||||
markLoginPending(identity?:Pick<LoginAccountIdentity,'openid'|'unionid'>|null): void {
|
||||
this.requireOperational();
|
||||
// No argument is a reconnect of the current account; null selects device response identity.
|
||||
if(identity!==undefined)this.authorizedIdentity=identity===null?undefined:identity;
|
||||
this.loginIsPending = true;
|
||||
}
|
||||
|
||||
handleLogin(data: unknown): void {
|
||||
this.requireOperational();
|
||||
this.loginIsPending = false;
|
||||
const parsed = parseLoginResponse(data);
|
||||
if (parsed.state !== 0) {
|
||||
this.options.onServerDenial({ rpc: 'player_login', state: parsed.state, data: parsed.raw });
|
||||
return;
|
||||
}
|
||||
this.lastJoinedRoomcode = null;
|
||||
const committed = this.options.store.applyLoginSuccess(parsed,this.authorizedIdentity);
|
||||
if (parsed.room === null) {
|
||||
this.releaseOrFault('login lobby replacement');
|
||||
return;
|
||||
}
|
||||
this.openCommittedRoom(committed, parsed.reconnect, 'login room open',parsed.reconnect.present?{kind:'login-restored',sourceRpc:'player_login',raw:parsed.raw,deskinfo:parsed.reconnect.value}:{kind:'login-waiting',sourceRpc:'player_login',raw:parsed.raw});
|
||||
}
|
||||
|
||||
handleCreateRoom(data: unknown): void {
|
||||
this.requireOperational();
|
||||
const parsed = parseCreateRoomResponse(data);
|
||||
if (parsed.state !== 0) {
|
||||
this.options.onServerDenial({rpc: 'create_room', state: parsed.state, data: parsed.raw});
|
||||
return;
|
||||
}
|
||||
if (parsed.room === null) throw new Error('create_room missing parsed room');
|
||||
const current = this.options.store.getState().room;
|
||||
if (current.kind === 'inside' && current.roomcode === parsed.room.roomcode
|
||||
&& this.options.gameSession.state === 'active') return;
|
||||
// Subgame code receives an owned immutable value, never the server response graph.
|
||||
const roomtype = cloneAndFreezeRoomtype(parsed.room.roomtype);
|
||||
const count = this.options.gameSession.entry.resolveSeatCount(roomtype);
|
||||
const committed = this.options.store.createRoom(parsed, count);
|
||||
this.openCommittedRoom(committed, {present: false}, 'create room open',{kind:'created',sourceRpc:'create_room',raw:parsed.raw});
|
||||
}
|
||||
|
||||
handleSelfJoin(data: unknown, message?:RoomStartMessage): void {
|
||||
this.requireOperational();
|
||||
const parsed = parseSelfJoinRoomResponse(data);
|
||||
this.options.roomHistory?.joined(parsed.raw);
|
||||
if (parsed.state !== 0) {
|
||||
this.options.onServerDenial({ rpc: 'self_join_room', state: parsed.state, data: parsed.raw });
|
||||
return;
|
||||
}
|
||||
if (parsed.room !== null && this.lastJoinedRoomcode === parsed.room.roomcode
|
||||
&& this.options.gameSession.state === 'active') return;
|
||||
const committed = this.options.store.replaceRoom(parsed);
|
||||
const started=parsed.raw.deskwar===1;
|
||||
const reconnect=started?{present:false} as const:parsed.reconnect;
|
||||
this.openCommittedRoom(committed,reconnect,'self join room open',started?{kind:'joined-started',sourceRpc:'self_join_room',raw:parsed.raw}:reconnect.present?{kind:'joined-snapshot',sourceRpc:'self_join_room',raw:parsed.raw,deskinfo:reconnect.value}:{kind:'joined-waiting',sourceRpc:'self_join_room',raw:parsed.raw});
|
||||
if(started)this.options.gameSession.publish({type:'room.started',sourceRpc:'self_join_room',message:message??{route:'agent',rpc:'self_join_room',data:parsed.raw}});
|
||||
this.lastJoinedRoomcode = parsed.room!.roomcode;
|
||||
}
|
||||
|
||||
handleBreakRoom(payload:unknown):void {
|
||||
this.requireOperational();
|
||||
if(this.options.store.getState().room.kind==='outside')return;
|
||||
this.options.roomHistory?.broken(requireRecord(payload,'$.data'));
|
||||
this.options.store.clearRoom();
|
||||
this.releaseOrFault('break room cleanup');
|
||||
}
|
||||
|
||||
handleSelfExit(parsed: ParsedSelfExitPayload): void {
|
||||
this.requireOperational();
|
||||
const state = this.options.store.getState();
|
||||
if (state.room.kind !== 'inside') {
|
||||
if (this.options.gameSession.state === 'idle' && this.activeLease === null) return;
|
||||
const errors = [
|
||||
new Error('self_exit_room found an active game without room state'),
|
||||
...this.releaseGame(),
|
||||
];
|
||||
this.throwFault('self exit inconsistent state', errors);
|
||||
}
|
||||
if (parsed.seat !== undefined && parsed.seat !== state.room.selfSeat) {
|
||||
throw new RangeError(
|
||||
`self_exit_room seat mismatch: expected ${state.room.selfSeat}, received ${parsed.seat}`,
|
||||
);
|
||||
}
|
||||
if (this.options.gameSession.state !== 'active' || this.activeLease === null) {
|
||||
const errors = [
|
||||
new Error('self_exit_room requires an active game session and host lease'),
|
||||
...this.releaseGame(),
|
||||
];
|
||||
this.throwFault('self exit inconsistent state', errors);
|
||||
}
|
||||
|
||||
try {
|
||||
this.options.roomHistory?.exited(parsed.raw,state.room.infinite);
|
||||
this.options.store.clearRoom();
|
||||
} catch (error) {
|
||||
this.throwFault('self exit state commit', [error, ...this.releaseGame()]);
|
||||
}
|
||||
this.releaseOrFault('self exit cleanup');
|
||||
}
|
||||
|
||||
handleServerSwitch(switchConnection: () => void): void {
|
||||
this.requireOperational();
|
||||
const errors = this.releaseGame();
|
||||
try {
|
||||
switchConnection();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
if (errors.length > 0) this.throwFault('server switch', errors);
|
||||
}
|
||||
|
||||
/** The server returns free_room only after this switch; settlement still owns the game lease. */
|
||||
handleDissolutionServerSwitch(switchConnection:()=>void):void {
|
||||
this.requireOperational();
|
||||
if(this.options.store.getState().room.kind==='outside'&&this.options.gameSession.state==='idle'&&this.activeLease===null){
|
||||
switchConnection();
|
||||
return;
|
||||
}
|
||||
if(this.options.gameSession.state!=='active')throw Error('Dissolution server switch requires an active game session');
|
||||
switchConnection();
|
||||
}
|
||||
|
||||
handleDissolutionResult(result:DissolutionResult):void {
|
||||
this.requireOperational();
|
||||
if(!result.passed)throw Error('Dissolution settlement requires a server-approved result');
|
||||
if(result.deskfree!==undefined){
|
||||
this.options.gameSession.publish({type:'room.dissolution-confirmed',freeNow:result.freeNow,deskfree:result.deskfree,
|
||||
...(result.tips!==undefined?{tips:result.tips}:{}),...(result.time!==undefined?{time:result.time}:{})});
|
||||
return;
|
||||
}
|
||||
this.options.store.clearRoom();
|
||||
this.releaseOrFault('dissolution without settlement cleanup');
|
||||
}
|
||||
|
||||
handleKick(_data: unknown, stopConnection: () => void): void {
|
||||
if (this.wasKicked) return;
|
||||
this.requireOperational();
|
||||
this.loginIsPending = false;
|
||||
this.wasKicked = true;
|
||||
|
||||
const errors = this.releaseGame();
|
||||
try {
|
||||
stopConnection();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
if (errors.length > 0) this.throwFault('kick terminal cleanup', errors);
|
||||
}
|
||||
|
||||
private openCommittedRoom(
|
||||
state: PlatformState,
|
||||
reconnect: ReconnectSnapshot,
|
||||
operation: string,
|
||||
entry: RoomEntryInput,
|
||||
): void {
|
||||
if (state.room.kind !== 'inside') {
|
||||
this.throwFault(operation, [new Error('Cannot open a game session without committed room state')]);
|
||||
}
|
||||
|
||||
const priorCleanupErrors = this.releaseGame();
|
||||
if (priorCleanupErrors.length > 0) this.throwFault(operation, priorCleanupErrors);
|
||||
if (this.gameWasDisposed()) return;
|
||||
|
||||
let lease: GameHostLeasePort;
|
||||
try {
|
||||
lease = this.options.createGameHostLease();
|
||||
if (!lease || typeof lease !== 'object' || typeof lease.invalidate !== 'function') {
|
||||
throw new TypeError('createGameHostLease must return { host, invalidate }');
|
||||
}
|
||||
} catch (error) {
|
||||
this.throwFault(operation, [error]);
|
||||
}
|
||||
this.activeLease = lease;
|
||||
|
||||
try {
|
||||
this.options.gameSession.open(lease.host);
|
||||
if (this.gameWasDisposed()) return;
|
||||
this.options.gameSession.publish({
|
||||
type: 'room.entered',
|
||||
roomtype: state.room.roomtype,
|
||||
entry,
|
||||
});
|
||||
if (this.gameWasDisposed()) return;
|
||||
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
|
||||
} catch (error) {
|
||||
const openErrors = extractGameSessionOpenFaultErrors(error);
|
||||
const errors = openErrors === null
|
||||
? [error]
|
||||
: [openErrors[0], openErrors[1]];
|
||||
errors.push(...this.releaseGame());
|
||||
this.throwFault(operation, errors);
|
||||
}
|
||||
}
|
||||
|
||||
private releaseOrFault(operation: string): void {
|
||||
const errors = this.releaseGame();
|
||||
if (errors.length > 0) this.throwFault(operation, errors);
|
||||
}
|
||||
|
||||
private gameWasDisposed(): boolean {
|
||||
return this.options.gameSession.state === 'disposed';
|
||||
}
|
||||
|
||||
private releaseGame(): unknown[] {
|
||||
this.lastJoinedRoomcode = null;
|
||||
const errors: unknown[] = [];
|
||||
const lease = this.activeLease;
|
||||
this.activeLease = null;
|
||||
|
||||
if (this.options.gameSession.state !== 'idle' && !this.gameWasDisposed()) {
|
||||
try {
|
||||
this.options.gameSession.close();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (lease !== null) {
|
||||
try {
|
||||
lease.invalidate();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private throwFault(operation: string, errors: readonly unknown[]): never {
|
||||
const fault = new RuntimeSessionFault(operation, errors);
|
||||
this.loginIsPending = false;
|
||||
this.terminalFault = fault;
|
||||
throw fault;
|
||||
}
|
||||
|
||||
private requireOperational(): void {
|
||||
if (this.terminalFault !== null) throw this.terminalFault;
|
||||
if (this.wasKicked) throw new Error('RuntimeSession is kicked');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "3e0b81db-7986-47f4-9e23-fbc9b47d8a36",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
import type { RuntimeConfig } from '../config/runtime-config.ts';
|
||||
import { LOGIN_GUARD_MS } from '../core/constants.ts';
|
||||
import { STARTUP_CONFIG } from '../config/profiles.ts';
|
||||
import type { InboundMessage, OutboundEnvelope } from '../core/types/envelope.ts';
|
||||
import { realClock, type Clock } from '../net/heartbeat.ts';
|
||||
import type { WireClient, WireEvent } from '../net/wire-client.ts';
|
||||
import {
|
||||
buildLoginRequest,
|
||||
type LoginAccountIdentity,
|
||||
type LoginDeviceSnapshot,
|
||||
} from '../protocol/contracts/index.ts';
|
||||
import { PlatformHandlers } from '../protocol/platform-handlers.ts';
|
||||
import { Router } from '../protocol/router.ts';
|
||||
import { FIRST_SLICE_RPC_ROUTE, isLoginResponse } from '../protocol/first-slice-routes.ts';
|
||||
import type { GameEntry, GameHost } from '../sdk/contracts/index.ts';
|
||||
import {SceneOrderedGameSessionHost} from './room/scene-game-session.ts';
|
||||
import {ROOM_LIFECYCLE_CONFIG} from '../config/room-lifecycle.ts';
|
||||
import { PlatformCommands, type JoinRoomCommand, type CreateRoomCommand } from './commands.ts';
|
||||
import type { ConnectionIntent } from './connection-intent.ts';
|
||||
import {
|
||||
createGameHostAdapter,
|
||||
type GameHostLease,
|
||||
type NumericToolsPort,
|
||||
} from './game-host-adapter.ts';
|
||||
import { RuntimeSession } from './runtime-session.ts';
|
||||
import type { ScenePort } from './scene-port.ts';
|
||||
import { PlatformStore } from './stores/platform-store.ts';
|
||||
import type { PlatformState } from './stores/platform-types.ts';
|
||||
import { ReadyGate, type ReadyGateName } from './ready-gate.ts';
|
||||
|
||||
export type PlatformWireClient = Pick<
|
||||
WireClient,
|
||||
'subscribe' | 'start' | 'send' | 'switchServer' | 'reconnectCurrent' | 'stop'
|
||||
>;
|
||||
|
||||
export interface PlatformRuntimeOptions {
|
||||
readonly feedback?:import('../sdk/contracts/game-capabilities.ts').FeedbackOwnerPort;
|
||||
readonly numericTools?:NumericToolsPort;
|
||||
readonly gameEntry: GameEntry;
|
||||
readonly resolveRuntimeConfig: () => Promise<RuntimeConfig>;
|
||||
readonly createWireClient: (config: RuntimeConfig) => PlatformWireClient;
|
||||
readonly scene: ScenePort;
|
||||
readonly loadResources: () => Promise<void>;
|
||||
readonly waitForMinimumDisplay: () => Promise<void>;
|
||||
readonly getLoginDeviceSnapshot: () => LoginDeviceSnapshot;
|
||||
readonly roomHistory?: import('../adapters/native/owned-room-history.ts').OwnedRoomHistory;
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
type RuntimeLifecycle = 'idle' | 'starting' | 'running' | 'kicked' | 'fatal' | 'stopped';
|
||||
|
||||
class PlatformRuntimeCleanupError extends Error {
|
||||
readonly errors: readonly unknown[];
|
||||
|
||||
constructor(operation: string, errors: readonly unknown[]) {
|
||||
super(`PlatformRuntime cleanup failed during ${operation}`);
|
||||
this.name = 'PlatformRuntimeCleanupError';
|
||||
this.errors = Object.freeze([...errors]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns the sole headless config, wire, router, store, and game-session composition. */
|
||||
export class PlatformRuntime {
|
||||
private readonly store = new PlatformStore();
|
||||
private readonly gate = new ReadyGate();
|
||||
private readonly clock: Clock;
|
||||
private lifecycle: RuntimeLifecycle = 'idle';
|
||||
private config: RuntimeConfig | null = null;
|
||||
private wire: PlatformWireClient | null = null;
|
||||
private unsubscribeWire: (() => void) | null = null;
|
||||
private session: RuntimeSession | null = null;
|
||||
private router: Router | null = null;
|
||||
private gameSession: SceneOrderedGameSessionHost | null = null;
|
||||
private commands: PlatformCommands | null = null;
|
||||
private activeGameHostLease: GameHostLease | null = null;
|
||||
private connectionIntent: ConnectionIntent = { type: 'none' };
|
||||
private loginEnvelope: OutboundEnvelope | null = null;
|
||||
private loginGuard: unknown = null;
|
||||
private wireIsOpen = false;
|
||||
private loginSceneShown = false;
|
||||
private fatalError: Error | null = null;
|
||||
private wireStopped = false;
|
||||
private startupGeneration = 0;
|
||||
private readonly stateObservers = new Set<() => void>();
|
||||
private observerDispatchError: Error | null = null;
|
||||
|
||||
constructor(private readonly options: PlatformRuntimeOptions) {
|
||||
this.clock = options.clock ?? realClock;
|
||||
}
|
||||
|
||||
get state(): PlatformState {
|
||||
return this.store.getState();
|
||||
}
|
||||
|
||||
get ready(): boolean {
|
||||
return this.gate.ready;
|
||||
}
|
||||
|
||||
subscribeState(
|
||||
listener: (state: PlatformState) => void,
|
||||
onError: (error: unknown) => void,
|
||||
): () => void {
|
||||
if (
|
||||
this.lifecycle === 'kicked'
|
||||
|| this.lifecycle === 'fatal'
|
||||
|| this.lifecycle === 'stopped'
|
||||
) {
|
||||
throw new Error(`PlatformRuntime cannot subscribe while ${this.lifecycle}`);
|
||||
}
|
||||
|
||||
let active = true;
|
||||
const unsubscribeStore = this.store.subscribe((state) => {
|
||||
if (!active) return;
|
||||
try {
|
||||
listener(state);
|
||||
} catch (error) {
|
||||
off();
|
||||
try {
|
||||
onError(error);
|
||||
} catch (callbackError) {
|
||||
const failure = this.failureError(
|
||||
'UI observer',
|
||||
error,
|
||||
[callbackError],
|
||||
);
|
||||
if (this.isStopped()) {
|
||||
this.observerDispatchError = failure;
|
||||
} else {
|
||||
this.showFatalOnce(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const off = (): void => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
try {
|
||||
unsubscribeStore();
|
||||
} finally {
|
||||
this.stateObservers.delete(off);
|
||||
}
|
||||
};
|
||||
this.stateObservers.add(off);
|
||||
return off;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.lifecycle !== 'idle') {
|
||||
throw new Error(`PlatformRuntime cannot start while ${this.lifecycle}`);
|
||||
}
|
||||
const generation = this.startupGeneration + 1;
|
||||
this.startupGeneration = generation;
|
||||
this.lifecycle = 'starting';
|
||||
|
||||
try {
|
||||
this.options.scene.showLoading();
|
||||
this.requireActiveStartup(generation);
|
||||
const resourcesReady = this.beginReadyTask(
|
||||
'resources',
|
||||
this.options.loadResources,
|
||||
generation,
|
||||
);
|
||||
const minimumDisplayReady = this.beginReadyTask(
|
||||
'minimum-display',
|
||||
this.options.waitForMinimumDisplay,
|
||||
generation,
|
||||
);
|
||||
this.requireActiveStartup(generation);
|
||||
const config = await this.options.resolveRuntimeConfig();
|
||||
this.requireActiveStartup(generation);
|
||||
if (config.identity.gameid !== this.options.gameEntry.gameId) {
|
||||
throw new Error(
|
||||
'RuntimeConfig identity.gameid must strictly equal GameEntry.gameId: '
|
||||
+ `config=${String(config.identity.gameid)} (${typeof config.identity.gameid}), `
|
||||
+ `entry=${String(this.options.gameEntry.gameId)} (${typeof this.options.gameEntry.gameId})`,
|
||||
);
|
||||
}
|
||||
this.config = config;
|
||||
|
||||
this.requireActiveStartup(generation);
|
||||
const gameSession = new SceneOrderedGameSessionHost(
|
||||
this.options.gameEntry,
|
||||
this.options.scene,
|
||||
{...ROOM_LIFECYCLE_CONFIG,clock:this.clock,onFailure:failure=>{
|
||||
if(!this.isTerminal())this.showFatalOnce(failure);
|
||||
}},
|
||||
host=>{if(this.activeGameHostLease?.host===host)this.activeGameHostLease.invalidate();},
|
||||
);
|
||||
const send = Object.freeze({
|
||||
send: (envelope: OutboundEnvelope): void => {
|
||||
this.requireOpenWire().send(envelope);
|
||||
},
|
||||
});
|
||||
const commands = new PlatformCommands({
|
||||
runtimeConfig: config,
|
||||
store: this.store,
|
||||
send,
|
||||
getRoomProjection:()=>gameSession.roomProjection?.getSnapshot()??null,
|
||||
});
|
||||
const session = new RuntimeSession({
|
||||
roomHistory:this.options.roomHistory,
|
||||
store: this.store,
|
||||
gameSession,
|
||||
createGameHostLease: () => this.createGameHostLease(commands, send),
|
||||
onServerDenial: outcome => {
|
||||
// Legacy onopen retries only an authenticated session. A rejected first login
|
||||
// must wait for another user submission, including after a socket reconnect.
|
||||
if(outcome.rpc==='player_login'&&this.store.getState().players.selfPlayerId===null){
|
||||
this.connectionIntent={type:'none'};this.loginEnvelope=null;
|
||||
}
|
||||
this.options.scene.showServerDenial(outcome);
|
||||
},
|
||||
});
|
||||
const handlers = new PlatformHandlers({
|
||||
store: this.store,
|
||||
gameSession,
|
||||
session,
|
||||
connection: {
|
||||
switchServer: (target, resend) => { this.beginServerSwitch(target, resend); },
|
||||
stopForKick: (data) => { this.stopForKick(data); },
|
||||
},
|
||||
showMessage:(message,time,source)=>{
|
||||
if(!this.options.scene.showTips)throw Error('show_message requires ScenePort.showTips');
|
||||
this.options.scene.showTips(message,time,source);
|
||||
},
|
||||
});
|
||||
const router = new Router({ session, platform: handlers, gameSession });
|
||||
this.requireActiveStartup(generation);
|
||||
const wire = this.options.createWireClient(config);
|
||||
|
||||
this.requireActiveStartup(generation);
|
||||
this.gameSession = gameSession;
|
||||
this.commands = commands;
|
||||
this.session = session;
|
||||
this.router = router;
|
||||
this.wire = wire;
|
||||
this.requireActiveStartup(generation);
|
||||
this.subscribeToWire(wire, generation);
|
||||
this.requireActiveStartup(generation);
|
||||
this.markReady('config');
|
||||
this.lifecycle = 'running';
|
||||
this.requireActiveStartup(generation);
|
||||
wire.start();
|
||||
await Promise.all([resourcesReady, minimumDisplayReady]);
|
||||
this.requireActiveStartup(generation);
|
||||
} catch (error) {
|
||||
if (!this.isActiveStartup(generation)) {
|
||||
throw this.fatalError ?? error;
|
||||
}
|
||||
const fatal = this.showFatalOnce(error);
|
||||
throw fatal;
|
||||
}
|
||||
}
|
||||
|
||||
login(account: LoginAccountIdentity): void {
|
||||
const config = this.requireConfig();
|
||||
const wire = this.requireOpenWire();
|
||||
const session = this.requireSession();
|
||||
const device=this.options.getLoginDeviceSnapshot();
|
||||
const envelope = buildLoginRequest(
|
||||
config,
|
||||
account,
|
||||
device,
|
||||
);
|
||||
|
||||
wire.send(envelope);
|
||||
this.loginEnvelope = envelope;
|
||||
this.connectionIntent = { type: 'login', envelope };
|
||||
session.markLoginPending(device.deviceLogin.enabled?null:Object.freeze({openid:envelope.data.openid,unionid:envelope.data.unionid}));
|
||||
this.startLoginGuard();
|
||||
}
|
||||
|
||||
createRoom(command: CreateRoomCommand): void {
|
||||
this.requireCommands().createRoom(command);
|
||||
}
|
||||
|
||||
joinRoom(command: JoinRoomCommand): void {
|
||||
this.requireCommands().joinRoom(command);
|
||||
}
|
||||
|
||||
prepare(): void {
|
||||
this.requireCommands().prepare();
|
||||
}
|
||||
|
||||
sendRoomText(text:string,announcement=false):void {this.requireCommands().sendRoomText(text,announcement);}
|
||||
sendRoomPhrase(text:string):void {this.requireCommands().sendRoomPhrase(text);}
|
||||
sendRoomVoice(voiceurl:string,time:string|number):void {this.requireCommands().sendRoomVoice(voiceurl,time);}
|
||||
sendRoomExpression(id:number):void {this.requireCommands().sendRoomExpression(id);}
|
||||
breakRoom():void {this.requireCommands().breakRoom();}
|
||||
surrenderRoom(count:number):void {this.requireCommands().surrenderRoom(count);}
|
||||
changeRoom():void {this.requireCommands().changeRoom();}
|
||||
applyDissolution():void {this.requireCommands().applyDissolution();}
|
||||
agreeDissolution():void {this.requireCommands().agreeDissolution();}
|
||||
refuseDissolution():void {this.requireCommands().refuseDissolution();}
|
||||
acknowledgeDissolution():void {
|
||||
const result=this.state.dissolution;
|
||||
if(result?.kind!=='result')throw Error('dissolution acknowledgement requires a result');
|
||||
if(result.passed&&!result.freeNow){
|
||||
this.requireSession().handleDissolutionResult(result);
|
||||
if(this.state.room.kind==='outside'){this.options.scene.showLobby();return;}
|
||||
}
|
||||
if(this.state.dissolution===result)this.store.acknowledgeDissolution();
|
||||
}
|
||||
|
||||
getVipRooms(codes:readonly (string|number)[]):void {this.requireCommands().getVipRooms(codes);}
|
||||
getVipRankList(limit:number):void {this.requireCommands().getVipRankList(limit);}
|
||||
joinOwnedRoom(code:number,location:unknown):void {this.requireCommands().joinOwnedRoom(code,location);}
|
||||
getTreasureList():void {this.requireCommands().getTreasureList();}
|
||||
redeemCard(cardno:string):void {this.requireCommands().redeemCard(cardno);}
|
||||
joinVipRoom(code:string|number,location:unknown):void {this.requireCommands().joinVipRoom(code,location);}
|
||||
getPlayerTasks():void {this.requireCommands().getPlayerTasks();}
|
||||
getPlayerInvitecode():void {this.requireCommands().getPlayerInvitecode();}
|
||||
bindInvitecode(code:string):void {this.requireCommands().bindInvitecode(code);}
|
||||
claimTask(taskid:number|string):void {this.requireCommands().claimTask(taskid);}
|
||||
|
||||
exitRoom(): void {
|
||||
this.requireCommands().exitRoom();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.lifecycle === 'stopped') return;
|
||||
this.startupGeneration += 1;
|
||||
this.lifecycle = 'stopped';
|
||||
this.connectionIntent = { type: 'none' };
|
||||
this.wireIsOpen = false;
|
||||
|
||||
const errors: unknown[] = [];
|
||||
for (const off of [...this.stateObservers]) {
|
||||
this.captureCleanupError(errors, off);
|
||||
}
|
||||
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
|
||||
const unsubscribe = this.unsubscribeWire;
|
||||
this.unsubscribeWire = null;
|
||||
if (unsubscribe !== null) this.captureCleanupError(errors, unsubscribe);
|
||||
|
||||
const gameSession = this.gameSession;
|
||||
if (gameSession !== null) {
|
||||
this.captureCleanupError(errors, () => { gameSession.dispose(); });
|
||||
}
|
||||
|
||||
const lease = this.activeGameHostLease;
|
||||
this.activeGameHostLease = null;
|
||||
if (lease !== null) {
|
||||
this.captureCleanupError(errors, () => { lease.invalidate(); });
|
||||
}
|
||||
|
||||
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
|
||||
this.throwCleanupErrors('stop', errors);
|
||||
}
|
||||
|
||||
private beginReadyTask(
|
||||
name: ReadyGateName,
|
||||
task: () => Promise<void>,
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
this.requireActiveStartup(generation);
|
||||
return task();
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
if (this.isActiveStartup(generation)) this.markReady(name);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (this.isActiveStartup(generation)) this.showFatalOnce(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private markReady(name: ReadyGateName): void {
|
||||
if (
|
||||
this.lifecycle === 'fatal'
|
||||
|| this.lifecycle === 'kicked'
|
||||
|| this.lifecycle === 'stopped'
|
||||
) return;
|
||||
this.gate.mark(name);
|
||||
if (this.gate.ready && !this.loginSceneShown) {
|
||||
this.loginSceneShown = true;
|
||||
this.options.scene.showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
private onWireEvent(event: WireEvent): void {
|
||||
if (
|
||||
this.lifecycle === 'fatal'
|
||||
|| this.lifecycle === 'kicked'
|
||||
|| this.lifecycle === 'stopped'
|
||||
) return;
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'open':
|
||||
this.wireIsOpen = true;
|
||||
this.store.setConnectionPhase('connected');
|
||||
this.throwObserverDispatchError();
|
||||
this.markReady('socket');
|
||||
if (this.connectionIntent.type === 'switch') {
|
||||
const loginEnvelope = this.loginEnvelope;
|
||||
if (loginEnvelope === null) {
|
||||
throw new Error('server switch requires a previously sent player_login envelope');
|
||||
}
|
||||
this.requireOpenWire().send(this.connectionIntent.resend);
|
||||
this.connectionIntent = { type: 'login', envelope: loginEnvelope };
|
||||
} else if (this.connectionIntent.type === 'login') {
|
||||
this.sendLoginEnvelope(this.connectionIntent.envelope);
|
||||
}
|
||||
break;
|
||||
case 'message':
|
||||
if (isLoginResponse(event.message)) {
|
||||
this.clearLoginGuard();
|
||||
}
|
||||
if(this.shouldQueueRoomPacket(event.message))this.gameSession!.queueRoomPacket(()=>this.dispatchMessage(event.message));
|
||||
else this.dispatchMessage(event.message);
|
||||
break;
|
||||
case 'slow':
|
||||
case 'reconnecting':
|
||||
this.wireIsOpen = false;
|
||||
this.store.setConnectionPhase(event.type);
|
||||
this.throwObserverDispatchError();
|
||||
if (this.isTerminal()) return;
|
||||
this.gameSession?.cancelPendingMount();
|
||||
this.options.scene.showReconnect();
|
||||
break;
|
||||
case 'close':
|
||||
this.wireIsOpen = false;
|
||||
this.gameSession?.cancelPendingMount();
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = this.withObserverDispatchError(error);
|
||||
// A lifecycle callback may stop and then throw. Preserve that error without
|
||||
// replacing the requested terminal stop with a fatal lifecycle transition.
|
||||
if (this.isStopped()) throw failure;
|
||||
this.showFatalOnce(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private shouldQueueRoomPacket(message:InboundMessage):boolean {
|
||||
// Preserve Router's authentication barrier at receipt time, before a later mount can finish.
|
||||
if(this.session?.loginPending||!this.gameSession?.isRoomScopeAvailable)return false;
|
||||
if(message.route===this.options.gameEntry.route)return true;
|
||||
if(message.route==='agent'&&message.rpc==='free_room')return true;
|
||||
if(message.route!=='room'||!Object.prototype.hasOwnProperty.call(FIRST_SLICE_RPC_ROUTE,message.rpc))return false;
|
||||
// Entry and connection control packets may invalidate the pending scope immediately.
|
||||
return !['player_login','create_room','self_join_room','connect_roomserver','connect_agentserver','kick_server','kick_offline'].includes(message.rpc);
|
||||
}
|
||||
|
||||
private dispatchMessage(message:InboundMessage):void {
|
||||
try {
|
||||
const stateBeforeDispatch=this.store.getState();
|
||||
const dispatchResult=this.requireRouter().dispatch(message);
|
||||
this.throwObserverDispatchError();
|
||||
if(dispatchResult==='handled-platform'&&this.lifecycle==='running'&&this.store.getState()!==stateBeforeDispatch){
|
||||
this.updateSceneAfterMessage(message.route,message.rpc);
|
||||
}
|
||||
} catch(error) {
|
||||
// Deferred packet processing keeps observer errors in the same failure chain.
|
||||
throw this.withObserverDispatchError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private isStopped(): boolean {
|
||||
return this.lifecycle === 'stopped';
|
||||
}
|
||||
|
||||
private isTerminal(): boolean {
|
||||
return this.lifecycle === 'fatal'
|
||||
|| this.lifecycle === 'kicked'
|
||||
|| this.lifecycle === 'stopped';
|
||||
}
|
||||
|
||||
private throwObserverDispatchError(): void {
|
||||
const failure = this.takeObserverDispatchError();
|
||||
if (failure === null) return;
|
||||
throw failure;
|
||||
}
|
||||
|
||||
private takeObserverDispatchError(): Error | null {
|
||||
const failure = this.observerDispatchError;
|
||||
this.observerDispatchError = null;
|
||||
return failure;
|
||||
}
|
||||
|
||||
private withObserverDispatchError(error: unknown): unknown {
|
||||
const observerFailure = this.takeObserverDispatchError();
|
||||
return observerFailure === null
|
||||
? error
|
||||
: this.failureError('UI observer dispatch', observerFailure, [error]);
|
||||
}
|
||||
|
||||
private sendLoginEnvelope(envelope: OutboundEnvelope): void {
|
||||
const wire = this.requireOpenWire();
|
||||
const session = this.requireSession();
|
||||
wire.send(envelope);
|
||||
session.markLoginPending();
|
||||
this.startLoginGuard();
|
||||
}
|
||||
|
||||
private beginServerSwitch(target: string, resend: OutboundEnvelope): void {
|
||||
if (this.loginEnvelope === null) {
|
||||
throw new Error('server switch requires a previously sent player_login envelope');
|
||||
}
|
||||
const wire = this.requireWire();
|
||||
this.clearLoginGuard();
|
||||
this.connectionIntent = { type: 'switch', target, resend };
|
||||
this.wireIsOpen = false;
|
||||
wire.switchServer(target);
|
||||
}
|
||||
|
||||
private createGameHostLease(
|
||||
commands: PlatformCommands,
|
||||
send: { send(envelope: OutboundEnvelope): void },
|
||||
): GameHostLease {
|
||||
const lease = createGameHostAdapter({
|
||||
feedback:this.options.feedback,
|
||||
numericTools:this.options.numericTools,
|
||||
gameEntry: this.options.gameEntry,
|
||||
store: this.store,
|
||||
commands,
|
||||
send,
|
||||
});
|
||||
let invalidated = false;
|
||||
const trackedLease: GameHostLease = Object.freeze({
|
||||
host: lease.host,
|
||||
invalidate: (): void => {
|
||||
if (invalidated) return;
|
||||
invalidated = true;
|
||||
try {
|
||||
lease.invalidate();
|
||||
} finally {
|
||||
if (this.activeGameHostLease === trackedLease) {
|
||||
this.activeGameHostLease = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
this.activeGameHostLease = trackedLease;
|
||||
return trackedLease;
|
||||
}
|
||||
|
||||
private updateSceneAfterMessage(route: string, rpc: string): void {
|
||||
if (route !== 'platform' && route !== 'agent' && route !== 'room') return;
|
||||
if (rpc === 'player_login') {
|
||||
if (this.store.getState().room.kind === 'outside') {
|
||||
this.options.scene.showLobby();
|
||||
} else {
|
||||
this.requireGameSession().showRoomAfterOpen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (rpc === 'self_join_room' || rpc === 'create_room') {
|
||||
if (this.store.getState().room.kind === 'inside') this.requireGameSession().showRoomAfterOpen();
|
||||
return;
|
||||
}
|
||||
if (rpc === 'self_exit_room') this.options.scene.showLobby();
|
||||
if (rpc === 'free_room'&&this.state.room.kind==='outside')this.options.scene.showLobby();
|
||||
}
|
||||
|
||||
private stopForKick(data: unknown): void {
|
||||
this.lifecycle = 'kicked';
|
||||
this.connectionIntent = { type: 'none' };
|
||||
this.wireIsOpen = false;
|
||||
const errors: unknown[] = [];
|
||||
for (const off of [...this.stateObservers]) {
|
||||
this.captureCleanupError(errors, off);
|
||||
}
|
||||
this.captureCleanupError(errors, () => { this.store.setConnectionPhase('kicked'); });
|
||||
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
|
||||
this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); });
|
||||
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
|
||||
this.throwCleanupErrors('kick', errors);
|
||||
}
|
||||
|
||||
private stopWireOnce(): void {
|
||||
if (this.wireStopped) return;
|
||||
const wire = this.wire;
|
||||
if (wire === null) return;
|
||||
this.wireStopped = true;
|
||||
wire.stop();
|
||||
}
|
||||
|
||||
private subscribeToWire(wire: PlatformWireClient, generation: number): void {
|
||||
let actualUnsubscribe: (() => void) | null = null;
|
||||
let releaseRequested = false;
|
||||
let released = false;
|
||||
const temporaryOwner = (): void => {
|
||||
if (released) return;
|
||||
if (actualUnsubscribe === null) {
|
||||
releaseRequested = true;
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
const unsubscribe = actualUnsubscribe;
|
||||
actualUnsubscribe = null;
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
this.unsubscribeWire = temporaryOwner;
|
||||
const returnedUnsubscribe: unknown = wire.subscribe(
|
||||
(event) => { this.onWireEvent(event); },
|
||||
);
|
||||
if (typeof returnedUnsubscribe !== 'function') {
|
||||
throw new TypeError(
|
||||
'WireClient.subscribe must return an unsubscribe function',
|
||||
);
|
||||
}
|
||||
actualUnsubscribe = returnedUnsubscribe as () => void;
|
||||
if (!releaseRequested) return;
|
||||
|
||||
const cleanupErrors: unknown[] = [];
|
||||
this.captureCleanupError(cleanupErrors, temporaryOwner);
|
||||
if (cleanupErrors.length === 0) this.requireActiveStartup(generation);
|
||||
|
||||
const primary = this.fatalError
|
||||
?? new Error(`PlatformRuntime startup interrupted while ${this.lifecycle}`);
|
||||
const failure = this.failureError(
|
||||
'subscription handoff',
|
||||
primary,
|
||||
cleanupErrors,
|
||||
);
|
||||
if (this.fatalError !== null) this.fatalError = failure;
|
||||
throw failure;
|
||||
}
|
||||
|
||||
private captureCleanupError(errors: unknown[], operation: () => void): void {
|
||||
try {
|
||||
operation();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
private throwCleanupErrors(operation: string, errors: readonly unknown[]): void {
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new PlatformRuntimeCleanupError(operation, errors);
|
||||
}
|
||||
|
||||
private startLoginGuard(): void {
|
||||
this.clearLoginGuard();
|
||||
let handle: unknown = null;
|
||||
handle = this.clock.setTimeout(() => {
|
||||
if (this.loginGuard !== handle) return;
|
||||
this.loginGuard = null;
|
||||
try {
|
||||
if(STARTUP_CONFIG.debugIO)console.log('[连接状态] 登录回包超时,请求重连',{reason:'login-response-timeout',timeoutMs:LOGIN_GUARD_MS,time:new Date().toISOString()});
|
||||
this.requireWire().reconnectCurrent();
|
||||
} catch (error) {
|
||||
this.showFatalOnce(error);
|
||||
}
|
||||
}, LOGIN_GUARD_MS);
|
||||
this.loginGuard = handle;
|
||||
}
|
||||
|
||||
private clearLoginGuard(): void {
|
||||
if (this.loginGuard === null) return;
|
||||
const handle = this.loginGuard;
|
||||
this.loginGuard = null;
|
||||
this.clock.clearTimeout(handle);
|
||||
}
|
||||
|
||||
private requireConfig(): RuntimeConfig {
|
||||
this.requireRunning();
|
||||
if (this.config === null) throw new Error('PlatformRuntime has no RuntimeConfig');
|
||||
return this.config;
|
||||
}
|
||||
|
||||
private requireOpenWire(): PlatformWireClient {
|
||||
const wire = this.requireWire();
|
||||
if (!this.wireIsOpen) throw new Error('PlatformRuntime connection is not open');
|
||||
return wire;
|
||||
}
|
||||
|
||||
private requireWire(): PlatformWireClient {
|
||||
this.requireRunning();
|
||||
if (this.wire === null) throw new Error('PlatformRuntime has no WireClient');
|
||||
return this.wire;
|
||||
}
|
||||
|
||||
private requireSession(): RuntimeSession {
|
||||
this.requireRunning();
|
||||
if (this.session === null) throw new Error('PlatformRuntime has no RuntimeSession');
|
||||
return this.session;
|
||||
}
|
||||
|
||||
private requireRouter(): Router {
|
||||
this.requireRunning();
|
||||
if (this.router === null) throw new Error('PlatformRuntime has no Router');
|
||||
return this.router;
|
||||
}
|
||||
|
||||
private requireGameSession(): SceneOrderedGameSessionHost {
|
||||
this.requireRunning();
|
||||
if (this.gameSession === null) throw new Error('PlatformRuntime has no game session');
|
||||
return this.gameSession;
|
||||
}
|
||||
|
||||
private requireCommands(): PlatformCommands {
|
||||
this.requireRunning();
|
||||
if (this.commands === null) throw new Error('PlatformRuntime has no platform commands');
|
||||
return this.commands;
|
||||
}
|
||||
|
||||
private requireRunning(): void {
|
||||
if (this.lifecycle !== 'running') {
|
||||
throw this.fatalError ?? new Error(`PlatformRuntime is ${this.lifecycle}`);
|
||||
}
|
||||
}
|
||||
|
||||
private isActiveStartup(generation: number): boolean {
|
||||
return this.startupGeneration === generation
|
||||
&& (this.lifecycle === 'starting' || this.lifecycle === 'running');
|
||||
}
|
||||
|
||||
private requireActiveStartup(generation: number): void {
|
||||
if (this.isActiveStartup(generation)) return;
|
||||
throw this.fatalError
|
||||
?? new Error(`PlatformRuntime startup interrupted while ${this.lifecycle}`);
|
||||
}
|
||||
|
||||
private showFatalOnce(value: unknown): Error {
|
||||
if (this.fatalError !== null) return this.fatalError;
|
||||
const primary = value;
|
||||
this.fatalError = this.failureError('fatal', primary, []);
|
||||
this.startupGeneration += 1;
|
||||
this.lifecycle = 'fatal';
|
||||
this.connectionIntent = { type: 'none' };
|
||||
this.wireIsOpen = false;
|
||||
|
||||
const cleanupErrors: unknown[] = [];
|
||||
for (const off of [...this.stateObservers]) {
|
||||
this.captureCleanupError(cleanupErrors, off);
|
||||
}
|
||||
this.captureCleanupError(cleanupErrors, () => { this.clearLoginGuard(); });
|
||||
|
||||
const unsubscribe = this.unsubscribeWire;
|
||||
this.unsubscribeWire = null;
|
||||
if (unsubscribe !== null) this.captureCleanupError(cleanupErrors, unsubscribe);
|
||||
|
||||
const gameSession = this.gameSession;
|
||||
if (gameSession !== null) {
|
||||
this.captureCleanupError(cleanupErrors, () => { gameSession.dispose(); });
|
||||
}
|
||||
|
||||
const lease = this.activeGameHostLease;
|
||||
this.activeGameHostLease = null;
|
||||
if (lease !== null) {
|
||||
this.captureCleanupError(cleanupErrors, () => { lease.invalidate(); });
|
||||
}
|
||||
|
||||
this.captureCleanupError(cleanupErrors, () => { this.stopWireOnce(); });
|
||||
const fatal = this.failureError('fatal', primary, cleanupErrors);
|
||||
this.fatalError = fatal;
|
||||
try {
|
||||
this.options.scene.showFatal(fatal);
|
||||
} catch (sceneError) {
|
||||
const withSceneError = this.failureError(
|
||||
'fatal',
|
||||
primary,
|
||||
[...cleanupErrors, sceneError],
|
||||
);
|
||||
this.fatalError = withSceneError;
|
||||
return withSceneError;
|
||||
}
|
||||
return fatal;
|
||||
}
|
||||
|
||||
private failureError(
|
||||
operation: string,
|
||||
primary: unknown,
|
||||
cleanupErrors: readonly unknown[],
|
||||
): Error {
|
||||
if (cleanupErrors.length === 0 && primary instanceof Error) return primary;
|
||||
return new PlatformRuntimeCleanupError(operation, [primary, ...cleanupErrors]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "9f495e9b-65ae-4b3e-b44c-9c9a91f6679f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ServerDenial } from './runtime-session.ts';
|
||||
import type {RoomScope} from './room/room-scope.ts';
|
||||
|
||||
/** Semantic scene intents; the headless runtime never imports Cocos. */
|
||||
export interface ScenePort {
|
||||
showLoading(): void;
|
||||
showLogin(): void;
|
||||
showLobby(): void;
|
||||
showRoom(scope:RoomScope): void|PromiseLike<void>;
|
||||
showReconnect(): void;
|
||||
showKicked(data: unknown): void;
|
||||
/** Required when the host supports the server's generic message UI. */
|
||||
showTips?(message: string, time: number, source?:'show_message'|'update_roomcard'|'update_bean'|'binding_invitecode'): void;
|
||||
/** Recoverable server result; the consumer interprets the original failure payload. */
|
||||
showServerDenial(outcome: ServerDenial): void;
|
||||
showFatal(error: Error): void;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4f5169b6-e140-4a3a-9eed-e155fecf24df",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {requireArray,requireInteger,requireNumber,requireRecord,requireString} from '../../protocol/contracts/validation.ts';
|
||||
import type {ActiveDissolution,DissolutionParticipant,DissolutionResult,DissolutionState,InsideRoomState,PlatformState} from './platform-types.ts';
|
||||
|
||||
export type DissolutionRpc = 'self_apply_free_room'|'other_apply_free_room'|'self_agree_free_room'|'other_agree_free_room'|'self_refuse_free_room'|'other_refuse_free_room'|'free_room';
|
||||
|
||||
function inside(state:PlatformState):InsideRoomState {
|
||||
if(state.room.kind!=='inside')throw Error('dissolution requires an occupied room');
|
||||
return state.room;
|
||||
}
|
||||
|
||||
function participants(state:PlatformState,value:unknown):readonly DissolutionParticipant[]{
|
||||
const room=inside(state),values=requireArray(value,'agreefree.state');
|
||||
if(values.length!==room.seatPlayerIds.length)throw Error('agreefree.state length must match server room seats');
|
||||
const result:DissolutionParticipant[]=[];
|
||||
values.forEach((value,seat)=>{
|
||||
const playerid=room.seatPlayerIds[seat];
|
||||
if(playerid===null){if(value!==null)throw Error(`agreefree.state[${seat}] requires null for empty seat`);return;}
|
||||
const player=state.players.entities[playerid];
|
||||
if(!player)throw Error(`players.entities missing occupied seat ${seat}`);
|
||||
const vote=requireInteger(value,`agreefree.state[${seat}]`);
|
||||
if(vote!==-1&&vote!==0&&vote!==1&&vote!==2)throw Error('agreefree.state requires -1, 0, 1, or 2');
|
||||
result.push(Object.freeze({seat,playerid,nickname:player.nickname,state:vote}));
|
||||
});
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
function initiator(players:readonly DissolutionParticipant[]):DissolutionParticipant {
|
||||
const matches=players.filter(player=>player.state===0);
|
||||
if(matches.length!==1)throw Error('agreefree.state requires exactly one initiator');
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
export function restoreDissolution(state:PlatformState,payload:unknown,now:number):ActiveDissolution {
|
||||
const room=inside(state),raw=requireRecord(payload,'agreefree');
|
||||
const players=participants(state,raw.state),applicant=initiator(players);
|
||||
const countdown=requireNumber(raw.countdown,'agreefree.countdown');
|
||||
if(countdown<0)throw Error('agreefree.countdown requires non-negative seconds');
|
||||
return Object.freeze({kind:'active',roomcode:room.roomcode,selfSeat:room.selfSeat,participants:players,initiator:applicant,deadline:now+countdown*1000});
|
||||
}
|
||||
|
||||
/** No quorum or timeout calculation is performed here. Every transition is a server packet. */
|
||||
export function reduceDissolution(state:PlatformState,rpc:DissolutionRpc,payload:unknown,now:number):DissolutionState {
|
||||
const room=inside(state),raw=requireRecord(payload,'$.data');
|
||||
if(rpc==='self_apply_free_room'||rpc==='other_apply_free_room'){
|
||||
const vote=restoreDissolution(state,raw.agreefree,now);
|
||||
const seat=rpc==='self_apply_free_room'?room.selfSeat:requireInteger(raw.seat,'$.data.seat');
|
||||
if(vote.initiator.seat!==seat)throw Error('apply seat conflicts with agreefree initiator');
|
||||
return vote;
|
||||
}
|
||||
if(rpc==='free_room'){
|
||||
// Legacy/actual server omits freeNow for ordinary voting, and emits 1 for immediate dissolution.
|
||||
if(raw.freeNow!==undefined&&raw.freeNow!==false&&raw.freeNow!==true&&raw.freeNow!==0&&raw.freeNow!==1)throw Error('freeNow requires boolean or protocol flag');
|
||||
const freeNow=raw.freeNow===true||raw.freeNow===1;
|
||||
const players=freeNow&&raw.seats===undefined
|
||||
? state.dissolution?.participants ?? Object.freeze([])
|
||||
: participants(state,raw.seats);
|
||||
const applicant=freeNow&&raw.seats===undefined?state.dissolution?.initiator??null:initiator(players);
|
||||
const result:DissolutionResult={kind:'result',roomcode:room.roomcode,selfSeat:room.selfSeat,participants:players,initiator:applicant,passed:true,refusedSeat:null,freeNow,
|
||||
...(raw.deskfree!==undefined&&raw.deskfree!==null?{deskfree:requireRecord(raw.deskfree,'$.data.deskfree')}:{}),
|
||||
...(raw.tips!==undefined?{tips:requireString(raw.tips,'$.data.tips')}:{}),
|
||||
...(raw.time!==undefined?{time:requireNumber(raw.time,'$.data.time')}:{})};
|
||||
return Object.freeze(result);
|
||||
}
|
||||
const vote=state.dissolution;
|
||||
if(!vote||vote.kind!=='active')throw Error(`${rpc} requires an active dissolution vote`);
|
||||
const seat=rpc.startsWith('self_')?room.selfSeat:requireInteger(raw.seat,'$.data.seat');
|
||||
const voter=vote.participants.find(player=>player.seat===seat);
|
||||
if(!voter)throw Error(`${rpc} requires a participating seat`);
|
||||
const refused=rpc==='self_refuse_free_room'||rpc==='other_refuse_free_room';
|
||||
const players=Object.freeze(vote.participants.map(player=>player.seat===seat?Object.freeze({...player,state:refused?2 as const:1 as const}):player));
|
||||
if(refused)return Object.freeze({kind:'result',roomcode:vote.roomcode,selfSeat:vote.selfSeat,participants:players,initiator:vote.initiator,passed:false,refusedSeat:seat,freeNow:false});
|
||||
return Object.freeze({...vote,participants:players});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user