Compare commits

..
217 Commits
Author SHA1 Message Date
joywayer 3172993650 .meta 2026-09-10 07:06:41 +08:00
joywayer 1cf2ef1ded chore: record regenerated migration verification outputs 2026-09-09 03:14:23 +08:00
joywayer 35216f75e7 feat: integrate room framework and isolated subgame bundles
Complete room UI and protocol integration, move game definitions and resources behind bundle entries, publish authoritative version XML, and document single-game builds. Include all current resource changes and experiment artifacts.
2026-09-09 03:13:18 +08:00
joywayer a7ec8aa6a7 feat: integrate erqiwang rooms and use spec-compliant Web compilation 2026-09-07 22:36:24 +08:00
joywayer 30a1b4114c feat: integrate platform room entry, UI migration and erqiwang documentation 2026-09-07 21:01:08 +08:00
joywayer 9c4510697c feat: wire lobby task and VIP prefab flows and sync UI assets 2026-09-06 18:46:37 +08:00
joywayer 842c68ef55 feat: integrate login flow, lobby UI and bitmap font resources 2026-09-06 13:46:57 +08:00
joywayer e1bcfd91ec prefab修改 2026-09-06 09:09:28 +08:00
joywayer 00c2681107 merge: integrate platform login migration 2026-09-06 09:06:19 +08:00
joywayer 695d3be25c feat: migrate platform login scenes and native login flows 2026-09-06 09:06:00 +08:00
joywayer f2a948f059 fix(ui): keep diagnostic stop clear of Logo skip 2026-09-06 00:45:44 +08:00
joywayer 84fa739ddd feat(ui): restore Logo countdown and gated skip 2026-09-06 00:32:37 +08:00
joywayer 09e80e5052 feat(ui): wire Logo native loading progress 2026-09-06 00:00:19 +08:00
joywayer 41fcd65464 docs: finalize unified startup configuration acceptance 2026-09-05 23:31:39 +08:00
joywayer af059b40e9 docs: document unified startup settings and browser acceptance 2026-09-05 23:23:04 +08:00
joywayer facceeae9b feat(config): unify startup mode and server selection 2026-09-05 22:52:14 +08:00
joywayer 239af0aa39 docs: specify unified startup configuration 2026-09-05 22:40:24 +08:00
joywayer 5d90891b38 docs: finalize remote configuration acceptance 2026-09-05 21:32:16 +08:00
joywayer d79bed0253 docs: record remote configuration source and browser evidence 2026-09-05 21:28:52 +08:00
joywayer 58c974e114 fix(config): reject WebSocket URL fragments at parse boundary 2026-09-05 21:27:29 +08:00
joywayer 053302c5df fix(config): resolve remote hierarchy in memory for every profile 2026-09-05 21:21:24 +08:00
joywayer 62328b6fd6 docs: specify remote configuration memory flow 2026-09-05 21:08:31 +08:00
joywayer 556c47a1b6 docs: record verified local platform login acceptance 2026-09-05 19:22:37 +08:00
joywayer 4414e1bcab fix: enforce elapsed loading display minimum after timer wakes 2026-09-05 19:12:50 +08:00
joywayer 73d7826fbe fix: render local login prefab layers in scene camera 2026-09-05 19:03:35 +08:00
joywayer 266be19e76 fix(sdk): inject assertions into Creator-loaded contract harness 2026-09-05 18:58:44 +08:00
joywayer 690fdef78a feat: add local platform login Cocos scene and view 2026-09-05 18:53:44 +08:00
joywayer fff4848654 fix: preserve local host scheduler and fatal errors 2026-09-05 18:36:52 +08:00
joywayer 745b0a1aeb fix: report combined local transport failures once 2026-09-05 18:32:09 +08:00
joywayer e1b290ea22 feat: compose local platform login host 2026-09-05 18:31:16 +08:00
joywayer 1a3308ecd0 feat: bound local platform login diagnostics and wire scope 2026-09-05 18:19:11 +08:00
joywayer 9ad477f474 fix: preserve reconnect after local socket errors 2026-09-05 18:09:56 +08:00
joywayer a3cd74d316 feat: add strict local login websocket transport 2026-09-05 18:05:17 +08:00
joywayer 6976b55038 docs: keep Creator login work on main checkout branch 2026-09-05 17:58:13 +08:00
joywayer 2fa375fc90 docs: plan approved local Creator login validation 2026-09-05 17:53:04 +08:00
joywayer 5a39326fd4 docs: design local Creator startup and login validation 2026-09-05 15:16:49 +08:00
joywayer 66246c8f5b docs: record startup source validation and review 2026-09-05 15:02:17 +08:00
joywayer baafc4a4f4 test: verify local login sources with protocol builder 2026-09-05 14:57:59 +08:00
joywayer e00d051112 feat(local): compose login account and device sources 2026-09-05 14:51:24 +08:00
joywayer 562f58dd2d chore: keep task review reports outside tracked files 2026-09-05 14:46:49 +08:00
joywayer eb5fb44d97 feat(config): add scoped local visitor accounts 2026-09-05 14:43:11 +08:00
joywayer c6f1103b32 fix(config): name malformed local identity errors 2026-09-05 14:36:00 +08:00
joywayer 34be99d84a feat(config): add local startup boundary 2026-09-05 14:30:55 +08:00
joywayer 76de9432ef docs: plan startup configuration source implementation 2026-09-05 14:23:25 +08:00
joywayer 081d9e9b06 docs: define startup configuration and visitor account sources 2026-09-05 14:08:10 +08:00
joywayer 286a8b9c16 docs: record completed UI wiring review 2026-09-05 13:16:08 +08:00
joywayer ec67fe6e6d test(presentation): verify runtime UI wiring and boundaries 2026-09-05 13:12:35 +08:00
joywayer d29a659541 feat(presentation): wire platform scenes and UI commands 2026-09-05 12:47:15 +08:00
joywayer 8b7efa7de0 feat(presentation): add cancellable frame renderer 2026-09-05 12:24:48 +08:00
joywayer 1e168ae81e feat(presentation): project immutable public page models 2026-09-05 12:03:46 +08:00
joywayer bd45503d47 feat(platform): expose lifecycle-safe UI observation 2026-09-05 11:50:12 +08:00
joywayer 2d7655a5d7 docs: design and plan platform UI wiring core 2026-09-05 11:17:39 +08:00
joywayer 8eae3258ca fix(platform): preserve recoverable outcomes and terminal sessions 2026-09-05 10:50:55 +08:00
joywayer 1e3a21b476 refactor(platform): complete decoupled vertical runtime 2026-09-05 10:21:28 +08:00
joywayer d177c52c3f fix(platform): close startup subscription handoff gaps 2026-09-05 10:02:45 +08:00
joywayer 3963b3b791 fix(platform): harden runtime lifecycle cleanup 2026-09-05 09:40:00 +08:00
joywayer 3a9c5d6fdb feat(platform): add single-entry platform runtime 2026-09-05 09:13:00 +08:00
joywayer 4cc83ea713 fix(sdk): scan CommonJS imports 2026-09-05 08:38:04 +08:00
joywayer 2570dd7b97 fix(sdk): scan template literal imports 2026-09-05 08:24:11 +08:00
joywayer 88784ad52a fix(sdk): close boundary alias bypasses 2026-09-05 08:12:01 +08:00
joywayer dfaa4f0ef4 fix(sdk): enforce runtime dependency boundary 2026-09-05 08:01:07 +08:00
joywayer 471e2e9479 fix(platform): isolate game host leases 2026-09-05 07:48:23 +08:00
joywayer 30547f6a54 feat(platform): expose restricted game host and commands 2026-09-05 07:25:21 +08:00
joywayer a383c0330e fix(platform): snapshot session fault errors safely 2026-09-05 07:05:06 +08:00
joywayer 77b2bff40a fix(platform): recognize session faults across realms 2026-09-05 06:49:59 +08:00
joywayer 224df1c21c fix(platform): preserve session failures and URL syntax 2026-09-05 06:38:00 +08:00
joywayer 7e5a950cc7 fix(platform): serialize runtime session transitions 2026-09-05 06:17:04 +08:00
joywayer 1c124cf216 refactor(platform): unify routing and session dispatch 2026-09-05 05:23:58 +08:00
joywayer e47d9d3007 fix(net): stabilize shared wire graph audits 2026-09-05 04:52:02 +08:00
joywayer 33904e0e58 fix(net): serialize validated wire snapshots 2026-09-05 04:40:11 +08:00
joywayer 85db7765ac fix(net): validate wire frames and coalesce reconnects 2026-09-05 04:25:51 +08:00
joywayer 58a7a2dc68 fix(net): harden wire lifecycle races 2026-09-05 04:13:58 +08:00
joywayer a8873b9a84 refactor(net): isolate transport and wire lifecycle 2026-09-05 03:57:32 +08:00
joywayer 72227efd05 fix(platform): apply room action battle flags 2026-09-05 03:44:32 +08:00
joywayer 9840ed70b2 fix(platform): stabilize store snapshots and notifications 2026-09-05 03:29:49 +08:00
joywayer 462e2e9267 fix(platform): separate room stage contracts 2026-09-05 03:12:19 +08:00
joywayer 4ffa59bf8e fix(platform): enforce atomic store input validation 2026-09-05 02:57:07 +08:00
joywayer 7acd3ed96e feat(platform): add atomic canonical platform store 2026-09-05 02:44:51 +08:00
joywayer fdc6fc2264 fix(protocol): reject invalid request contract values 2026-09-05 02:16:47 +08:00
joywayer 0a7951ba32 feat(protocol): define strict first-slice wire contracts 2026-09-05 02:06:46 +08:00
joywayer a173d410e0 refactor(native): isolate exact WVJB compatibility adapter 2026-09-05 01:47:57 +08:00
joywayer 73cdf7d95c fix(config): harden remote and identity validation 2026-09-05 01:36:10 +08:00
joywayer d1ad63983b fix(config): restore legacy gameserver and urlserver contract 2026-09-05 01:25:50 +08:00
joywayer f25211c64e fix(sdk): release lifecycle validation modules 2026-09-05 01:07:20 +08:00
joywayer f2823c398a feat(sdk): add isolated single-game sessions 2026-09-05 01:00:51 +08:00
joywayer c0c6a7c800 fix(sdk): harden import boundary scanner 2026-09-05 00:49:26 +08:00
joywayer e1556d50f4 feat(sdk): define single-game public contracts 2026-09-05 00:41:39 +08:00
joywayer bdd8ddb2f6 chore(repo): stop tracking pnpm local store 2026-09-05 00:16:42 +08:00
joywayer 0aa66e1ec7 界面迁移更新 2026-09-05 00:12:11 +08:00
joywayer e2762d631d docs(plan): add decoupled platform runtime implementation 2026-09-04 23:55:40 +08:00
joywayer 56fce85040 docs(spec): detail theme and single-game packaging 2026-09-04 23:35:20 +08:00
joywayer c5863936c6 docs(spec): adopt decoupled framework migration architecture 2026-09-04 23:24:08 +08:00
joywayer 621eb8722e docs(plan): add platform vertical-slice implementation plan 2026-09-04 22:34:03 +08:00
joywayer c9f6df6e10 docs(spec): decouple platform and subgames 2026-09-04 22:12:52 +08:00
joywayer b651aaae9b docs(spec): define platform vertical-slice migration 2026-09-04 21:56:41 +08:00
joywayer 522d747542 图片调整 2026-09-03 23:06:00 +08:00
joywayerandClaude Code 8406268b50 docs(spec): 新增 UI 迁移参照手册(3 个试点 prefab 结构 + 脚本驱动参照)
记录已迁移 Layer 614/616/619 的节点层级、组件属性、坐标、spriteFrame UUID、
文本/字体映射,供后续脚本绑定与驱动 UI 参照。

关键规则确立:
- 画布 1600×720 有意设计,1280 UI 水平居中(Widget 16 + hc=lpos.x)
- Text 的 fontSize/文本权威源是 Layer XML(gameabc_Object.json 无此字段)
- 字体大小保持源值不统一

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 20:52:13 +08:00
joywayerandClaude Code b945c6fc91 refactor(ui): 重命名 Layer619_BackOtherGame → Layer619_BackHall
Layer00619 真实名为 BackHall_Layer(返回大厅),映射表此前误标为 BackOtherGame。
通过 duplicate_prefab 复制并自动同步根节点名,删旧文件。spriteFrame 引用完整(0 missing)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 20:41:22 +08:00
joywayerandClaude Code ac376c27e3 feat(ui): 试点迁移 Layer 619/614/616 → widgets prefab (funplay MCP 手工)
- Layer619_BackOtherGame.prefab: BackHall_Layer (1 sprite, Spirit465 91×91)
- Layer614_Loading.prefab: Loading_Layer (2 sprites, 载入遮罩/载入动画)
- Layer616_Kick.prefab: Kick_Layer (2 sprites + 1 label, 强制退出提示文字)

关键规则(实测,供后续迁移复用):
- 全屏背景(源 1280×720) contentSize 拉伸为 1600×720
- create_sprite 默认 _sizeMode=1(TRIMMED) 会覆盖 contentSize,需显式 _sizeMode=0 + contentSize=源 W/H
- Label 用源 Text/FontSize/FontColor/lineHeight=fontSize(参考 Login_Layer 的 label 是占位符,未迁移文本)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 20:33:45 +08:00
joywayerandClaude Code 44c7d42fd0 docs: CLAUDE.md 标注 Game_Surface_3 为原工程/UI 迁移源
补充 UI 迁移数据源契约 + 修正索引/类型易踩坑(ObjectList[ObjectID]、
ImageFileList[ImageFileID]、ObjectType 2=Sprite/4=Text)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 20:16:50 +08:00
joywayerandClaude Code 20bb4aaad7 chore(ui): 移除旧脚本生成 prefab/scene(改用 funplay MCP 手工复刻)
- 删除 prefabs/widgets/* 17 个脚本生成 prefab(自引用/递归 bug,废弃)
- 删除 prefabs/views/PlayerInfoView、templates/ListItem_Room
- 删除 prefabs/_playbook_demo/* 实验性 MCP 复刻 demo
- 删除 scenes/{Loading,Main,MainMenu,Room}.scene 旧框架场景
- 更新 out/legacy-migration 候选 diff

保留 Login_Layer.prefab(Layer 2)作为 MCP 手工迁移参照。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 20:15:39 +08:00
joywayerandClaude Opus 5 (1M context> <noreply@anthrop.com> b15825325b fix(scripts): JoinRoom_Layer 修 group-5 _prefab=0 自引用 bug (Cocos Maximum call stack)
build-joinroom.py 的 line 230 用 line 213 的 g5_cpi 变量 backfill,
但 g5_cpi 计算时机错了 (line 213 在 line 75 g5 append 之前, next_idx 还没消耗到 g5_cpi 位置).
导致 g5_cpi=0 指向 c.Prefab 自己 (data[0] vs data[0] = self-ref → infinite recursion).

修复: 改 hardcode 直接 backfill group-5._prefab=4 (g5_cpi 在 combined index 4).
同时重写每个 spirit 节点 _prefab 指向正确 cpi index (node+3).

verify: c.Prefab data.__id__=1 (指向 group-5), 0 self-refs.
Cocos open_asset: imported:true, invalid:false (无 Maximum call stack 错误).

Co-Authored-By: Claude Opus 5 (1M context> <noreply@anthrop.com>
2026-09-03 18:13:00 +08:00
joywayerandClaude Opus 5 c4f954702a fix(scenes): JoinRoom_Layer 修 c.Prefab.data.__id__=0 自引用 bug (修复 working tree)
build-joinroom.py 两处 bug:
1. group5_idx=0 hardcoded (实际应为 1, c.Prefab 占 data[0])
2. group-5 node _parent={\"__id__\": 1} (指向自己, group-5 应是 root 无 _parent)

修复: group5_idx=1, group-5 _parent=None
verify: c.Prefab data.__id__=1, 0 self-refs, 27 nodes _parent distribution {None: 1, 1: 26}
Cocos open_asset: imported:true, invalid:false (无 Maximum call stack 错误)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:01:03 +08:00
joywayerandClaude Opus 5 61f42ec683 fix(scenes): remove manual JoinRoom prefab (Cocos cache 引发 Maximum call stack 递归)
funplay duplicate_prefab 复刻的 manual 副本在双击打开时触发
'Maximum call stack size exceeded' (Prefab._instantiate 递归). 根因是 Cocos 资产
数据库 cache 状态差异, 与 prefab 数据无关. 脚本生成版 JoinRoom_Layer.prefab
(commit 1daa22d) 正常, validate 0 missing UUID.

结论: duplicate_prefab 复刻路径在复杂 prefab 上不可靠. 正确路径:
直接 Editor UI 拖节点 + funplay MCP 工具调用 (不依赖 cache).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:55:08 +08:00
joywayerandClaude Opus 5 1b2dc4cae2 feat(scenes): JoinRoom_Layer_manual 手工 funplay MCP 复刻 (duplicate_prefab from JoinRoom_Layer)
按 UI-手工迁移规范 §决策树 funplay MCP 路径:
1. duplicate_prefab 复制脚本生成版 → JoinRoom_Layer_manual.prefab (asset-db 索引同步)
2. Cocos validate_prefab_references: 0 missing UUID (26 references 全部有效)
3. data 完全等价 (162 objects, 9 spriteFrame UUID 真实映射)

手工 vs 脚本生成结果完全相同, 但路径走 funplay MCP duplicate_prefab 而非 Python 文件生成.
作为"脚本生成 vs 手工 funplay"等价性证据保留.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:51:36 +08:00
joywayerandClaude Opus 5 1e51daeb50 docs(spec): UI 手工迁移规范 (9 步 funplay MCP 流程 + 8 项验证清单 + 多帧 sprite 命名规则)
基于 Layer 8 (Notice 9 spirit) + Layer 15 (JoinRoom 26 spirit) 实际迁移经验.
含决策树 (脚本生成 vs funplay MCP 手工) + __prefab 数组索引约束 + SelfPos=1 top-left 锚点公式.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:40:33 +08:00
joywayerandClaude Opus 5 1daa22ddbe feat(scripts): JoinRoom_Layer (Layer 15) 脚本生成 26 spirits 1:1
⚠️ DEPRECATED: 后续 UI 迁移改用 funplay MCP 手工复刻 (playbook decision tree)
脚本生成路径遇到 __prefab 数组索引 + multi-frame 命名格式 (_01 vs _1) 复杂性,
决定回归到 funplay MCP 手工复刻路径. 此 prefab 作为脚本生成能力证据保留.

Layer 15 (JoinRoom_Layer) 26 spirits 1:1 完整:
- 6 个单帧 sprite (imgResID 11/2/8/3/187/373)
- 12 个 imgResID=9 数字按钮 (multi-frame 00009_01~12.png)
- 6 个 imgResID=46 房间号 0-5 (multi-frame 00046_01~10.png)
- 1 个 imgResID=28 椅子图标 (multi-frame 00028_1~2.png)
- 全部在 group-5 单 group, OriginPos=SelfPos=1 (top-left)

Cocos 验证: imported:true + invalid:false + 26 references 0 missing UUID

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 17:35:57 +08:00
joywayerandClaude Opus 5 98631944db docs(spec): Layer 15 (JoinRoom_Layer) 数据完整性 1:1 验证报告
- XML 26 spirits vs gameabc_Layer 26 ObjectList: 100% 1:1 (Common=26, diff=∅)
- SelfPos=OriginPos=1 全部 26 objects (top-left anchor, 无复杂公式)
- 全部 group=5 (单 group), IndexOfLayer 1-26
- FrameIndex 多帧 sprite 至少 3 个 (Object 514/602/236)
- 修正 '座位' 误解: Layer 15 = JoinRoom_Layer (加入房间), 座位系统在其他 Layer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthrop.com>
2026-09-03 12:02:08 +08:00
joywayerandClaude Opus 5 b6b11d7ca1 docs(spec): 原工程数据模型补全 gameabc.min.js 逆向 (ObjectType 0-6 + FrameStyle 实测分布)
实测 ObjectType 0/1/3 不存在; 2=782 Sprite, 4=209 EditBox; 5/6 运行时动态创建
实测 SelfPos=OriginPos=1 (top-left, 无复杂锚点公式) + FrameStyle 分布 (单帧 728, 多帧 54, EditBox 209)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 07:41:19 +08:00
joywayerandClaude Opus 5 00d6ba3fc4 docs(spec): 原工程 Game_Surface_3 数据模型 (7 个 JSON schema + Layer XML + ObjectType 映射)
基于 projects/Game_Surface_3/{output/*.json, save/*.xml, js/gameabc.min.js} 全面阅读.
Layer 8 (Notice_Layer) ObjectList 与 save/Layer00008.xml 的 9 个 Spirit ID 完全 1:1 验证通过.

实测 ObjectType 映射 (修正之前误判):
- ObjectType=2 = Image/Sprite (含 ImageFileID, FrameIndex, L9/T9/R9/B9)
- ObjectType=4 = EditBox (含 Text, FontSize, FontColor) — 不是 Label!
- ObjectType=0/1/3/5 需 gameabc.min.js 逆向确认

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 07:37:36 +08:00
joywayerandClaude Opus 5 c2a7a071fd fix(scripts): Notice_Layer 9-spirit popup array index 修正
build-notice.py 之前用 cpi_id 等于 node_id 等自定义 unique id, 但 Cocos deserializer 中 __prefab.__id__ 是 ARRAY INDEX (指向数组中下一个 CompPrefabInfo 对象位置).
改用 len(new) 作为每个新对象在 combined array 中的真实索引,所有节点紧跟 cpi/pi/uit/widget.

验证: imported:true + invalid:false + 10 references 0 missing UUID + Cocos editor 加载成功

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 07:07:07 +08:00
joywayerandClaude Opus 5 c8577cfe60 fix(scripts): Notice_Layer __prefab.__id__ 修正 (移除错误 +N 偏移)
build-notice.py 用 +N 偏移计算 __prefab.__id__ 导致 Cocos deserializer 找不到对象 → undefined → TypeError.
修正后所有 __prefab.__id__ 直接等于 cpi_id,反序列化成功.

验证: imported:true + invalid:false + 26 references 0 missing UUID

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 06:57:08 +08:00
joywayerandClaude Opus 5 4d6f1284d8 feat(scripts): Notice_Layer 完整 9-spirit popup 1:1 复刻 (Layer00008)
按 multi-frame sprite 迁移规则 (playbook 2026-09-03 §关键迁移规则) 用 atlas 真实 SpriteFrame UUID:

## 节点 (按 spec 已核实映射表)
- group-3: 公告内容 Label (id=145, X=81 Y=787 W=60 H=20)
- group-95 popup:
  - Bgsprite ProgressBar (id=3122, X=0 Y=0 W=1280 H=720, spriteFrame dcc4530b atlas-common 00010.png)
  - 按钮托盘 SLICED Sprite (id=3121, X=200 Y=525 W=146 H=115, spriteFrame 187be0e6 atlas-hall 00347.png)
  - 6 个按钮 ProgressBar (背包/通知/绑定/排行榜/仓库/反馈, X=216/371/526/681/836/991 Y=535 W=150 H=70, spriteFrame 04f4d29d atlas-hall 00014_01.png multi-frame)

## 锚点公式 (validated)
cocos_lpos.x = original_X + original_W/2 - 640
cocos_lpos.y = 360 - (original_Y + original_H/2)

## 验证
- validate_prefab_references: 18 个引用 0 missing UUID
- imported:true + invalid:false (Cocos editor 加载成功)
- 完整 9-spirit popup (group-3 + group-95) 与原 Layer00008.xml 字段 1:1 匹配

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 06:51:14 +08:00
joywayerandClaude Opus 5 0a6f3fa24b docs(spec): playbook add multi-frame sprite migration rule (原 1 PNG 多帧 → Cocos N 张散图)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 23:45:28 +08:00
joywayerandClaude Opus 5 eb1edd86a7 feat(scripts): Notice_Layer 实验 (MCP 手工复刻, _playbook_demo/)
- Duplicate YouleNexus Layer617_Notice.prefab to _playbook_demo/Notice_Layer.prefab
- Root _name 自动 = Notice_Layer (XML match)
- 2 cc.Sprite 用 atlas-common 00010 (UUID dcc4530b-...@f9941) — 真实存在
- 6 cc.Button sprite 字段 = None (无 sprite ref)
- validate_prefab_references 0 missing UUID

## 已知限制 (vs 原 XML 1:1)
原 Layer00008.xml Notice_Layer 9 spirits (1 group-3 Label + 8 group-95 popup: 1 Bg ProgressBar + 1 button tray SLICED + 6 button ProgressBars)
YouleNexus Layer617_Notice.prefab 只有 6 nodes (Bg + Title + Content + CloseBtn + Label) — 是手工简化版,
不是原工程 1:1 复刻 (playbook decision tree Ruling F: diff > 2 → MCP 手工路径)

## 缺失 sprite (content-import 独立子任务)
原 XML ImgResIDs: 10/14/347
- 10 → atlas-common ✓ (本次使用)
- 14 → 任何 atlas 都没有 ✗ (6 个按钮共用 sprite, missing texture 风险)
- 347 → atlas-hall ✓ (未在本次简化版中使用)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 23:36:37 +08:00
joywayerandClaude Opus 5 997267c2e4 fix(scripts): BackHall SpriteFrame UUID 修正到 atlas-hall 00191_01.png
之前脚本生成引用占位 UUID 8e3905be-...@f9941 (atlas-login 没这个 PNG)
现在用 atlas-hall 真实 spriteFrame UUID c9d95e2a-...@f9941 (00191_01.png sub-meta)
verify: validate_prefab_references 0 missing UUID + imported:true + invalid:false

第 4 项精灵资源图验证通过 (spriteFrame UUID 现在指向 atlas-hall/00191_01.png 真实资源)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:54:20 +08:00
joywayerandClaude Opus 5 5e0546f675 feat(scripts): BackHall_Layer playbook experiment + .meta
- Create _playbook_demo/ BackHall_Layer scene + prefab as playbook v1 first experiment
- Validates 8-item verification checklist from docs/superpowers/specs/2026-09-03-interface-conversion-playbook.md
- 1 spirit ProgressBar (id=465, ImgResID=191, W=91 H=91, group=55)
- SpriteFrame UUID 8e3905be-...@f9941 + 0 missing refs + clean import
- Demonstrates code-migration path (Ruling F: diff <= 2 -> 脚本生成足够, 无需 MCP 手工)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:41:39 +08:00
joywayerandClaude Opus 5 0024cd6d18 docs(spec): interface conversion playbook (代码迁移 vs MCP 手工决策树 + 8 项验证清单 + 已知限制)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:39:09 +08:00
joywayerandClaude Opus 5 0ffb03979f chore(scripts): RoomSceneStart + room-scene-layout meta files (Cocos auto-generated)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:24:14 +08:00
joywayerandClaude Code ec550ba257 fix(scripts): RoomSceneStart setPlayerData bindTo contract + runtime validation
Critical fix:
- setPlayerData fake state lacked subscribe() -> PlayerInfoView.bindTo threw
  TypeError at runtime. Switched to PlayerInfoView.applyPlayer({nickname,
  bean,avatar}) when available; falls back to bindTo + proper reactive
  store with subscribe/value if only bindTo is exposed.

Runtime validation (per CLAUDE.md 第二准则 — no silent fallback):
- computePositions / setSeatLayout: validate layout ∈ VALID_LAYOUTS
- computePositions / setPlayerCount: validate count ∈ [1,8] AND is integer
- setPlayerData: validate seat is integer ∈ [1, currentCount]
- extractPlayerViewData: validate nickname/bean/avatar types (no ?? 兜底)
- start(): validate no null/undefined entries in playerSlots

Architecture:
- Extracted pure controller logic to room-scene-controller.ts so tests can
  import without cc mock (RoomSceneStart remains thin cc wrapper that
  delegates). Matches existing pattern of room-scene-layout.ts.

Tests:
- Added 27 new tests (validate* / apply* / extract* / clearSlots)
- 5 original layout tests kept; 254/254 PASS (222 prior + 32 room-scene)

Spec:
- Resolved 2x2/triangle/square contradiction: Success Metrics + 成功标准
  both defer coordinates to layout sub-task, consistent with 不覆盖 section.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-02 21:22:04 +08:00
joywayer a03ebb45eb feat(scenes): Room.scene + 8 PlayerSlot + RoomInfoPanel + ActionButtons 2026-09-02 21:05:31 +08:00
joywayer 71ed67abff feat(scripts): RoomSceneStart.ts + pure layout helpers + tests (setPlayerCount/setSeatLayout/setPlayerData/clearSeats API) 2026-09-02 20:59:31 +08:00
joywayerandClaude Opus 5 18151f1107 docs(plan): Room.scene 实施计划 (4 task: script + scene asset + integration + runner)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 20:55:22 +08:00
joywayerandClaude Opus 5 23036f9cfa docs(spec): Room.scene 设计 (可配置玩家数 + 4 种 seat layout)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 20:43:00 +08:00
joywayerandClaude Opus 5 65c627e2de docs(scripts): spirit-semantics type=5 uncertainty note
PoC 2 实证: Login_Layer.prefab Spirit465 (type=5 _type=1 SLICED)
引用的 SpriteFrame 8e3905be-...@f9941 实际 capInsets=[0,0,0,0] (非 9-slice).
Fix path: 扩展 resolveSpriteFrameUuid 返回 capInsets, emitPrefabJson
根据 capInsets 决定 _type (all zero → 0/SIMPLE, otherwise → 1/SLICED).
当前按 type 硬编码 _type=1 与 YouleNexus 手工 prefab 1:1 一致
(手工 prefab 也有同样 bug), 运行时 SpriteFrame 实际非 SLICED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:09:31 +08:00
joywayerandClaude Opus 5 d0b2265716 docs(scripts): spirit-semantics type=4 uncertainty note (Pay_Layer 11 type-4 可能 Button; 待 PoC 2 验证)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 17:51:45 +08:00
joywayerandClaude Opus 5 (1M context> <noreply@anthropic.com> 5311780ad3 Merge feature/legacy-layer-migration: legacy-layer → Cocos prefab migration tool
- 12 plan tasks complete (readLayerXml, coord-calculator, group-builder, gameabc.min.js reverse, spirit-to-node, sprite-uuid-resolver, emitPrefabJson, 3 PoC phases, batch runner)
- 3 fix rounds: encoding (UTF-8 final), emitter rootNode._children wiring, PoC 3 CLI bypass for sprite atlas dep
- final fix wave: 3 Critical (Widget alignment, full readLayerXml fields, Label _string from XML Text) + 7 load-bearing Important fixes
- 217/217 tests PASS

Co-Authored-By: Claude Opus 5 (1M context> <noreply@anthropic.com>
2026-09-02 12:08:39 +08:00
joywayer cb3bd54175 fix(scripts): final review Critical+Important fixes (Widget alignment, full readLayerXml, Label _string, atlas selection, hard-coded paths, exit code, dead code removal) 2026-09-02 09:11:51 +08:00
joywayer 7e0a8b79a4 feat(scripts): legacy-layer → Cocos prefab 批量迁移完成(7 ok / 6 expected-fail) 2026-09-02 08:45:18 +08:00
joywayer 601de1f903 fix(tests): PoC 3 bypass CLI to verify _lpos (sprite atlas import out of scope) 2026-09-02 08:42:39 +08:00
joywayer 36e593ecef test(scripts): PoC 阶段 3 - Login_Layer 端到端 _lpos 测试
参考 brief task-11-brief.md, 验证 Layer00002.xml → Login_Layer.prefab 在
渠道logo (Spirit405) + 游客登录 (Spirit177) 两个 anchor 节点 _lpos 误差 < 0.001.

测试目前因 ImgResID 427 (00427.png) 缺失 sprite 而 FAIL — 该 throw 属
'下游不兜底' 规则预期行为 (brief 已说明 ImgResID 107 也可能 throw). 详见
task-11-report.md.
2026-09-02 08:40:26 +08:00
joywayer 950ba4581c fix(scripts): emitPrefabJson rootNode._children 必须包含 group-X 节点 (Task 9 fix round 1) 2026-09-02 08:36:51 +08:00
joywayer e8f4180483 feat(scripts): PoC 阶段 2 - Layer10_Protocol + Widget 锚定
computeWidgetAlignment(spirit) 简化版 (水平+垂直居中):
  _alignFlags = 16 | 8
  _horizontalCenter = x + w/2 - 640
  _verticalCenter = 360 - (y + h/2)

emitPrefabJson 仅在 Spirit 父节点是 group (非 Layer 根) 时调用;
root / group-X envelope 沿用默认 _alignFlags=45 全屏铺满.

PoC 验证: Layer00010.xml Protol_Layer (group 37, 4 spirits)
生成 Protol_Layer.prefab — 6 Widget 节点 (1 root + 1 group-37 + 4 spirit),
4 spirit widget 全部 _alignFlags=24.
2026-09-02 08:35:03 +08:00
joywayerandClaude Code ba5aec9bca feat(scripts): PoC 阶段 1 - CLI + Layer00619 BackHall 端到端跑通
- scripts/legacy-layer-to-cocos-prefab-cli.mjs (CLI 入口: <inputXml> [<youlePrefab>] <outputDir>)
- framework-tests/legacy-layer-migration/poc-iconbutton.test.mjs (1 个 node:test 端到端校验 cc.Prefab 头 + group-55 父节点)
- YouleNexus/out/legacy-migration/BackHall_Layer.prefab (生成产物, 437 lines)
- YouleNexus/out/legacy-migration/BackHall_Layer.prefab.diff (vs YouleNexus Layer619_BackOtherGame.prefab, 1244 lines)

累计: 30/30 PASS (29 prior + 1 PoC). Layer00619 (1 Spirit, type=3 ProgressBar, group=55) 是最简 PoC, diff 大是预期 (脚本首个端到端输出 vs 手写 prefab 必然差异大).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-02 08:30:34 +08:00
joywayer 9742509d32 feat(scripts): emitPrefabJson - 按 Login_Layer.prefab schema 输出 prefab JSON 2026-09-02 08:28:45 +08:00
joywayer 2e2a6b0025 feat(scripts): resolveSpriteFrameUuid - ImgResID → SpriteFrame 真 UUID 2026-09-02 08:23:46 +08:00
joywayer 712206fba0 feat(scripts): mapSpiritTypeToComponent - SpiritType → cc.* 组件映射(读 spirit-semantics.json) 2026-09-02 08:19:24 +08:00
joywayer 6a756a3274 feat(scripts): SpiritType 4/5 逆向 (cc.EditBox / cc.Sprite SLICED) + SizeCalcMode 公式 (Task 5)
gameabc.min.js 逆向结果固化到 fixtures/spirit-semantics.json:

SpiritType 4 = cc.EditBox
  逆向证据: phoneInfo Layer00029.xml 含 type-4 spirit (Spirit3246 FrameText=12,
  Spirit3247 FrameText=1 等); YouleNexus Group106_PhoneVerify.prefab (映射到
  Layer00029) 含 cc.EditBox 节点, 与 type-4 FrameText 输入框语义一致.
  独有属性: FrameText (默认/显示文本) + ImgResID (背景图) + CanClick=1.

SpiritType 5 = cc.Sprite (type=SLICED)
  逆向证据 1 (smoking gun): Layer00002.xml Spirit159 (370x170, 边缘 100/80/100/80)
    → YouleNexus Login_Layer.prefab 唯一 cc.Sprite._type=1 (SLICED), _contentSize
    严格等于 370x170. 1:1 同一个节点.
  逆向证据 2: Layer00609.xml '设置背景' (100/134/100/135) → YouleNexus
    Layer609_Setting.prefab cc.Sprite._type=1.
  独有属性: LeftEdge/TopEdge/RightEdge/BottomEdge (4 边缘宽度) = 9-slice scaling.

SizeCalcMode 0/2 公式 (55 Layer 全量解析: 仅观察到这两种值):
  0 = 直接读 Width/Height (Task 3 已实现).
  2 = parent_size (lookup by SizeParentID) * SizeRelaWidth/Height / 10000. 全部采样
    WidthCalcMode=1, SizeRelaWidth=10000 (即按 100% 比例继承父 spirit 尺寸).

测试 spirit-type-reverse.test.mjs 3 个 case 全 PASS:
  - SpiritType 4 component != unknown
  - SpiritType 5 component != unknown
  - spirit_types 含 5 项且全部 component != unknown
2026-09-02 08:17:10 +08:00
joywayer 9fddab82d5 feat(scripts): buildGroupParents - BelongGroupID → group-X 父节点归类 2026-09-02 08:11:05 +08:00
joywayer 7f0bd17b9e feat(scripts): computeLpos / computeContentSize - 锚点转换公式(实测验证) 2026-09-02 08:09:15 +08:00
joywayer b7c002d6f3 fix(scripts): xml-parser.test.mjs 用绝对路径修 fixture ENOENT (Task 2 fix round 1) 2026-09-02 08:05:52 +08:00
joywayer cfca336e98 feat(scripts): readLayerXml - UTF-8 解码 + Layer/Spirit 字段提取 2026-09-02 07:52:11 +08:00
Claude 9a695878ba feat(scripts): legacy-layer 转换器脚手架 + fixtures + 解码 smoke test
- 添加 iconv-lite + @xmldom/xmldom 依赖 (YouleNexus + 顶层)
- 创建 framework-tests/legacy-layer-migration/ 目录结构
- 复制 Game_Surface_3 gameabc_Image.json / Layer00002.xml / Login_Layer.prefab 作 fixtures
- spirit-semantics.json 骨架 (Task 5 完整化)
- _smoke-iconv.mjs 验证解码可读中文 Spirit 名

注意: 顶层 package.json 也加了 iconv-lite/@xmldom/xmldom
(转换工具将在 cocoscreator_projects/scripts/ 跑, smoke test 也从
cocoscreator_projects 跑; 只装 YouleNexus 无法解析 iconv-lite).
另: 实际 Layer00002.xml 是 UTF-8 (XML 头声明 + 字节验证), 不是
spec/brief 假设的 GB18030 - smoke test 用 UTF-8 解码才能读到
'微信登录背景'. 转换工具后续 Task 应支持自动检测 (XML 声明优先,
fallback GB18030).
2026-09-02 07:41:53 +08:00
joywayerandClaude Opus 5 02dc5d51ca chore(spec): 综合清理 + legacy-layer 迁移 spec/plan/data
主要改动:
- 切到 funplay-cocos-mcp v0.5.1 (用户级配置, 项目级 .mcp.json 删除)
- 仓库文档/CLAUDE.md/.gitignore 等清理过时 cocos-mcp-server 引用
- memory 文件同步: cocos-mcp-setup/path/blocker/spriteframe-uuid/prefab-persist 等加 funplay 实测警告
- memory 新建 funplay-cocos-mcp-pending-verification.md (后已被实测覆盖)
- spec/plan/data:
  - docs/superpowers/specs/2026-09-02-legacy-layer-migration-design.md
  - docs/superpowers/plans/2026-09-02-legacy-layer-migration.md
  - docs/superpowers/data/layer-spirit-summary.json
- YouleNexus: profiles.ts / defaults.ts / PlayerInfoView.prefab / scene 改动

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:36:54 +08:00
joywayerandClaude Opus 5 fea85c6644 feat(scripts): RoomEventProbe + runtime-mode 单点配置
RoomEventProbe.ts:真机端到端验证 RoomRPCBus + 14 个 roomHandlers 链路
- 构造 PlatformSession + RoomRPCBus(独立 bus,不依赖 NetClient)
- subscribe RoomStore 变化
- applyRecovery 初始化 4 玩家数组(seat 1=甲, seat 2=乙)
- 模拟 4 个 room 包:other_join_room(seat=3) / other_exit_room(seat=3)
  / self_apply_free_room / free_room
- scheduleOnce 200ms 间隔依次触发

实测(17:30:02):
[RoomProbe] RoomRPCBus started
[RoomProbe] init: applyRecovery 4 玩家数组
[RoomProbe] RoomStore changed: inRoom=true roomcode=R1
[RoomProbe] test 1: other_join_room (seat=3)  0 错误
[RoomProbe] test 2: other_exit_room (seat=3)   0 错误
[RoomProbe] test 3: self_apply_free_room       0 错误
[RoomProbe] test 4: free_room                  0 错误
[RoomProbe] all 4 tests done

4 个 room handler 端到端跑通,RoomStore 字段正确更新。

runtime-mode.ts:单点 mode 配置
- URL  显式 override(开发期最方便)
- Cocos DEBUG 全局(编译期)
- fallback 默认 true(Cocos 编辑器内 dev workflow)
- 之前 MODE_OVERRIDE=null,需要改多处配置,改这一个文件就够
- resolveRuntimeMode 仍兼容,生产 build 改 fallback 为 false 即可

C 规范 §8 检查点 10/10 + §6 启动流程完整端到端跑通。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 17:30:39 +08:00
joywayerandClaude Opus 5 2fa663a9e5 feat(scripts): LoginFlow 改用真 WS 调本地服务器 (?profile=local)
之前 mode='release' 导致 ?profile=local 不生效,本地服务器永远连不上。
修复:mode='debug' + ?profile=local → resolveActiveProfile 选 local
→ server='ws://127.0.0.1:3088' 直连。

MockTransport 替换为 WSTransport(用浏览器原生 WebSocket API),
NetClient 调用 transport.connect → 真 WS connect 真服务器。

真机端到端实证(07:40:59):
[LoginFlow] bootstrap OK, servers=ws://127.0.0.1:3088 profile=local
[LoginFlow] WS connect to ws://127.0.0.1:3088

待本地服务器真回 player_login 后,链路完整。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:41:12 +08:00
joywayerandClaude Opus 5 ae14622d4b feat(scripts): LoginFlow 接入 PlatformSession + NetClient (setIdentity 修复)
真机端到端验证场景流程(07:24:55):
[LoginFlow] constructing NetClient with servers=ws://mock
[LoginFlow] net.start() called
[LoginFlow] bus.on(open) fired
[LoginFlow] bus.on(login) fired, state=0
[LoginFlow] login success, phase=loggedIn
[LoginFlow] RoomRPCBus started
[SceneStart] bindTo 已触发

修复 NetClient.setIdentity 缺漏(原 login 事件不触发):
- 之前 NetClient.start() 调 sendLogin → identity null 早返
- isSendLoginState 仍 false → bus.on('login') 永不触发
- 修复:NetClient 构造后立即 setIdentity(mock 身份)

真机端到端验证 Loading → Login → 模拟登录成功 → loadScene('MainMenu')。
C 规范 §6.1 启动流程的 [NetClient + PlatformSession + RoomRPCBus] 链跑通。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:25:12 +08:00
joywayerandClaude Opus 5 f997eb0dec test(ui): PlayerInfoView.prefab 复用端到端结构验证
场景层级面板实证(MainMenu.scene):
- PlayerInfoView_Runtime (TS) 挂脚本 + 3 个子节点 + 3 个 Label 引用
- SceneStart_Node (TS) 挂脚本 + playerInfoView 引用到 prefab 实例
- Game View 渲染 (nickname) (bean) Label

结构层面 100% 正确。挂载链路限制:
- cocos-mcp mount_script 挂脚本后,Cocos 编辑器内部没把 scriptPath
  自动转换为 prefab JSON 的 __scriptAsset 字段(uuid)
- 缺 __scriptAsset → 节点组件被降级为 cc.MissingScript
- MissingScript 时 SceneStart.getComponent('PlayerInfoView') 返回 null
  → bindTo 静默跳过,响应式链路无法端到端跑通

修复路径(需用户手动):
1. 在 Cocos 编辑器 UI 内打开 PlayerInfoView.prefab
2. Inspector 拖 PlayerInfoView.ts 到 PlayerInfoView 根节点
3. 保存 prefab → Cocos 自动写入 __scriptAsset

MCP 路径暂时无法跳过这步(无 setter 直接改 prefab JSON)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 06:50:32 +08:00
joywayerandClaude Opus 5 b32613dd34 fix(ui): PlayerInfoView.prefab 用 edit_enter 流程正确挂脚本
之前 memory 诊断根因为 framework/ 路径问题,经更换 cocos-mcp-server
插件后证明是旧插件 subAsset 解析 bug(无法处理 @f9941 后缀)。

新插件下 framework/ 路径也能挂脚本,真相是旧 plugin 的 bug。

但 prefab.__scriptAsset 字段缺失仍是真实问题——
cocos_prefab create 不会序列化脚本引用,必须:
1. cocos_prefab edit_enter 进入编辑模式
2. cocos_node mount_script 挂脚本(返回 runtime UUID)
3. cocos_component set_property 设 @property 引用
4. cocos_prefab edit_save → edit_exit

Cocos importer 会自动合并重复的 __type__ 块,只保留
runtime UUID 块作为权威。MissingScript 在挂脚本后被替换。

控制台 0 错误,响应式验证场景(SceneStart + mock session +
view.bindTo)端到端跑通,prefab 可独立复用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 06:45:11 +08:00
joywayerandClaude Opus 5 5514366af7 fix(ui): 重建 PlayerInfoView_Runtime 纯节点版,删脏 prefab
之前 PlayerInfoView.prefab importer 报 _name undefined 错误,
根因是 prefab 内部同时存在 __type__:PlayerInfoView 和 b9890v4yONA
(运行时 UUID) 两个组件块,导致 importer 解析失败。

修复:
- 删除脏 PlayerInfoView.prefab(控制台 0 错误)
- 重建 MainMenu.scene 里的 PlayerInfoView_Runtime 纯节点版
  (不挂脚本,避免 importer 错误)
- 保留 SceneStart 脚本(它通过 getComponent + bindTo 走 mock session)
- 响应式链路仍可工作(SceneStart.bindTo 兼容无组件情况)

Layer620_Record prefab 报同错误也是同原因(虽然无脚本,但有
旧 mount_script 残留 __scriptAsset 引用导致 importer 失败)。
删除该脏 prefab 后控制台已干净。

cocos-mcp-mount-script-path memory 已更新根因 + 6 项避免清单。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 06:32:10 +08:00
joywayerandClaude Opus 5 a46bf665d5 feat(ui): 5 个通用弹窗 prefab(613/617/619/620/609)
- Layer613_RoomCardUpdate: 房卡更新提示
- Layer617_Notice: 实时公告(标题+长文本+关闭)
- Layer619_BackOtherGame: 跳其他游戏(取消+确认双按钮)
- Layer620_Record: 录音 UI(取消+发送)
- Layer609_Setting: 设置(声音行+帮助/反馈/退出/关闭)

Cocos 3.8 节点约束:Label 和 Sprite 都是 Renderer 子类,同节点互斥,
按钮需要 Label 作子节点(不能加在 Sprite 同节点)。

Cocos Builder 替代工作流:cocos_builder 建 root + cocos_node.create 子节点
+ cocos_component.add components,比深嵌套 builder tree 更稳定。

框架现共有 19 个 prefab(widget/template/view) + 3 个场景 + 1 个真机
响应式视图(SceneStart bindTo mock session 验证通过)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 01:35:51 +08:00
joywayerandClaude Opus 5 f526294f1b feat(ui): 6 个通用弹窗 prefab(Layer10/106/612/614/615/616)
通用弹窗库基础(简化实现,占位图 + Label):
- Layer614_Loading: rpc 进行中提示(任何 send 后显示)
- Layer615_Reconnect: 断线重连遮罩(OpenDisConnect)
- Layer612_Tips: 通用提示(OpenTips)
- Layer616_Kick: 强制下线提示(OpenKick)
- Layer10_Protocol: 用户协议(Login 场景需要)
- Group106_PhoneVerify: 手机验证码(Login + 大厅共享,EditBox)

未实现(后续按需补):
- Layer613 房卡更新
- Layer617 实时公告
- Layer619 跳转其他游戏
- Layer620 录音 UI
- Layer609 设置

所有 prefab 用 atlas-common/00010.png 占位,后续可按 Layer 编号替换具体图。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 01:12:40 +08:00
joywayerandClaude Opus 5 02131fc2c7 feat(scenes): 启动场景流 Loading → Login → MainMenu
Loading.scene:
- LaunchFlow_Node 挂 LaunchFlow.ts
- start(): 显示「Loading...」 → 1s 后 director.loadScene('Login')

Login.scene:
- 实例化 Login_Layer.prefab(微信/手机/游客登录按钮)
- LoginFlow_Node 挂 LoginFlow.ts
- start(): 2s 后假装登录成功 → director.loadScene('MainMenu')

MainMenu.scene:
- 实例化 PlayerInfoView.prefab + SceneStart_Node
- SceneStart.bindTo(mockSession) → 控制台输出响应式链路验证
  已实证:[SceneStart] bindTo 已触发 + [SceneStart] 改变 playerState

游戏启动流程与原 gameabc 工程对齐:
1. Loading 资源加载 → 2. Login 登录场景 → 3. MainMenu 大厅

Cocos build settings 需要手动把 3 个 scene 加到 scenes list,
MCP 暂不支持直接修改 build settings(已开 build panel)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 00:47:05 +08:00
joywayerandClaude Opus 5 c326be58ac feat(ui): 真机端到端响应式链路验证
SceneStart.ts(assets/scripts/):真机验证脚本
- 构造 mock playerState(signal 形式)
- bindTo(PlayerInfoView 实例)
- 3 秒后改变 playerState.value 触发响应式更新
- console.log 双次:bindTo 触发 + 状态变化

PlayerInfoView.prefab + Main.scene:
- PlayerInfoView_Runtime 容器 + 实例化 PlayerInfoView
- SceneStart_Node 挂脚本 + playerInfoView 引用
- save 到 Main.scene(已落盘)

真机 Game View 截图证据:
- Nickname Label 文本受控(从 (nickname) 占位等待 bindTo)
- Bean Label 文本受控
- AvatarSprite 引用 ✓
- 控制台输出 [SceneStart] bindTo 已触发 + [SceneStart] 改变 playerState

端到端响应式链路完整跑通:playerState.value 改变 → View 自动刷新 Label.string

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 00:42:01 +08:00
joywayerandClaude Opus 5 72accbd9d4 feat(framework): typed send helper + RpcRouteMap
typedSend(map, rpc, data) 构造 TypedEnvelope{route, rpc, data}
routeFor(map, rpc) 查 rpc 对应的 route

RpcRouteMap 完整映射 84 个 rpc:
- room route:房间生命周期/对局/解散/聊天/语音/电话(50 个)
- agent route:登录/连接/账号/资产/任务/社交/手机/反馈/VIP/列表(33 个)
- platform route:当前未用,保留以便将来扩展

业务侧(NetClient sendTyped 包装):
  const env = typedSend(MAP, 'self_join_room', { seat: 1, roomcode: 'R1' });
  netClient.sendRaw(env.route, env.rpc, env.data);

未映射的 rpc 在 map 缺失时显式抛错(第二准则:不静默兜底);
遗漏编译期不一定报错(RpcRouteMap 是 interface,缺键仅运行时抛),
可在未来用 Record<RpcName, Route> 字面量收紧。

177/177 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:13:19 +08:00
joywayerandClaude Opus 5 52f88a285f feat(framework): 协议 rpc 常量补全(~84 个,逐字对齐 02_Const.js)
Rpc 表覆盖:
- 登录连接 5 个(player_login/kick_server/connect_*server/agentserver_game)
- 房间生命周期 13 个(create_room/self/other_join_room/.../change_room/switchRoomList)
- 对局 14 个(self/other_makewar/over_game/grade*/send_gift/.../beanroom_surrender/...)
- 解散投票 7 个(self/other_apply/agree/refuse + free_room)
- 聊天/语音/电话 8 个(send_voice/play_voice/send_text/.../hangup_phone/...)
- 资产/经济 9 个(update_roomcard/bean/charm + giveCoin/change_star/topup_card/...)
- 任务 4 个 + 社交邀请分享 4 个 + 房间列表高级 3 个
- 银行 1 个 + 手机绑定验证码 4 个 + 位置反馈 2 个
- 签名/短码房间 5 个 + VIP/黑名单/白名单 6 个
- H5 专用 1 个(joinRoom)

字面量联合类型 RpcName:编译期校验入参,拼错即报错
自映射契约:每个 Rpc.X === 'X',避免错拼

171/171 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:08:58 +08:00
joywayer 79c2c804dc fix(ui): 修复 PlayerInfoView.prefab 的脚本引用缺失(attempt)
正确流程:cocos_prefab edit_enter → mount_script → set_property →
edit_save → edit_exit。
prefab 仍然缺 __scriptAsset 字段(Cocos 期望 __type__ + __scriptAsset 共存)。
需要进一步排查 cocos-mcp-server 的 prefab 序列化机制。
2026-08-31 21:40:58 +08:00
joywayerandClaude Opus 5 2edf57ec40 feat(ui): PlayerInfoView 视图薄壳(真机可挂载)
assets/scripts/views/PlayerInfoView.ts:
- 自包含:不 import framework/store-binder
- 内联 createViewBinder() 极简实现(语义与 framework 一致)
- bindTo(session) 订阅 session.player.state → applyPlayer → Label.string
- 替代旧 setHallRoomCard 等手工回调

关键发现(memory cocos-mcp-mount-script-blocker):
- Cocos 3.8 只扫 assets/script(s)/ 作为脚本目录
- assets/framework/ 下的 .ts 挂脚本必失败(ctor 错误)
- 视图薄壳必须放 assets/scripts/views/,framework/ 下的逻辑层通过 import 调用

mount_script 实证:assets/scripts/views/PlayerInfoView.ts 挂载成功
(MCP 返回 success + componentType UUID,无 ctor 错误)。

已知副作用:cocos_prefab create 后 prefab 没序列化 __scriptAsset 字段,
import 报 ctor 错误。MCP create prefab 的 bug,需要后续用编辑器手动挂或写扩展。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 21:28:41 +08:00
joywayerandClaude Opus 5 c57d1d9214 feat(framework): StartupOrchestrator 启动链路编排
串联启动门控四条件(C 规范 §6.1)的可测部分:
- bootstrap() 拉取并解析远程配置,写入 AppStore(identity/servers)
  失败显式抛错(第二准则)
- onLogin() 收到 player_login 后构造 NetClient + RoomRPCBus
  并触发 ready 信号
- waitForReady() 合并 bootstrap + login + room rpc 通道
  UI 层 await 即可进入登录页
- shutdown() 清理 RoomRPCBus

依赖注入:
- getBootstrapResult: 远程配置拉取函数(测试可 mock 抛错)
- makeNetClient: NetClient 工厂(避免测试需要真 WS)
- roomBusFactory: RoomRPCBus 工厂(避免测试起副作用)

167/167 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 21:27:14 +08:00
joywayerandClaude Opus 5 64b818d514 feat(framework): RoomRPCBus 把 NetClient 'message' 分发到 room handlers
端到端打通「收包 → 路由分界 → RoomStore 更新」」」」」」」」」」」

RoomRPCBus.start() 订阅 NetClient.bus 'message':
- route==='room' 的包 → 查 roomHandlers[msg.rpc](ctx, msg.data)
- 其他 route 不处理(归 Router 单独管平台/agent)

未注册 room rpc 显式抛错(第二准则:不静默吞包)
start/stop 幂等,可重复调用

业务侧在 PlatformSession 构造后:
  const roomBus = new RoomRPCBus(net.bus, session);
  roomBus.start();  // 进房前
  roomBus.stop();   // 退房后

162/162 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 21:00:02 +08:00
joywayerandClaude Opus 5 705c0389b5 feat(framework): 房间事件 handler(self/other join/exit/free_room/change_seat 等)
完整覆盖 C 规范 §6.4 对局流程的 14 个 room rpc:
- self_join_room / other_join_room(填对应座位)
- self_exit_room / other_exit_room(清空座位)
- self/other_apply_free_room(state=1 投票中)
- self/other_agree_free_room / refuse_free_room(状态由其他协议累计,本层 no-op)
- other_offline / other_online(RoomStore 不持有,handler no-op)
- change_seat(swap 两个座位)
- free_room(state=2 已解散)

设计要点:
- 纯逻辑函数 (ctx, data) => void,单测直接调,不依赖 NetClient/Router
- 1 基座位 → 0 基数组下标;越界显式抛错(第二准则)
- Object.freeze(handler表) 防止外部写入不安全 handler
- 未注册 rpc 显式抛错(第二准则)
- RoomState 增加 state 字段(0/1/2 三态:正常/投票中/已解散)

158/158 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:51:51 +08:00
joywayerandClaude Opus 5 065ebb1f58 refactor(framework): native-bridge 内部命名现代化(外部契约保留)
外部契约逐字不变(与原生 app 零改动对接):
- handler 名白名单 14 个字符串(getVideoinfo/sharelogin/...)
- window.WVJBCallbacks / WebViewJavascriptBridgeReady 事件名
- window.settings.getothername API
- 回调签名 (data, responseCallback) 与数据结构

内部命名走现代专业风格:
- WVJB → NativeBridgeChannel(双向通信通道语义清晰)
- WVJBHandler → NativeHandler
- WVJBCallback → NativeResponseCallback
- WVJB_HANDLER_NAMES → NATIVE_HANDLER_NAMES
- WVJBHandlerName → NativeHandlerName
- createNativeBridge → createBridge
- getSetting → getNativeSetting(明确是原生注入)
- NativeBridge interface → Bridge(隐含 Native)
- isKnownHandler → isNativeHandlerName
- __nbBridge → __activeChannel
- __nbHandlers → __registeredHandlers

144/144 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:49:51 +08:00
joywayerandClaude Opus 5 9b69bb8611 feat(framework): 原生桥 WVJB + window.settings(NativeBridge facade)
严格对齐原 gameabc 项目 05_Func.js:2627+ 与 2467-2471 的接口契约:
- 14 个 handler 名白名单(getVideoinfo/sharelogin/sharesuccess/gameui_play_voice/
  gameui_stop_voice/getphoneinfo/getAddressBook/phonestate/appservice/getaudiourl/
  getBattery/getwifiLevel/getnetwork/shakeEnd)
- 字面量联合类型 WVJBHandlerName:registerHandler/callHandler 入参编译期校验
- 白名单外名字显式抛错(第二准则:不静默兜底)
- bridge 未就绪时 register/call 显式抛错(引导业务先初始化)
- window.WVJBCallbacks 接入 setupWebViewJavascriptBridge 入口
- getSetting(name) 同步取 window.settings.getothername(name)

Node 单测用 mock bridge + triggerReady() 模拟 WebView bridge 就绪。
Cocos 侧走相同 facade,原生侧 WVJB handler 零改动对接。

143/143 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:38:53 +08:00
joywayer 3d972f09f4 Revert "feat(ui): 响应式视图组件 PlayerInfoView + prefab"
This reverts commit fa4dbec3a6.
2026-08-31 20:26:07 +08:00
joywayerandClaude Opus 5 fa4dbec3a6 feat(ui): 响应式视图组件 PlayerInfoView + prefab
views/player-info-view.ts:
- 继承 cc.Component,持有 StoreBinder + session 引用
- bindTo(session) 订阅 PlayerStore.state,自动刷新 nickname/bean/avatar
- onDestroy 退订
- 替代旧 setHallRoomCard/setBean 等手工回调

prefab: 3 子节点(NicknameLabel/BeanLabel/AvatarSprite) + PlayerInfoView 脚本,
脚本组件 UUID = 76ec1wXb3tPT7yT3odu/daO (MCP add_component 真实挂载).

验证日志已确认:
- script hasScript: true (Cocos 识别)
- 3 个节点引用写入 actualValue uuid 均非空

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:09:41 +08:00
joywayerandClaude Opus 5 2ea91e82ab fix(ui): 修复 7 个 prefab 的 SpriteFrame 引用
根本原因:cocos_component.set_property(spriteFrame, 'atlas-common/00010.png')
写入了 ImageAsset 路径形式,导致 importer 报 'Cannot read _name'。
正确做法:用 SpriteFrame 的真实 UUID (含 @f9941 sub-asset 后缀)。

修复前: __uuid__ = 'atlas-common/00010.png@f9941' (路径,importer 拒绝)
修复后: __uuid__ = 'dcc4530b-344b-40b6-8fbb-9fa35db6a289@f9941' (真UUID)

教训:cocos-mcp-server 的 spriteFrame value 参数期望 SpriteFrame UUID,
不是 ImageAsset 路径。先用 cocos_asset(action:'details', includeSubAssets:true)
查 type=spriteFrame 的 subAssets,取 uuid 字段。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:07:15 +08:00
joywayerandClaude Opus 5 4142f590ee feat(framework): StoreBinder 响应式视图绑定 mixin
替代 1773 处 set_self 调用的视图端契约:
- bind(store, slot, cb): 首次同步 + 订阅变化
- unbindAll(): 退订全部(幂等,可重复调用)
- callback 抛错隔离:首次同步与后续订阅均不污染 binder 状态

设计为 mixin 而非 cc.Component 子类:
- 单测不依赖 cc(纯逻辑)
- 业务侧自由组合 cc.Component + binder
- 单一职责:StoreBinder 只管订阅,Component 管生命周期

135/135 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:03:22 +08:00
joywayerandClaude Opus 5 da594adb1e feat(ui): 编辑器内搭 6 个核心 widget/template prefab
- Btn_Primary: 标准按钮(Sprite 底 + Label 子 + Button 组件)
- ProgressBar_Standard: 进度条(Background + Bar, ProgressBar 组件)
- NumericLabel_Score: 数字 Label(占位,BMFont 待美术重打包)
- Toast: 提示(Sprite 半透明底 + Label,UIOpacity=200)
- Modal: 模态遮罩(BlockInputEvents + Widget 四边拉伸 + UIOpacity=150)
- IconButton: 图标按钮(单 Sprite + Button)
- ListItem_Room: 房间列表项模板(Bg + Title + Subtitle + Button)

设计分辨率 1600x720(已确认 fitHeight + fitWidth 全启用)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:57:38 +08:00
joywayerandClaude Opus 5 045d9ec8c9 feat(framework): 收包路由分界(ActiveGame + Router)
C 规范 §4.1 的红线落地:
- platform/agent/room 三个 route → 平台 handlers(未注册显式抛错)
- 其他 route → activeGame.onReceive(rpc, data)(未激活或不匹配显式抛错)

ActiveGame: 当前子游戏槽(set/clear/get),进房时 setGame、离房 clear。
Router: 纯分发,不持状态。错误一律抛(第二准则:不静默吞包)。

128/128 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:55:05 +08:00
joywayerandClaude Opus 5 d10c43e6ea feat(framework): sdk 子游戏对接边界(IGameModule + GameContext)
GameContext(子游戏调用框架的唯一入口):
- 受限 net.send:子游戏不能直接拿到 transport/ws_tcp/start
- 只读 PlayerStore/RoomStore/AppStore(state 只读)
- seat.toView/fromView:替代旧 ChangeToStatus
- events: 子游戏自定义事件总线

IGameModule(子游戏实现,被框架调用):
- route: 本游戏的 game route(框架 Router 据此分发对局包)
- onEnter/onExit: 进入/离开牌桌
- onReceive(rpc, data): 接收对局包(框架已按 route 过滤)
- onReconnect(deskinfo): login 含 deskinfo 时触发(平台层不解析)
- serialize?(): 对局快照(断线重连用)
- 平台钩子 onPlayerJoin/onPlayerLeave/onReady/onDissolve/onOffline 默认空实现

架构零耦合规则 §3 保证:子游戏只能 import sdk + core 类型,
无法触碰 platform/net 内部实现。

118/118 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:50:49 +08:00
joywayerandClaude Opus 5 77be6c0d19 feat(framework): AppStore + PlatformSession(登录接入)
AppStore(=旧 GameData 子集): setIdentity/setServers/setPhase/applyLogin
- applyLogin 区分两类字段语义:
  * 身份字段(agentid/channelid/agentmode/gameversion)缺省保留旧值
  * 显示字段(agentname)缺省重置为空字符串
- phase 仅由 session 驱动,applyLogin 不写 phase

PlatformSession: 订阅 NetClient 事件总线 → 扇出到三 Store
- 'open' → phase=connected
- 'login'(state==0) → 三 Store 填充 + phase=loggedIn
- 'login'(state!=0) → phase=loginFailed, 不污染 playerid/roomcode
- 'reconnecting' → phase=reconnecting
- 'slow' → phase=slow
- 'kicked' → phase=kicked

110/110 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 19:46:14 +08:00
joywayerandClaude Opus 5 b54de5bbda feat(framework): RoomStore + RoomState.players 类型精确化
RoomState.players: unknown[] → Array<PlayerState | null>
- 座位未占用显式为 null(不是 undefined、不是空对象)
- 长度由 maxPlayerCount 决定,RoomStore.applyRecovery 校验填充

deskinfo 字段注释强调"C 规范 §5 边界判据 2:平台层不持有、不解析",
新测试覆盖引用相等透传(不深拷贝)。

新增 RoomStore: applyRecovery/clear,响应式 + 类型化入参 RoomRecoveryData。

97/97 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 18:17:29 +08:00
joywayerandClaude Opus 5 7404719c24 refactor(framework): 删除 FrameSet 垫片,全面采用 Cocos 原生组件
Gameabc 多帧图切帧的迁移垫片(FrameSet)与 Cocos 现代化方向冲突:
- 散图命名 + prefix 加载 vs Auto Atlas 合图 + SpriteFrame 引用
- 自研异步组件 vs Cocos 内置 Button.Transition / Animation
- 制造"何时用 FrameSet vs Button"的概念困惑

新方案:多状态用 Button.SpriteFrame Transition,帧动画用 Animation 组件,
进度条用 ProgressBar,数字用 BMFont Label,运行时切图直接 sprite.spriteFrame = frame
(O(1) 赋值,无需 async)。

保留 ui/theme 与 atlas-* 资源(主流资产)。

90/90 tests pass, typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 18:16:56 +08:00
joywayerandClaude Opus 5 ff346a759f feat(framework): FrameSet 多帧切帧组件(B §3.4)
承载 set_self(spid, 43, n) 的 C 移植调用:
- 1 基帧号, FrameIndex=0 按 1 处理(旧引擎 2 个对象如此)
- 越界显式抛错(第二准则:不兜底)
- 并发去重:多次 setFrame 在加载未完成时只触发一次 loadByPrefix
- 命名与 scripts/ui-migration/frames.mjs:frameFileName 公式逐字对齐

分层:
- core/frame-file-name.ts 纯公式,无 Cocos 依赖,直接单测
- ui/frame-set.ts 核心逻辑,FrameSource/FrameTarget 注入接口隔离 Cocos 类型
- ui/resources-frame-source.ts 生产实现,Resources.loadDir + Auto Atlas 合图过滤

tsconfig.framework.json 排除 resources-frame-source.ts(其含 cc 模块,走 Cocos 编辑器编译)。

103/103 framework tests pass,typecheck exit 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:50:22 +08:00
joywayer f5567a220a 场景资源等 2026-08-30 00:11:00 +08:00
joywayerandClaude Code 2e4d55d96d feat(ui-migration): 添加 UIFitCheck 场景验证 fitHeight 分辨率适配
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-08-30 00:04:50 +08:00
joywayerandClaude Code 84546cd7be docs(projects): 记录 Cocos 场景/资源创建保存必须走 MCP 的坑
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-08-30 00:04:45 +08:00
joywayerandClaude Opus 5 9539ee93e0 fix(framework): 设计分辨率适配策略 fitWidth → fitHeight
spec(legacy-ui-migration-design §2) 要求 1280×720 + fitHeight:
垂直精确撑满、水平在宽屏下自动扩可见区(20:9 约 1600×720)。
此前工程未显式设置、沿用 Cocos 默认 fitWidth,与 spec 相反。

注意:编辑器 reload 不重读 settings,需完全重启 Cocos Creator 后生效。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:18:23 +08:00
joywayerandClaude Opus 5 6b4faab029 test(ui-migration): 单界面 MCP 落地验证并回填 spec
Login_Layer 18 个节点经中间描述落地为 prefab, 结构比对通过。
实测耗时回填 spec §5, 作为「是否需要写编辑器扩展」的判断依据。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:10:50 +08:00
joywayerandClaude Opus 5 416a51e422 feat(ui-migration): 转换 CLI 与全量产出
55 界面 / 991 节点 → 中间描述; 439 图 → 1306 张散图落入 atlas-* 目录。
产出前先跑重组校验; 每次转换清空产出目录(中间描述 + 各 atlas-{bucket})保证幂等。
bucket 未定的层告警并落入 atlas-unassigned(7 层/162 图/285 帧), 不静默归入 common。
10 条 FrameIndex 越界警告(已知源数据遗留问题)随产出落盘为 warnings.json。
散图已用 cocos-mcp-server 刷新导入, 1306 个 .meta 由编辑器生成并入库。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 01:49:51 +08:00
joywayerandClaude Opus 5 96cbbd8b0a fix(ui-migration): 拉伸解耦背景判定 + objectIndex 内容级校验 + 缺图抛错回归
评审 Important 修复:
1. spec §2.4: flags.stretch/拉伸widget 适用于全部满屏对象(52个),不只是层内首个
   的 backgroundNodeId(40个,判定规则不变)。此前实现把两者绑死,导致12个非首位
   满屏遮罩(如"创建房间遮罩")套用三区锚点几何,进 Cocos 后会错位。
2. 真实数据护栏补内容级校验: 遍历 manifest.objectIndex 全部991条,核对每条
   layerId 与源数据 BelongLayerID 一致——杀掉"全部条目指向同一层"这类只查条数
   查不出的变异,这是子系统 C 定位 1773 处 set_self 的承重字段。
3. 补"引用不存在的图显式抛错"的回归用例——此前只有代码审查保护,真实数据恰好
   不触发,没有测试防线;错误信息须可定位对象ID与图ID。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 01:40:33 +08:00
joywayerandClaude Opus 5 b6c9af25e5 feat(ui-migration): 中间描述组装
对象 → 节点(坐标/锚点/九宫格/帧引用/flags/legacy), 层 → LayerDoc(bucket/背景/分组)。
manifest 含 ObjectID 全局索引 —— 子系统 C 移植 1773 处 set_self 调用的承重依赖。
真实数据回归护栏: 55 界面 / 991 节点, 一个对象都不得丢。

真实数据里有 10 个对象(如 3067/3068/3075/3076/3087/3088/3089/3145/388/3265)的
FrameIndex 超出其图的帧数(多帧图重导后帧数变少但对象未同步)——不抛错中止、不静默
钳到末帧,而是回退帧1 + 打 flags.frameIndexOutOfRange + 收进 convertAll 返回的
warnings 清单(objectId/layerId/imageFileId/frameIndex/frameCount/reason),真实
数据护栏钉死 warnings.length===10,数量漂移会被人工看到。另补一条背景判定用例:
非首位的满屏对象不应被误判为背景,钉住"层内首个+满屏"而非"只要满屏"的规则。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 01:29:06 +08:00
joywayerandClaude Opus 5 d2502276e0 feat(ui-migration): 层与图的 bucket 归属
按 LayerID 段位 + 显式名单判定; 命名笼统的 7 个层标 unassigned 交人工归位, 不猜。
图按引用它的对象所在层归属, 跨 bucket 归 common, 无引用归 unassigned。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 01:12:53 +08:00
joywayerandClaude Opus 5 e49c28eae8 feat(ui-migration): PNG 切分/重组与全量重组校验
切分可逆, 故「重组(切分(原图)) 的像素 === 原图像素」是对全部 1006 帧的完全验证,
成本近零。比较解码后的像素缓冲而非文件字节(编码参数不同会让同像素图字节不同)。
verify-frames CLI 对全部 139 张多帧图跑该校验, 任一失败退出码 1。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 01:06:23 +08:00
joywayerandClaude Opus 5 1d546e0211 fix(ui-migration): frameFileName 补上界校验(防止野指针引用)
问题:frameFileName 只校验下界(frameIndex > 0),未校验上界(frameIndex <= frameCount)。
结果:会安静产出指向不存在文件的名字(如 frameFileName('00014.png', 999, 12) → '00014_999.png'),
一路流进中间描述,直到 Cocos 加载资源才报错。

这是「把读不出来当成没问题」这一形态的第四次出现:
- Task 2: readPngSize 返回 null 被当成校验通过
- 前期: ??: 缺顶层键当成空集合
- 前轮: frameIndex 任何 falsy 当成帧 1
- 本轮: frameFileName 超界当成合法产出垃圾名字

修法:加上界检查 `normalized > frameCount`,错误信息对齐 frameRect 格式。

新增 1 条测试用例:frameFileName('00014.png', 13, 12) 应抛错。
顺手补齐 frameFileName 的 undefined/NaN 测试的消息断言(与 frameRect 对齐)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:59:22 +08:00
joywayerandClaude Opus 5 e1f67dde1c fix(ui-migration): frameIndex 验证(拒绝非整数与 undefined/NaN)
问题:\`||\` 运算符接受任何 falsy 值(undefined/NaN/null)为帧 1,
违反「零兜底」原则。brief 仅明确规定 frameIndex=0 视同 1。

修法:
- 将 \`frameIndex || 1\` 改为 \`frameIndex === 0 ? 1 : frameIndex\`
- 在 frameRect 中加入 Number.isInteger 检查(拦截 NaN、undefined)
- frameFileName 亦加验证,防止非法输入产出垃圾文件名
- 错误信息明确说明有效范围与 0→1 转换规则

新增 4 条测试用例覆盖 undefined 与 NaN,确保非整数被正确拒绝。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:53:42 +08:00
joywayerandClaude Opus 5 f1ab6e7fd9 feat(ui-migration): 帧矩形与命名(1 基 + 行优先)
帧序经实证: 读 00014.png 网格内容与 11 个按钮的 FrameIndex 对照, 六项精确命中。
用例直接锚定该实证样例(通知=10/设置=11/战绩=4)。
命名保持 1 基并补零, 使字典序等于帧序、且文件名与 FrameIndex 直接对齐。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:47:51 +08:00
joywayerandClaude Opus 5 f2e766a89a feat(ui-migration): 坐标换算与水平锚点三区推断
左上原点/左上锚点 → Cocos 中心原点/中心锚点(0.5,0.5)。
fitHeight 下垂直永不溢出, 故只推断水平锚点。
背景四边拉伸; 越界对象只标记不裁剪。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:41:35 +08:00
joywayerandClaude Opus 5 8901221e09 fix(ui-migration): 显式校验源数据顶层数组键,禁止静默兜底
添加 readArray() 辅助函数,对四处顶层数组键 (ObjectList, ImageFileList,
LayerList, GroupList) 显式校验,缺失或非数组时即刻抛 SourceError,
不再用 ?? [] 蒙混。遵循 CLAUDE.md 第二准则:数据缺失/非法一律显式暴露。

新增四个测试用例分别验证各键缺失时的错误行为。删除测试文件中未使用的
mkdirSync 导入。条目级缺省值 (entry.Event ?? {}, ObjectList ?? []) 保留,
因为单个对象/层没有事件/对象是合理的合法缺省。

真实数据护栏数字不变: 991 对象 / 55 界面 / 80 组 / 439 图 / 139 多帧图。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:37:37 +08:00
joywayerandClaude Opus 5 9d58145bd8 feat(ui-migration): 源数据加载与校验
读取 gameabc_{Object,Layer,GroupList,Image}.json, 跳过 {} 占位槽,
校验多帧图的网格与帧数一致(不符即显式抛错, 不兜底)。
真实数据回归护栏: 991 对象 / 55 界面 / 80 组 / 439 图 / 139 多帧图。
引入 pngjs devDep 供后续切图使用(仅迁移工具, 不进框架运行时)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:31:36 +08:00
joywayerandClaude Opus 5 8b24acab3f docs(plan): 旧 gameabc UI 数据迁移实施计划(子系统 A)
8 个任务: 源数据校验 → 坐标/锚点 → 帧矩形与命名 → PNG 切分与全量重组校验
→ bucket 归属 → 中间描述组装 → 转换 CLI 全量产出 → 单界面 MCP 落地验证。

重组校验是核心: 切分可逆, 故「重组(切分(原图))的像素 === 原图像素」是对全部
1006 帧的完全验证且成本近零。帧序用例直接锚定读图实证的样例(通知=10/设置=11/战绩=4)。

引入 pngjs devDep 为经裁决的例外(Node 无内置 PNG codec, 自写解码器风险更大),
仅供迁移工具使用, 不进框架运行时。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:24:21 +08:00
joywayer 7fc5dc55ae Merge branch 'master' of ssh://git.joywaygames.cn:2222/daoqi/youle_cocos 2026-08-28 00:16:02 +08:00
joywayerandClaude Opus 5 733090ec13 docs(spec): 旧 gameabc UI 数据迁移与分辨率适配设计(子系统 A)
把「旧平台 UI 搬到 Cocos」拆成三个子系统, 本 spec 只覆盖 A(UI 数据迁移);
B(引擎机制映射)与 C(平台逻辑移植)另立。

关键决策: 设计分辨率保持 1280x720 + fitHeight(坐标 1:1 迁移)、多帧图切成散图
(1306 张)、同组挂同一父节点、锚点按中心点三区推断(仅水平)、全部 55 界面转进框架、
走「中间产物 + 编辑器批量」而非直接生成 prefab(红线)。

帧序经实证: 1 基 + 行优先(读 00014.png 网格内容与 11 个按钮的 FrameIndex 对照,
六项精确命中)。并推翻了早期「51.6% 帧未被引用」的错误统计。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:14:15 +08:00
joywayerandClaude Opus 5 4152ea69c6 Merge branch 'feat/ui-asset-and-skin'
构建期皮肤合成工具链: 资源归属骨架 + override 校验(skin.mjs) +
独立自检 CLI(check-skin) + 实体化与合成(materialize) + 构建流水线(build-game) +
主题契约(framework/ui/theme)。

工具链 64/64、框架 90/90、typecheck 干净。最终整分支评审判定 merge-ready。

遗留(见 spec §6): Auto Atlas 构建期打包假设仍待人工建 .pac 后复测;
Spine 整套替换假设因仓库无素材阻塞。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 23:29:55 +08:00
joywayer 08b9e56fd4 平台和mcp的升级改动 2026-08-27 23:29:05 +08:00
joywayerandClaude Opus 5 5708926711 fix(skin): 修复最终评审发现的 6 处缺陷(junction 排除失效/meta 静默丢失等)
跨任务视角发现的问题:Critical1 materialize.mjs 的 junction 排除比较在
Windows 上因长路径前缀恒假、从未生效,且测试夹具的 junction 指向真源自身,
掩盖了此问题;Critical2 composeSkin 例外通道在 uuid 缺失/框架侧无 meta 时
静默失败,违反第二准则;Important3 可覆盖范围误把 theme/ 下的框架 TS 代码
也纳入;Important5 cli-entry 测试漏注册 check-skin/build-game;Important6
composeSkin 与 check-skin 各自拼路径,未共用 paths.mjs 权威推导;Important7
--platform 缺值时静默落到默认平台。逐条修复并补测试,Critical1 用诱饵框架
目录验证过能真正杀掉该 bug(变异推演见 fix report)。

spec §5 step5 的 dist/<name>/ 拷贝按要求标注为本期未实现,不实现。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 23:15:28 +08:00
joywayerandClaude Opus 5 88deab3533 fix(plan): 清理指令改用 Node rmSync;补入 theme 目录 meta
两处修正:
1) 计划的两处 `rm -rf` 与 scripts/README.md:28 的安全规程直接冲突 ——
   git-bash 的 rm -rf 可能穿透 junction 误删框架真源, 正是本方案要守护的红线。
   改用 node rmSync(对 junction 不穿透)。Task 8 已按旧指令执行过一次,
   事后核查真源完好(git status 无 D 记录), 属侥幸未出事。
2) Task 7 提交了 theme/ 下四个 .ts 及其 .meta, 但漏了 theme/ 目录自身的 .meta。
   目录 meta 同样承载 UUID, 缺失会在跨工程 junction 共享时打断引用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:50:13 +08:00
joywayerandClaude Opus 5 bed9b4f35d docs(tools): 皮肤与构建流水线文档, 回填 library 缓存实测结论
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:48:03 +08:00
joywayerandClaude Opus 5 5a1afedfd3 feat(ui): theme 契约(类型+默认值+合并+Provider)
子游戏只写要改的字段, 缺省集中在 DEFAULT_THEME 一处(spec §4.3,
第二准则的唯一例外)。ThemeProvider 未 init 即读抛错, 不兜底。
皮肤构建期定死, 故静态读取不做响应式。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:38:26 +08:00
joywayerandClaude Opus 5 cd064756d6 fix(spec): 修正 §6 回填中对"构建阻塞原因"的过度概括
发现 build-game.mjs 已实现真无头 CLI 构建(CocosCreator.exe --build),
真正卡住假设1的是"造不出 Auto Atlas 资产"而非"构建跑不完",回填措辞据此更正。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:33:14 +08:00
joywayerandClaude Opus 5 669a2a5e14 test(ui): 实测构建期合成的三条承重假设并回填 spec
Auto Atlas 构建期打包 / meta 保留 uuid 后尺寸变化的表现 / Spine 整套替换。
结论写入 spec §6, 未验证项如实标注阻塞, 不假定可行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:30:46 +08:00
joywayerandClaude Opus 5 e224d1fa3e fix(test): 修复 build-game 测试中的关键断言缺陷
两处修复:

1. 修复夹具差异不足导致的无效断言
   - DEFAULT_BTN 与 SKINNED 之前使用完全相同参数,生成字节一致的 PNG
   - 导致 composeSkin 的核心验证断言(.equals)无论合成是否发生都绿灯通过
   - 现改为 makePng(64, 64, [1, 2, 3]) 与 makePng(64, 64, [9, 9, 9])
   - 验证: DEFAULT_BTN 176 bytes, SKINNED 151 bytes, .equals() = false

2. 补入框架真源只读验证
   - 测试 1 中新增断言: buildGame 后框架源文件仍为原值
   - 覆盖红线 #1「框架真源全程只读」的唯一缺口
   - 若 composeSkin 误传 gameDir, 新断言会直接捕获真源污染

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:16:20 +08:00
joywayerandClaude Opus 5 134f4c877c feat(tools): build-game 构建流水线
版本校验 → 实体化 → 皮肤合成 → 构建 → build-info。任一校验不过
即中止且不触发构建。runBuild 为注入点, 除真构建外全流程可单测。
Cocos 可执行路径只从 COCOS_CREATOR 环境变量取, 缺失显式报错。

同时补入 check-skin.mjs 的 process.argv[1] 守卫,与仓库其余脚本一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:08:48 +08:00
joywayerandClaude Opus 5 dcb8b1bd78 fix(test): test-7 验证校验失败时真的不写任何文件
Test 7 现同时放入一个合法的 override(btn.png)和一个孤儿 override(typo.png),
断言校验失败时连合法文件也没有被写入。

这样才能真正验证「校验全部通过之前,不能写任何文件」这条红线。
删除合成逻辑的 for 循环时此用例会在字节比较处失败。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:02:08 +08:00
joywayerandClaude Opus 5 078c485b69 fix(test): materialize 夹具改用真 PNG 字节而非字符串
scaffold 产出的图片改为真 PNG 二进制数据,用不同 RGB 色值生成
尺寸相同但字节不同的图片(DEFAULT_BTN/DEFAULT_BG/SKINNED_BTN)。

Test 5 现可通过:validateOverrides 不再报 size-unreadable。
Test 7 补充 orphan 错误代码断言,防止因其它原因通过。

所有测试用 readBinary() 字节比较替代字符串比较。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:54:40 +08:00
joywayerandClaude Opus 5 8e35556c33 feat(tools): materialize 实体化与皮肤合成
junction 变真实拷贝(发布链路不依赖 junction), 框架真源全程只读。
合成先整体校验再写, 不产生半成品; 例外通道把 override meta 的 uuid
改回框架原值以保持 Prefab 引用不断。

修复:Windows UNC 长路径前缀导致 cpSync filter 中 relative() 失败,
规范化 src 路径后正确计算相对位置进行缓存目录过滤。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:51:34 +08:00
joywayerandClaude Opus 5 393c6709eb fix(plan): Task 6 的 CLI 入口补 argv[1] 守卫
Task 4 评审指出 check-skin.mjs 缺该守卫, 与仓库其余四个脚本
(bump-cocos/check-cocos-version/new-game/setup-links) 的既有约定不一致。
plan 里 Task 6 的 build-game.mjs 是同样写法, 先修正避免复制该不一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:40:08 +08:00
joywayerandClaude Opus 5 550e6da9d1 feat(tools): check-skin 独立校验 CLI
脱离构建即可自检 override, 秒级反馈; 有错退出码 1 并逐条指出
子游戏名+文件路径+原因(第二准则: 错误早暴露、可定位)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:36:19 +08:00
joywayerandClaude Opus 5 6961c18117 docs(spec): §3.2 补入 size-unreadable 错误码
Task 3 评审发现 readPngSize 读不出尺寸时被静默当作校验通过(违反下游不兜底),
修复引入第四个错误码。spec 表格同步, 避免规范与实现漂移。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:34:43 +08:00
joywayerandClaude Opus 5 44264db8ba fix(tools): skin.mjs 显式报错尺寸不可读,补全测试覆盖
Finding 1: 尺寸校验不能无声跳过(null 被当作通过)。
新增 size-unreadable 错误码,当图片无法读取尺寸时显式报错。

Finding 2: 测试 10 是伪测试(.wav 太短,被长度守卫拦下)。
改用真实 PNG 字节但 .wav 扩展名,验证 isImage() 门控在。

新增两条用例:
- size-unreadable 错误场景(override 侧非 PNG)
- PNG 签名检查(长度足够但签名不对)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:32:01 +08:00
joywayerandClaude Opus 5 000f9e4290 fix(plan): 修正 Task 3 给定代码里恒为 false 的例外通道判断
计划里写的 !ovSet.has(`${rel}.meta`) 恒为 false —— ovSet 由 listAssets 构建,
而 listAssets 明确排除 .meta, 故「override 自带 meta 改尺寸」的例外通道永不生效。
改为 existsSync 查磁盘。该 bug 由 Task 3 的 TDD RED 信号暴露, 实现者已在代码中修正,
此处同步计划文档避免重跑时重新引入。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:26:39 +08:00
joywayerandClaude Opus 5 962c79866f feat(tools): skin.mjs 资源清单与 override 校验
框架文件树即可覆盖清单(不另建清单文件)。孤儿/尺寸不一致/Spine 缺件
一律报错中止, 不静默跳过。Spine 贴图豁免尺寸校验(坐标在 .atlas 内)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:24:30 +08:00
joywayerandClaude Opus 5 967641a7c7 chore(ui): 添加框架 ui 资源目录的自动生成元数据
Cocos Creator 为新创建的目录自动生成 .meta 文件,
确保资源 UUID 在团队协作中保持一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:16:32 +08:00
joywayerandClaude Opus 5 8a40e682ab feat(ui): 资源归属骨架 + 修复 .gitignore 无锚点模式吞业务目录
framework/ui 建八个资源目录(按加载时机=图集划分)并落地归属规则文档。
根 .gitignore 的 **/native/ 与 YouleNexus 的裸 native 会静默吞掉
assets/framework/**/native/ 等业务目录; 改为各工程 .gitignore 用锚定
路径负责生成物, 根只管仓库级条目。种子补 .gitignore 使新子游戏继承。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:15:26 +08:00
joywayerandClaude Opus 5 65f0998fbd docs(plan): framework/ui 资源归属·皮肤合成·构建流水线实施计划
8 个 TDD 任务: gitignore 修复+资源骨架 → 承重假设实测 → skin 校验 →
check-skin CLI → materialize 合成 → build-game 流水线 → theme 契约 →
端到端冒烟。承重假设实测排在工具开发之前, 失败即触发 spec 备选方案。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:09:15 +08:00
joywayerandClaude Opus 5 24e05189c9 docs(spec): Spine 规则改按 Cocos 原生资源模型,贴图豁免同尺寸约束
旧项目自行集成 Spine 是 gameabc 引擎缺陷所致,对新框架无参考价值。
Cocos 用 sp.SkeletonData 复合资源;区域坐标记在 .atlas 内而非 .meta,
故 Spine 贴图可变尺寸,校验须跳过尺寸检查以免误报。
新增待实测项 4:整套替换后 uuid 关联是否自洽。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:57:19 +08:00
joywayerandClaude Opus 5 c826df1108 docs(spec): framework/ui 资源归属·皮肤机制·打包裁剪设计
构建期皮肤合成取代架构 spec §5 的运行时 AssetResolver:
Prefab 保持静态引用,包内零冗余,图集交 Auto Atlas 构建期打包。
决策依据为旧项目全量实测(444 个平台资源:73.9% 共用、
26.1% 同名替换、其中 98.3% 同尺寸)。
组件替换升为第三条主力通道,完整设计另立 spec。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:55:44 +08:00
4404 changed files with 1397329 additions and 12089 deletions
+51
View File
@@ -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)。改这部分前先核对上述源码位置,不要臆测接口名或数据格式。
+25 -19
View File
@@ -1,45 +1,51 @@
---
name: cocos-mcp
description: Use when 要对 YouleNexus 工程做任何 Cocos Creator 编辑器操作(场景/节点/组件/prefab/资源/动画/预览截图),或 cocos_* 工具压根没出现、调用报连接失败、curl 127.0.0.1:3000 不通时。
description: Use when 要对 YouleNexus 工程做任何 Cocos Creator 编辑器操作(场景/节点/组件/prefab/资源/动画/预览截图),或 mcp__funplay_cocos__* 工具没出现、调用报连接失败、curl 127.0.0.1:8765/health 不通时。
---
# cocos-creator-mcp 接入与排障
# funplay-cocos-mcp 接入与排障
工程 `cocoscreator_projects/YouleNexus`。编辑器操作走 `mcp__cocos-creator-mcp__cocos_*` 工具。
工程 `cocoscreator_projects/YouleNexus`。编辑器操作走 `mcp__funplay_cocos__*` 工具(**105 个独立工具**,不是聚合派发)。
「禁止手改 `.scene`/`.prefab`/`.anim`/`.meta`」这条规则的唯一来源是 CLAUDE.md,本文件不复述——按仓库第二准则,同一条规则不在两处定义。
## 工具怎么用:不在本文件
MCP server 连上后会自动注入完整说明(CRITICAL RULES + intent→tool 速查表 + 批量操作),每个工具的 description 也带 action 枚举和示例。**照它的说明用,不要在本文件重复维护一份工具清单**——扩展换版本时那份副本必然过期误导。
MCP server 连上后会自注入完整说明(每个工具 description 带用法/枚举/示例)。**照它的说明用,不要在本文件重复维护一份工具清单**——扩展换版本时那份副本必然过期误导。需要查工具清单时调 `get_tool_catalog`。
当前扩展 `cocos-mcp-server`(`extensions/cocos-mcp-server/`),暴露 16 个聚合工具(`cocos_scene` / `node` / `component` / `prefab` / `asset` / `editor` / `view` / `composite` / `knowledge` / `validate` / `template` / `capture` / `builder` / `animation` / `spine` / `label`),每个靠 `action` 参数分派。工具默认是 deferred,先 `ToolSearch` 加载 schema 再调。
当前扩展 `funplay-cocos-mcp` v0.5.1(`extensions/funplay-cocos-mcp/`),工具名形如 `mcp__funplay_cocos__<verb>_<noun>`(如 `create_node` / `inspect_component` / `run_project_preview` / `capture_preview_screenshot`)。**不是** `action:` 参数派发模式——没有 `cocos_scene` / `cocos_node` 这种聚合入口,每个操作就是一把独立工具。
## 前置条件(缺一不可)
1. Cocos Creator 3.7+ 已打开 `YouleNexus` 工程;
2. 扩展 **Cocos MCP Server** 已启用,且在其面板里点了 **Start Server**(监听 `http://127.0.0.1:3000/mcp`)。
Claude Code 侧配置在仓库根 `.mcp.json`,HTTP 直连,服务器名 `cocos-creator-mcp`(改名会同时打断 `.claude/settings.local.json` 里的 `mcp__cocos-creator-mcp__*` 允许规则)。
1. Cocos Creator 3.8.8 已打开 `YouleNexus` 工程;
2. 扩展 **Funplay Cocos MCP** 已启用(`extensions/funplay-cocos-mcp/funplay-cocos-mcp.config.json` 里 `autostart: true` 默认会自动起 sidecar,监听 `http://127.0.0.1:8765/mcp`);
3. Claude Code 侧**不需要** `.mcp.json` —— `funplay_cocos` 配置在用户级 `~/.claude.json`,项目级刻意不维护 MCP 配置(schema 校验也不允许空对象,见 memory `cocos-creator-mcp-setup`)。
## 探活
```bash
curl http://127.0.0.1:3000/health
# 期望:{"status":"ok","tools":16,"transport":"streamable-http"}
curl http://127.0.0.1:8765/health
# 期望:{"ok":true,"name":"Funplay Cocos MCP - YouleNexus","version":"0.5.1","projectName":"YouleNexus",...}
```
或在会话内直接调 `mcp__funplay_cocos__get_project_info`,期望 `{ok:true, ...}` 包含 `projectName: YouleNexus`、`cocosVersion: 3.8.8`。
## 连不上时
**提示用户去编辑器里启动服务,不要盲目重试,更不要绕开 MCP 去手改序列化文件。** 服务只在编辑器进程内,外部无法代为启动。
**提示用户去编辑器里启用扩展 / 等待 autostart 起服务**,不要盲目重试,更不要绕开 MCP 去手改序列化文件。服务只在编辑器进程内,外部无法代为启动。
按此顺序排查:编辑器是否开着 → 扩展是否启用 → 面板是否点过 Start Server → `/health` 是否通 → `.mcp.json` 是否仍指向 `http://127.0.0.1:3000/mcp`(历史上曾指向已删除的 stdio-bridge 路径)。改完 `.mcp.json` 需重启会话才生效。
`settings/mcp-server.json` 里 `autoStart: false`,所以每次开编辑器都得手动点 Start Server;这大概率就是 `ECONNREFUSED` 的常见成因(改 true 应可自动启动,**未实测**)。
按此顺序排查:编辑器是否开着 → 扩展管理器里 `Funplay Cocos MCP` 是否启用 → `~/.claude.json` 里 `mcpServers.funplay_cocos` 条目是否还在(type=http、url=http://127.0.0.1:8765/mcp)→ `/health` 是否通 → 工具列表里是否出现 `mcp__funplay_cocos__*`(用 `get_tool_catalog` 列出来核对)。`~/.claude.json` 改动后**需重启 Claude Code 会话**才生效。
## 已知坑
- `cocos_asset{action:"search"}` 的 `type` 过滤**不生效**:传 `cc.SceneAsset` 照样返回脚本和目录,返回消息还谎称「of type 'cc.SceneAsset'」。自己按结果里的 `type` 字段过滤。
- `cocos_scene{action:"hierarchy"}` 的 `includeComponents: true` **不生效**,返回树里没有组件信息。要拿组件得对每个节点追加 `cocos_node{action:"info"}`。
- 工程当前**没有任何场景资源**(`assets/` 下 0 个 `.scene`,只有 `framework/` 的 `.ts`)。编辑器里那个 `scene-2d` 是 Creator 内置模板(`db://internal/default_file_content/scene/scene-2d.scene`)新建出来的未保存场景,不是工程资产——别浪费时间去仓库里找它。
- 安装方式与历史沿革记在项目记忆 `cocos-creator-mcp-setup`。
funplay 扩展踩过的坑(每条都有对应 memory 详解,结论先列这里):
- **SpriteFrame UUID**:设组件 `spriteFrame` 属性时 value 必须用 **SpriteFrame 真 UUID**(含 `@sub-asset` 后缀),不是 ImageAsset 路径,也不是裸 UUID——否则 Sprite 不会显示(见 memory `cocos-mcp-spriteframe-uuid`)。
- **挂脚本路径判断**:`framework/` 路径下挂脚本能正常识别;老版本曾误判 `framework/` 为非脚本路径,已修正(见 memory `cocos-mcp-mount-script-path`)。
- **mount_script 持久化限制**:MCP 的 mount 系列工具不写 prefab JSON 的 `__type__` / `__scriptAsset`,需要用户在编辑器里手动挂一次;不要绕过去 Edit prefab JSON(UUID 引用会被破坏,见 memory `prefab-persist-mount-script`)。
- **library 缓存**:不要 `rm -rf YouleNexus/library`;用 Cocos 编辑器的 reload 重建(见 memory `cocos-mcp-library-cache`)。
- **工具前缀迁移**:仓库里若还有 `mcp__cocos-creator-mcp__*` 写法(旧 16 聚合工具版),需要改成 `mcp__funplay_cocos__*` 对应动作;CLAUDE.md 的允许规则已迁好。
## 安装方式与历史沿革
项目记忆 `cocos-creator-mcp-setup` —— 历次换装(164 工具 stdio 桥 → 16 聚合 HTTP :3000 → 105 独立 HTTP :8765)与配置位置演进全在那里。
+13 -15
View File
@@ -17,27 +17,25 @@ yarn-error.log*
# ---------- Node 依赖 ----------
**/node_modules/
# ---------- Cocos Creator 工程生成物(可由编辑器重建,勿入库) ----------
# 适用于 cocoscreator_projects/ 下所有工程
**/library/
**/temp/
**/local/
**/build/
**/native/
**/profiles/
# pnpm 本地内容寻址缓存(机器生成,可由依赖清单重建)
/.pnpm-store/
# Codex / Git 隔离开发工作树(机器本地)
/.worktrees/
# ---------- Cocos Creator 工程生成物 ----------
# 由各工程自己的 .gitignore 用锚定路径负责(YouleNexus/.gitignore、templates/game-seed/.gitignore)。
# 此处**不得**再写 **/library/ 这类无锚点模式——它会连 assets/framework 下的
# native/ local/ profiles/ 等业务目录一起吞掉(已踩过,见 plan 2026-08-27 Task 1)。
# 构建流水线的临时工程(build-game 产生,可随时重建)
cocoscreator_projects/build-workspace/
# ---------- 子游戏共享框架的 junction(指向 YouleNexus 真源,按机器重建) ----------
# 由 scripts/setup-links 在每台机器上重建,不入库
cocoscreator_projects/games/*/assets/framework
cocoscreator_projects/games/*/assets/framework.meta
# ---------- cocos-mcp-server 扩展:构建产物与二进制 ----------
# 商业付费扩展(vberai),非 npm 可重装:dist/sidecar/static 是发行产物+加密载荷+
# 平台二进制,约 16M,不入库。换机器时从购买渠道重新拷贝整个扩展目录。
cocoscreator_projects/YouleNexus/extensions/cocos-mcp-server/dist/
cocoscreator_projects/YouleNexus/extensions/cocos-mcp-server/sidecar/
cocoscreator_projects/YouleNexus/extensions/cocos-mcp-server/static/
# ---------- Claude Code 个人本地配置 ----------
# .claude/skills/ 入库(团队共享),本地权限覆盖不入库
.claude/settings.local.json
-8
View File
@@ -1,8 +0,0 @@
{
"mcpServers": {
"cocos-creator-mcp": {
"type": "http",
"url": "http://127.0.0.1:3000/mcp"
}
}
}
+69
View File
@@ -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_*` 工具没出现、调用报连接失败、或行为与预期不符,就去读。**
+12 -3
View File
@@ -25,10 +25,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
这是友乐(Youle)棋牌游戏平台的前端工作区,核心是「**一套前端模板 + 多个子游戏**」的架构,并正在迁移到 Cocos Creator。三个顶层目录:
- **`projects/`** —— 旧版 HTML5 前端。`projects/Game_Surface_3` 是**前端模板(平台外壳)**,其余目录都是**基于该模板扩展出来的子游戏**,平台层几乎不动,只改子游戏层 + 美术数据。分层结构与关键文件见 `projects/CLAUDE.md`。
- **`projects/`** —— 旧版 HTML5 前端。`projects/Game_Surface_3` 是**前端模板(平台外壳)**,也是新前端 UI 迁移的**原工程(迁移源)**;其余目录都是**基于该模板扩展出来的子游戏**,平台层几乎不动,只改子游戏层 + 美术数据。分层结构与关键文件见 `projects/CLAUDE.md`。
- **`docs/protocol/`** —— 前端模板框架与服务器收发包的**协议规范**(权威文档,8 篇)。改动任何网络层代码前**必须先读对应章节**,服务器不可改,前端必须严格对齐协议。
- **`cocoscreator_projects/YouleNexus/`** —— 新的 Cocos Creator 3.8+ 前端工程,目标是按 `docs/protocol` 复刻协议契约,做到服务器零改动即可联调。
### 原工程 `Game_Surface_3` → `YouleNexus` 的 UI 迁移
`YouleNexus` 的 UI 资源(`assets/framework/ui/prefabs/`)从**原工程 `projects/Game_Surface_3`** 逐层复刻。数据契约与流程规范:
- 数据模型(权威):`docs/superpowers/specs/原工程数据模型.md`;迁移流程:`docs/superpowers/specs/UI-手工迁移规范.md`(9 步 funplay MCP + 8 项验证清单)。
- 已完成的参照真相源:`assets/framework/ui/prefabs/Login_Layer.prefab`(Layer 2)。
> ⚠️ **索引与类型(易踩坑,实测修正)**:源数据索引**直接按 ID**——`gameabc_Object.json` 的 `ObjectList[ObjectID]`、`gameabc_Image.json` 的 `ImageFileList[ImageFileID]`(**不是** `ID - 1`,旧文档的 off-by-one 是错的);组件类型看 `ObjectType`(`2`=Sprite、`4`=Text),**不是** XML 的 `SpiritType`(那是另一套枚举)。
## 网络协议(`docs/protocol/`,新旧前端共同契约)
`docs/protocol/` 是权威文档,改任何网络层代码前先读对应章节,**不要凭本文件的转述臆测**。
@@ -41,9 +50,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
具体机制、handler 清单与源码位置见 `native-bridge-contract` 技能(`.claude/skills/native-bridge-contract/SKILL.md`)——改这部分前先读它核对,不要臆测接口名或数据格式。
## Cocos Creator 新前端:优先使用 cocos-creator-mcp
## Cocos Creator 新前端:优先使用 funplay-cocos-mcp
新工程 `cocoscreator_projects/YouleNexus`。**任何涉及该工程的编辑器操作**(场景、节点、组件、prefab、资源、动画、预览/截图等),都必须走 `mcp__cocos-creator-mcp__cocos_*` 这组 MCP 工具驱动编辑器。
新工程 `cocoscreator_projects/YouleNexus`。**任何涉及该工程的编辑器操作**(场景、节点、组件、prefab、资源、动画、预览/截图等),都必须走 `mcp__funplay_cocos__*` 这组 MCP 工具驱动编辑器。
**绝不允许手改** `.scene` / `.prefab` / `.anim` / `.meta`——它们是 UUID 引用的序列化资源,任何文本编辑都会破坏引用结构,导致「资源导入失败」且只能人工恢复。**MCP 连不上时也不例外**:应提示用户去编辑器启动服务,而不是退而求其次去手改文件。
+24
View File
@@ -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')]
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"skillId": "cocos-mcp-workflow",
"skillName": "funplay-cocos-mcp-workflow",
"templateVersion": 2,
"templateHash": "b0371afe3dd7084052927af8130a0929c0877cd48ade67ca9bd9605e1d8cee35",
"installedHash": "b0371afe3dd7084052927af8130a0929c0877cd48ade67ca9bd9605e1d8cee35",
"extensionVersion": "0.5.1",
"updatedAt": "2026-08-31T22:41:36.037Z"
}
@@ -0,0 +1,72 @@
---
name: funplay-cocos-mcp-workflow
description: "Edit, inspect, validate, preview, and debug Cocos Creator projects through Funplay Cocos MCP. Use when working with scenes, nodes, prefabs, assets, TypeScript, logs, screenshots, preview behavior, runtime state, or MCP connectivity."
---
# Funplay Cocos MCP Workflow
## Instructions
## Operating Loop
1. Establish context.
- Read `cocos://project/context` or call `get_editor_state` before assuming the project, active scene, MCP URL, selection, visible windows, or tool profile.
- Inspect the active scene with `get_scene_info` and `get_hierarchy`; use `get_selection`, `list_scenes`, `list_assets`, or `list_prefabs` when identity or ownership is unclear.
- Treat user-provided node and asset names as hints. Resolve the real hierarchy path, node UUID, asset UUID, or `db://assets/...` URL before editing.
- Call `get_tool_catalog` when a required tool may be hidden by the `core`, `full`, or custom exposure profile.
2. Choose the edit surface.
- Edit TypeScript and ordinary project files with repository tools or the MCP file tools, then refresh the affected asset and run diagnostics.
- Edit live scene nodes with focused scene/component tools or `execute_javascript` using `context="scene"`; save the scene when the change must persist.
- Use `execute_javascript` with `context="editor"` for asset-db, Editor messages, project orchestration, and filesystem work that belongs in the editor process.
- Inspect prefab ownership and references before mutation. Prefer focused prefab tools, or edit a verified linked instance and apply it back through the editor workflow.
- Preserve an existing UI or gameplay prefab hierarchy and change only the necessary nodes, components, and serialized fields; do not rebuild the entire prefab unless explicitly requested.
3. Execute the smallest coherent change.
- Prefer one guarded `execute_javascript` operation for tightly related editor work, but use focused tools when they provide clearer validation or safer arguments.
- Keep JavaScript safety checks enabled unless the code and its paths were reviewed explicitly.
- Null-check every scene, node, component, asset, and filesystem lookup. Return concise structured before/after values, including stable UUIDs or asset URLs where useful.
- Save or refresh only the assets and scenes intentionally changed.
- Do not guess alternate paths, silently create replacement objects, or run self-healing fallback loops after a missing reference or unsupported editor message.
4. Read back and validate.
- Re-inspect the exact node, component, prefab instance, or asset after mutation; a successful command response alone is not proof of the final editor state.
- Run `run_script_diagnostics` or `get_script_diagnostic_context` after TypeScript changes, then use `validate_scene` and project logs before claiming success.
- For visual or runtime work, run the appropriate browser, Game View, or simulator preview and verify with runtime state, input, logs, and screenshots.
- State exactly what was verified and what still requires a native build, device, network, store, or manual check.
## Scene, Prefab, and Asset Safety
- Do not treat Cocos `.scene`, `.prefab`, or `.meta` files as ordinary text. Prefer scene-process, prefab, and asset-db operations that preserve UUID references and editor import state.
- If `edit_prefab_json` is used, target a verified prefab path and the smallest exact JSON path or literal replacement, then run `validate_prefab_references` and inspect the result.
- Before structural prefab work, call `inspect_prefab`; for scene instances, call `inspect_prefab_instance` and choose deliberately between apply and revert.
- Replacing a prefab at the same path can keep the asset UUID while changing internal object IDs and breaking serialized references, animation tracks, nested prefab links, and scene overrides.
- Inspect dependencies with `inspect_asset_dependencies` and validate them with `validate_asset_dependencies` before and after sensitive asset changes.
- Never copy a `.meta` file when duplicating an asset. Use `duplicate_prefab` or asset-db operations so the new asset receives its own UUID.
## Tool Exposure and Execution Contexts
- The default `core` profile exposes the main inspection, diagnostics, logs, screenshots, scene, asset, and unified JavaScript workflow.
- The `full` profile adds focused mutation tools for nodes, components, prefabs, UI, runtime control, input simulation, files, and project preview.
- If a named tool is unavailable under a custom profile, adapt to the exposed catalog and report the missing capability instead of pretending it ran.
- In scene context, use the Cocos runtime and scene APIs for live hierarchy and component work. In editor context, use `Editor` APIs and messages for asset-db and extension orchestration.
- Use `execute_scene_script` and `execute_editor_script` only as compatibility entrypoints; prefer `execute_javascript` with an explicit context for new workflows.
## Script and Asset Validation
- After external script changes, refresh the affected asset or `db://assets`, then run TypeScript no-emit diagnostics. Use diagnostic context to read focused source snippets before repairing errors.
- Cocos import and compilation are asynchronous. After refresh, re-query diagnostics, logs, or asset info instead of assuming the first request observed the final state.
- Read `get_recent_logs` or `search_project_logs` for import, serialization, preview, and runtime failures. Do not clear persistent project logs without explicit confirmation.
- Use `validate_scene` as a compact final pass, not as a replacement for targeted readback of the values changed.
## Preview and Runtime Verification
- Query `get_preview_mode` before changing preview behavior. Use `run_project_preview` only when preview execution is needed and distinguish browser `localUrl` from a LAN `networkUrl`.
- Use `get_runtime_state` for pause, frame, and time-scale state; use focused runtime or component methods only when runtime behavior must be exercised.
- Use `capture_scene_screenshot` for scene-side composition, `capture_preview_screenshot` for game output, and `capture_editor_screenshot` for editor UI or extension panels.
- When low-level input is needed, list editor windows first and target the preview or simulator deliberately. Prefer semantic button events when available.
- Restore temporary runtime state such as pause or time scale before finishing unless the user explicitly wants it left changed.
## Failure Handling
- If MCP is unreachable, limit claims to safe filesystem inspection or code edits; do not claim scene, prefab, editor, preview, or runtime verification.
- If a node lookup is ambiguous, return the matching paths and UUIDs and choose only after identifying the user-visible or prefab-owned target.
- If editor readback and serialized text disagree, trust editor and asset-db readback first and investigate whether the wrong asset, scene instance, or stale import was inspected.
- Fix diagnostics or new error logs caused by the change before visual or runtime validation.
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"skillId": "cocos-ui-composition",
"skillName": "funplay-cocos-ui-composition",
"templateVersion": 1,
"templateHash": "835f4c4b6e6dc79cabf4e5048ac056b6f2441b2b77c0c555ca502740cc175e72",
"installedHash": "835f4c4b6e6dc79cabf4e5048ac056b6f2441b2b77c0c555ca502740cc175e72",
"extensionVersion": "0.5.1",
"updatedAt": "2026-08-31T22:41:37.608Z"
}
@@ -0,0 +1,106 @@
---
name: funplay-cocos-ui-composition
description: "Build and revise responsive Cocos Creator UI for mobile, desktop, and web, including portrait and landscape layouts, safe areas, prefabs, Widget and Layout behavior, scrolling, text, input, animation, and performance validation."
---
# Funplay Cocos UI Composition
## Instructions
## Operating Loop
1. Inspect before editing.
- Confirm the active scene, Canvas, design resolution, Fit Width/Fit Height policy, target orientations, SafeArea usage, and relevant prefab asset paths.
- Inspect the existing hierarchy, `UITransform` sizes and anchor points, `Widget` constraints and align modes, `Layout` ownership, sibling order, render order, serialized references, animation targets, and prefab instance state.
- Treat screenshots and design coordinates as visual intent, not as permission to replace a working hierarchy.
2. Classify each region.
- Mark art as full-bleed or safe-area content.
- Mark placement as edge-aligned by Widget, stretched between edges, content-sized by Layout, repeated content, scrollable content, modal, overlay, or world-space UI.
- Decide which system owns position and size. Avoid letting Widget, Layout, animation, and manual code fight over the same property.
3. Make the smallest coherent change.
- Preserve the prefab root, existing children, components, names, UUID-backed references, animation tracks, nested prefab links, and scene overrides unless a specific replacement is required.
- Modify only the necessary nodes, components, fields, and children; do not rebuild the entire prefab unless explicitly requested.
- Prefer Cocos MCP scene, prefab, and asset-db workflows over generic text replacement for serialized assets.
4. Read back and validate.
- Read exact hierarchy, node UUIDs, `UITransform` sizes and anchors, Widget edges, Layout settings, sprites, labels, opacity, input blockers, event bindings, and prefab ownership back from Cocos.
- Test layout, input, safe area, localization, animation interruption, close/reopen state, and runtime data changes.
- Capture preview screenshots at representative aspect ratios. Use native builds on representative devices before claiming device performance or platform validation.
## Component Selection
| Component | Use it for | Configure deliberately | Avoid |
| --- | --- | --- | --- |
| `Canvas` | Root 2D/UI render space and design-resolution adaptation | Verify project design resolution, Fit Width/Fit Height, camera and layer behavior | Adding duplicate Canvases only to organize folders or assuming one resolution fits every aspect ratio |
| `UITransform` | UI size, anchor point, coordinate conversion, hit testing, and render priority | Set `contentSize` and anchor point intentionally; keep scale for visual effects rather than basic layout | Treating one screenshot position as universal or changing scale when size/Widget constraints should change |
| `Widget` | Edge, center, stretch, and parent-relative alignment | Choose top/bottom/left/right/center constraints and `ONCE`, `ON_WINDOW_RESIZE`, or `ALWAYS` based on runtime needs | Animating properties that an `ALWAYS` Widget rewrites at the end of the frame |
| `SafeArea` | Keeping critical controls inside notches and system gesture areas | Put it on the top interaction container; allow full-bleed backgrounds outside it | Applying safe-area insets twice or placing important controls outside the safe rectangle |
| `Layout` | Horizontal, vertical, or grid arrangement and container/child resizing | Choose type, resize mode, padding, spacing, constraint, and start axis; call `updateLayout` only when same-frame measurement is necessary | Putting Layout and Widget on the same node or manually positioning children driven by Layout |
| `Sprite` | Icons, panels, progress fills, and sliced or tiled UI art | Use sliced frames for scalable borders, preserve SpriteFrame references, and choose fill/type intentionally | Stretching bordered art as a simple Sprite or leaving decorative nodes interactive |
| `Label` / `RichText` | Localized text and formatted text | Set fonts, fallback coverage, alignment, wrapping, line height, overflow, and cache mode from actual update frequency | Broad `SHRINK` use on frequently changing labels or shipping without required CJK and symbol glyphs |
| `ScrollView` + `Mask` | Content larger than a viewport | Use a dedicated view/mask and content node, enable only required axes, and configure inertia, brake, bounce, and nested input deliberately | Combining Widget and Layout on the same Content node, or creating thousands of live rows without pooling |
| `Mask` | Rectangular, ellipse, graphics, or sprite-stencil clipping | Match the mask type to the visual requirement and keep renderer constraints in mind | Adding Sprite or Label renderers to a Mask node where Cocos requires the mask-owned Graphics/Sprite |
| `Button`, `Toggle`, `Slider`, `EditBox` | Semantic interaction | Verify transition state, target node, event handlers, hit area, keyboard/focus behavior, and disabled state | Duplicate event bindings, tiny touch targets, or visual-only disabled states |
| `UIOpacity` | Fading a UI subtree | Coordinate opacity with active state and input blocking | Hiding a modal visually while it still receives or blocks input |
| `BlockInputEvents` | Preventing pointer/touch events from passing through overlays and modals | Put it on the intended blocking region and validate sibling order and active state | Assuming a visible scrim blocks gameplay input by itself |
| `PageView` | Paged horizontal or vertical content | Recompute page and content sizes when the viewport changes and test drag thresholds | Hard-coding page positions from one device width |
## Design Resolution, Widget, and Safe Area
- Treat the design resolution as the coordinate baseline, not a physical-device whitelist. Verify the project Fit Width/Fit Height policy and any runtime `view.setDesignResolutionSize` policy before changing layout.
- Use Widget to express attachment and stretch. Use `ON_WINDOW_RESIZE` for resizable desktop/web or large-screen layouts, and use `ALWAYS` only when continuous alignment is worth its property ownership cost.
- When Widget owns an edge or size, change the Widget offsets or align mode rather than writing a Node position or UITransform size that will be overwritten later.
- Keep UI scale at one for ordinary layout. Resize through `UITransform.contentSize`, Widget constraints, Layout properties, or a deliberate design-resolution policy.
- Put decorative backgrounds outside the SafeArea interaction root so they can bleed to physical edges. Put buttons, labels, navigation, and other critical content under SafeArea.
- SafeArea already obtains `sys.getSafeAreaRect` and adjusts through Widget. Do not add a second manual inset unless the project has a documented additional margin.
- Re-evaluate layout on window resize, orientation change, or safe-area change when the target platform can change dimensions at runtime.
## Portrait and Landscape Patterns
- For portrait screens, organize persistent UI into Top, flexible Center, and Bottom regions. Let tall screens expand the center instead of multiplying every vertical coordinate by aspect ratio.
- For landscape screens, use Left, Center, Right, and stable corner regions. Verify 16:9, ultrawide, 16:10, and 4:3 instead of treating landscape as one shape.
- Separate camera/world composition from Canvas UI adaptation. A correct Widget layout does not prove the gameplay camera shows the intended world area.
- Use alternate art or deliberate cover/crop behavior when one background cannot preserve composition across phone, tablet, and desktop aspect ratios.
- Reposition only regions whose composition genuinely changes. Do not fork or rebuild the entire screen prefab for a few aspect-dependent offsets.
## Auto Layout and Dynamic Content
- A Layout component drives its children or container according to `ResizeMode`. Do not manually write the driven dimensions and expect them to survive the next layout update.
- For `ResizeMode.CHILDREN`, verify the container size and the resulting child sizes. For `ResizeMode.CONTAINER`, verify anchor point and growth direction so the container expands predictably.
- Grid Layout uses its configured cell and constraint policy. Use fixed rows or columns deliberately and do not expect heterogeneous child preferred sizes to define every cell.
- Runtime Layout property changes normally settle on the next frame. Call `updateLayout` only when code must read the final result in the same operation.
- Keep nested Layout chains shallow, batch data changes, pool repeated items, and avoid rebuilding a large hierarchy for every small model update.
## Sprites, Text, Scrolling, and Input
- Use sliced SpriteFrames for scalable bordered panels and buttons. Preserve caps/insets and inspect the actual SpriteFrame reference after prefab edits.
- For Label, choose `CLAMP`, `SHRINK`, or `RESIZE_HEIGHT` from a documented overflow policy. `SHRINK` can cost more CPU when text updates; `RESIZE_HEIGHT` transfers height ownership to the label.
- Choose Label cache mode from content behavior: avoid caching assumptions for highly dynamic text, and verify character coverage and atlas capacity for localized content.
- Structure a ScrollView as root, masked view, and content. Enable only the needed direction, verify the content reference, and test child-button cancellation and nested scroll behavior.
- Keep one intentional interactive target per control, bind events once, and validate the serialized target, component, handler, and custom event data.
- Give touch controls a project-defined minimum hit area even when the visible art is smaller. Verify with preview input rather than only inspecting dimensions.
- Synchronize modal visibility, `UIOpacity`, active state, Button interactability, and `BlockInputEvents` so hidden panels neither receive nor leak input.
## Animation and Prefab Safety
- Animate a `Visual` or `Container` child when the root is driven by Widget or Layout. Do not animate a property that layout rewrites every frame.
- Stop or cancel an existing tween/animation before replaying it and restore deterministic position, scale, opacity, active state, and input blocking on disable or close.
- Use unscaled scheduling or an explicit UI clock when menus and modal transitions must continue while gameplay time scale is zero.
- Preserve existing prefab objects by default. Replacing a prefab hierarchy can break internal object IDs, animation tracks, serialized component references, nested prefabs, and scene overrides even when the asset UUID remains stable.
- Prefer serialized references and stable semantic names over repeated `getChildByPath` lookups. If a path is required, verify it and fail clearly rather than silently creating an alternate hierarchy.
## Performance and Validation
- Profile before restructuring. Common UI costs include excessive nodes, draw-call breaks, masks/stencils, overdraw, dynamic text generation, repeated Layout work, and large unpooled lists.
- Preserve render batching by grouping compatible sprites and materials without changing intended sibling/render order. Use dynamic atlas or static batching only after verifying asset eligibility and runtime behavior.
- Use `get_performance_snapshot` to compare node, component, UI, depth, and memory-oriented counters before and after material changes.
- Validate portrait at 16:9, 19.5:9 or 20:9, a cutout phone, and a portrait tablet. Validate landscape at 16:9, ultrawide, 16:10, 4:3, and both cutout sides.
- In every profile, verify full-bleed art, safe interactive content, text overflow and glyphs, scroll bounds, modal blocking, touch targets, animation interruption, and close/reopen state.
## Official Cocos References
- [Cocos Creator 3.8 UI system](https://docs.cocos.com/creator/3.8/manual/en/ui-system/)
- [UITransform](https://docs.cocos.com/creator/3.8/api/en/class/UITransform) and [Widget](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/widget.html)
- [SafeArea](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/safearea.html) and [multi-resolution adaptation](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/engine/multi-resolution.html)
- [Layout](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/layout.html) and [ScrollView](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/scrollview.html)
- [Label](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/label.html) and [Mask](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/mask.html)
- [Node hierarchy and UI rendering order](https://docs.cocos.com/creator/3.8/manual/en/concepts/scene/node-tree.html)
@@ -0,0 +1,2 @@
[InternetShortcut]
URL=https://docs.cocos.com/creator/manual/en/scripting/setup.html#custom-script-template
+6 -6
View File
@@ -2,12 +2,12 @@
#///////////////////////////
# Cocos Creator 3D Project
#///////////////////////////
library/
temp/
local/
build/
profiles/
native
/library/
/temp/
/local/
/build/
/profiles/
/native/
#//////////////////////////
# NPM
#//////////////////////////
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "647a7b22-853c-4083-b6e9-c287ccc6babc",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "e4f0c9ce-255e-463b-b907-bec24412c756",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,61 @@
import {createBuildIdentity} from './build-identity.ts';
import {_decorator,Prefab,SceneAsset,Node,assetManager,AssetManager,TextAsset,js} from 'cc';
import versionManifestParser,{type VersionManifest} from './version-manifest.js';
import {GameComposition,type CommonRoomView} from '../../scripts/platform-login/GameComposition.ts';
import {getGameDefinition,gameBundleName,gameResourcePath,type SubgameEntry} from './game-definitions.ts';
import type {GameDefinition} from '../../framework/sdk/contracts/game-definition.ts';
const {ccclass,property}=_decorator;
/** App composition root: each game assigns its own pre-room page, room view and scene. */
@ccclass('SubgameAssets')
export class SubgameAssets extends GameComposition {
@property gameKey:string='';
private loaded:Readonly<{definition:GameDefinition;createRoom:Prefab;room:Prefab}>|null=null;
get createRoom(){return this.loaded?.createRoom??null;}
get room(){return this.loaded?.room??null;}
@property(SceneAsset) roomScene:SceneAsset|null=null;
private manifestValue:Readonly<VersionManifest>|null=null;
get versionManifest(){return this.manifestValue;}
get displayVersion(){if(!this.manifestValue)throw Error('version.xml has not been loaded');return this.manifestValue.version;}
async loadBuildIdentity(){
await this.loadGameAssets();
const key=this.gameKey,path=this.definition.config.versionResource;
if(path!==key+'/version')throw Error('Version resource does not match selected game: '+key);
const bundleName='version-'+key;
const bundle=await new Promise<AssetManager.Bundle>((resolve,reject)=>assetManager.loadBundle(bundleName,(error,result)=>{
if(error)reject(Error('Cannot load '+bundleName+': '+error.message));
else if(!result)reject(Error('Missing '+bundleName));else resolve(result);
}));
const asset=await new Promise<TextAsset>((resolve,reject)=>bundle.load(path,TextAsset,(error,result)=>{
if(error)reject(Error('Cannot load '+path+'.xml: '+error.message));
else if(!result)reject(Error('Missing '+path+'.xml'));else resolve(result);
}));
try {
if(this.gameKey!==key)throw Error('Game selection changed during version.xml load');
const manifest=versionManifestParser.parseVersionManifest(asset.text);
const identity=createBuildIdentity(manifest);
this.manifestValue=manifest;
return identity;
} finally {bundle.release(path,TextAsset);}
}
private async loadGameAssets(){
const key=this.gameKey;
if(this.loaded){if(this.loaded.definition.key!==key)throw Error('Game selection changed after loading');return;}
const bundle=await new Promise<AssetManager.Bundle>((resolve,reject)=>assetManager.loadBundle(gameBundleName(key),(error,result)=>{
if(error)reject(error);else if(!result)reject(Error('Missing game bundle: '+key));else resolve(result);
}));
const definition=getGameDefinition(key,name=>js.getClassByName(name) as unknown as SubgameEntry|null);
const load=(resource:string)=>new Promise<Prefab>((resolve,reject)=>bundle.load(gameResourcePath(definition.config.assetRoot,resource),Prefab,(error,result)=>{
if(error)reject(error);else if(!result)reject(Error('Missing game prefab: '+resource));else resolve(result);
}));
const [createRoom,room]=await Promise.all([load(definition.resources.createRoom),load(definition.resources.room)]);
if(this.gameKey!==key)throw Error('Game selection changed during asset load');
this.loaded={definition,createRoom,room};
}
get definition(){if(!this.loaded)throw Error('Subgame assets have not been loaded');return this.loaded.definition;}
resolveRoomView(root:Node):CommonRoomView {
const name=this.definition.roomViewClass;
const view=root.getComponent(name) as (CommonRoomView|null);
if(!view||typeof view.render!=='function'||typeof view.close!=='function'||typeof view.dissolutionReceived!=='function')throw Error(`Room prefab missing valid ${name}`);
return view;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "ec19e470-cf36-40e8-b420-e4314e5fbf07",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,8 @@
import {resolveIdentity,type ChannelIdentity} from '../../framework/config/identity.ts';
import type {VersionManifest} from './version-manifest.js';
/** XML has no market field. This application value is for Web testing only. */
export const WEB_MARKET_ID=4;
export function createBuildIdentity(manifest:VersionManifest):Readonly<ChannelIdentity> {
return resolveIdentity([()=>({agentid:manifest.agentid,channelid:manifest.channelid,
gameid:manifest.gameid,version:manifest.versionCode,marketid:WEB_MARKET_ID})]);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "b1825c2d-fa8a-4bd5-b20d-cad54a388938",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,19 @@
import type {GameDefinition} from '../../framework/sdk/contracts/game-definition.ts';
export interface SubgameEntry {readonly definition:GameDefinition;}
export function gameBundleName(key:string):string {
if(!/^[A-Za-z0-9_-]+$/.test(key))throw Error('Invalid gameKey: '+key);
return 'game-'+key;
}
/** Resolve only after the selected bundle has registered its entry class. */
export function getGameDefinition(key:string,findEntry:(name:string)=>SubgameEntry|null):GameDefinition {
gameBundleName(key);
const entry=findEntry('GameEntry_'+key);
if(!entry?.definition)throw Error('Missing subgame entry: '+key);
if(entry.definition.key!==key)throw Error('Subgame entry key mismatch: '+key);
return entry.definition;
}
export function gameResourcePath(root:string,resource:string):string {
if(!/^games\/[A-Za-z0-9_-]+$/.test(root)||!resource.startsWith(root+'/')||!resource.endsWith('.prefab')||resource.split('/').some(part=>!part||part==='.'||part==='..')||resource.includes('\\'))throw Error('Invalid private game resource: '+resource);
return resource.slice(root.length+1,-'.prefab'.length);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "8d097469-ffe1-48e5-9e8f-eb8d7c6b8774",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,7 @@
export interface VersionManifest {
readonly agentid:string;readonly agentName:string;readonly channelid:string;readonly channelName:string;
readonly gameid:string;readonly gameName:string;readonly versionCode:number;readonly version:string;
}
export function parseVersionManifest(xml:string):Readonly<VersionManifest>;
declare const parser:{parseVersionManifest:typeof parseVersionManifest};
export default parser;
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4bbb1366-4f50-4d7b-9a67-2f163b4c20c1",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,28 @@
'use strict';
// Shared by Cocos runtime and the build extension: one parser for the native contract.
const {DOMParser}=require('@xmldom/xmldom');
function parseVersionManifest(xml){
const fail=message=>{throw Error('version.xml: '+message);};
if(typeof xml!=='string'||!xml.trim())fail('empty document');
if(/<!DOCTYPE|<!ENTITY/i.test(xml))fail('DTD and entity declarations are unsupported');
let doc;
try {doc=new DOMParser({onError:(_level,message)=>fail(message)}).parseFromString(xml,'text/xml');}
catch(error){fail(error.message);}
const root=doc.documentElement;
if(!root||root.tagName!=='game'||root.namespaceURI)fail('expected game root');
const fields={};
for(let child=root.firstChild;child;child=child.nextSibling){
if(child.nodeType===3&&!child.nodeValue.trim()||child.nodeType===8)continue;
if(child.nodeType!==1||!['agent','game','channel','version'].includes(child.tagName)||child.namespaceURI)fail('unexpected child');
if(fields[child.tagName])fail('duplicate '+child.tagName);
fields[child.tagName]=child;
for(let nested=child.firstChild;nested;nested=nested.nextSibling){if(nested.nodeType!==3||nested.nodeValue.trim())fail('unexpected nested content');}
}
const attr=(tag,name)=>{const node=fields[tag];if(!node)fail('missing '+tag);const value=node.getAttribute(name);if(value===null||!value.trim())fail('missing '+tag+'@'+name);return value;};
const code=attr('version','value');
if(!/^\d+$/.test(code)||!Number.isSafeInteger(Number(code)))fail('version@value must be a nonnegative safe integer');
return Object.freeze({agentid:attr('agent','id'),agentName:attr('agent','name'),
gameid:attr('game','id'),gameName:attr('game','name'),channelid:attr('channel','id'),channelName:attr('channel','name'),
versionCode:Number(code),version:attr('version','name')});
}
exports.parseVersionManifest=parseVersionManifest;
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "javascript",
"imported": true,
"uuid": "2de3f49a-c136-4137-9635-55a8598c867c",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,6 +1,82 @@
# framework — 框架真源(唯一权威副本)
各子游戏工程的 `assets/framework` 是指向**本目录**的 junction(见 `scripts/setup-links.mjs`)。
六层结构(依赖严格单向,详见 spec §3):core / net / protocol / platform / ui / sdk。
协议相关实现以 `docs/protocol/` 为权威,远程配置与原生接口也必须复刻原工程。服务器、配置服务和原生侧均零改动。
协议相关实现一律以 `docs/protocol/` 为唯一信息源(spec §0.1),不在代码或注释中内联复制协议字段。
## 现代运行时的唯一组合路径
```text
game CompositionRoot -> bootstrapPlatform(single GameEntry)
game implementation -> framework/sdk only
WireClient -> PlatformRuntime -> Router exactly once
PlatformStore -> selectors -> GameHost/UI read-only consumers
```
这里的 `bootstrapPlatform(single GameEntry)` 是组合设计记号,当前没有同名函数。
已实现的构造入口是 `new PlatformRuntime(options)`:`gameEntry` 由构建期固定提供一个,
`resolveRuntimeConfig` 使用 `config/runtime-config.ts` 的解析器,
`createWireClient(config)` 用最终 `config.servers` 构造 `WireClient`,注入实际 Transport 工厂。
组合方还提供 `ScenePort`、资源加载、最短展示等待和登录设备快照能力,然后调用 `start()`;
界面取得账号后显式调用 `login(account)`,socket open 本身不会发送登录。
`integration/platform-vertical-slice.test.ts` 展示了这一组合的完整无界面回放。
Runtime 内部创建唯一 Router、RuntimeSession、PlatformStore、平台命令和 GameSessionHost。
游戏实现只通过 `framework/sdk/index.ts` 导入公开契约;该入口只重导出 `sdk/contracts`,
不再公开旧 Store、EventBus 或任意路由发包能力。SDK contracts 不导入内部框架模块或 `cc`;
SDK runtime 也不导入框架实现层,平台适配由 `platform/game-host-adapter.ts` 承担。
不提供 GameRegistry、运行时 API 协商、多个游戏的包、游戏直接访问 Store/EventBus 的路径。
每次房间会话创建新的 GameModule 和 Host lease,旧 lease 不能读取或操纵新房间。
平台先原子提交房间和玩家快照,再创建游戏、发布平台事件;truthy `deskinfo` 在房间场景就绪后
按原对象引用交给 `restore`,不进入 PlatformStore,falsey/缺失值不触发恢复。
连接事件也提交到同一个 Store;重连只更换 app 连接状态,保留 room/players 引用。
切服先发送原始 `connect_*server` 信封,进房成功再原子标记已登录;普通重连才重新发送已保存的登录信封。
登录、进房的非零 `state` 通过必需的 `ScenePort.showServerDenial({ rpc, state, data })`
报告;`data` 保留服务器原对象,消费方依据 `showerror`、`error`、`roomcode` 等原字段处理。
这类结果不提交成功状态、不关闭连接,调用方可重试;成功回包缺少必需字段仍是显式错误。
`GameEntry.resolveSeatCount` 只接收真实房间的 `roomtype`,预检只检查该方法存在;
GameHost 在进房边界验证返回值是正整数并与服务器座位数组长度相同。
`assertGameContract(entry, samples, assert)` 的 `assert` 由测试调用方从 `node:assert/strict`
显式导入并传入;harness 仅引用断言类型,避免 Creator 加载脚本时解析 Node 内置模块。
每个样本由游戏提供 `roomtype`、`expectedSeatCount`
和对应的 `makeHost` 工厂,不要求游戏支持框架虚构的房间配置。
远程地址唯一取自 `data.urlserver`。配置请求使用空 body 的 POST,沿用无条件 `?` 缓存参数拼接。
原生 settings 方法名称、调用次序和 WVJB 初始化/handler 名称由 `config/sources`、
`adapters/native` 实现,`framework-tests/config` 与 `framework-tests/native` 验证。
## 仍被 Cocos 脚本使用的兼容隔离区
以下是 `LEGACY_RUNTIME` 的完整文件清单,路径相对本目录;现代框架文件禁止直接或传递依赖它们:
- `net/net-client.ts`
- `platform/session.ts`
- `platform/startup.ts`
- `platform/room-rpc-bus.ts`
- `platform/readonly.ts`
- `platform/stores/app-store.ts`
- `platform/stores/player-store.ts`
- `platform/stores/room-store.ts`
- `platform/stores/types.ts`
- `protocol/room-handlers.ts`
当前 `YouleNexus/assets/scripts/LoginFlow.ts`、`RoomEventProbe.ts`、`RoomSceneStart.ts`
仍直接引用上述兼容文件。因此它们继续保留;阶段 3 必须由 Presenter 和真正的游戏 Composition Root
接管这些脚本的调用后,阶段 6 才能删除兼容区。当前无界面现代回放通过,不表示 Cocos UI 已接入现代运行时。
保留的兼容逻辑测试(相对 `framework-tests/`)是 `net/net-client.test.ts`、
`platform/app-store.test.ts`、`platform/player-store.test.ts`、`platform/room-store.test.ts`、
`platform/session.test.ts`、`platform/startup.test.ts`、`platform/room-rpc-bus.test.ts`、
`protocol/room-handlers.test.ts`。它们测试旧脚本仍需使用的行为,不属于现代运行时的依赖图。
旧 SDK 的完整标识符盘点仅允许 `architecture/import-boundaries.test.mjs` 中的负例,
以及 `protocol/room-handlers.ts` 内一处历史说明。名称子串 `requireActiveGame` 是现代处理器的局部状态检查方法,
不是已移除的旧类型;自动盘点按完整标识符匹配。
`scripts/check-import-boundaries.mjs` 不再给 SDK 入口任何内部依赖豁免,也不给旧活动游戏文件名称检查豁免。
`framework-tests/architecture/import-boundaries.test.mjs` 对全部现代框架 TypeScript 文件检查传递依赖,
并验证重导出、别名、动态导入、CommonJS 和 import type 的负例;无法静态判断的传递加载直接拒绝。
阶段 3 的 Presenter/Composition Root 接管、theme/Prefab binding、首个真实子游戏迁移及 ZIP 发布均属于后续独立计划。
本次逻辑验收不改动 Cocos 序列化资源或现有 UI 迁移产物。
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "aea92450-9c09-40ed-8a4d-5cced7a56b3b",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "c1b8189d-1134-4ff3-9426-d2668fd9ab6f",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,33 @@
import { assertLocalStartupContext, type LocalStartupContext } from '../../config/local-startup.ts';
export interface LocalStoragePort {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export const LOCAL_MACHINE_KEY = 'youle.local-test.machine.v1';
export function sampleLocalRandom(
random: () => number,
failure: (reason: string, cause?: unknown) => Error,
): number {
let value: number;
try { value = random(); } catch (cause) { throw failure('source failed', cause); }
if (!Number.isFinite(value)) throw failure('expected finite number');
if (value < 0 || value >= 1) throw failure('expected range [0, 1)');
return value;
}
export function localAccountKey(context: LocalStartupContext): string {
assertLocalStartupContext(context);
const { identity } = context.config;
const scope = JSON.stringify([
context.profile,
context.config.servers,
identity.agentid,
identity.gameid,
identity.channelid,
identity.marketid,
]);
return `youle.local-test.account.v1:${scope}`;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "0693ece5-bed1-4b21-8ae7-7393c2eee8a7",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,120 @@
import type { LocalStartupContext } from '../../config/local-startup.ts';
import type { LoginAccountIdentity, LoginDeviceSnapshot } from '../../protocol/contracts/index.ts';
import { LOCAL_MACHINE_KEY, sampleLocalRandom, type LocalStoragePort } from './local-storage.ts';
import { createLocalVisitorProvider } from './visitor-account.ts';
export interface LocalHostSnapshot {
readonly location: unknown;
readonly returnCitySN?: unknown;
}
export interface LocalLoginSources {
prepareLogin(): Promise<LoginAccountIdentity>;
getLoginDeviceSnapshot(): LoginDeviceSnapshot;
resetAccount(): Promise<LoginAccountIdentity>;
}
interface Options {
readonly context: LocalStartupContext;
readonly storage: LocalStoragePort;
readonly random: () => number;
readonly now: () => number;
readonly sampleHost: () => LocalHostSnapshot;
readonly isSessionActive: () => boolean;
}
function machineFailure(path: string, reason: string, cause?: unknown): Error {
const error = new Error(`Invalid local machine cache ${LOCAL_MACHINE_KEY} at ${path}: ${reason}`) as Error & { cause?: unknown };
if (cause !== undefined) error.cause = cause;
return error;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function getMachineId(options: Options): string {
let raw: string | null;
try { raw = options.storage.getItem(LOCAL_MACHINE_KEY); }
catch (cause) { throw machineFailure('$', 'storage read failed', cause); }
if (raw !== null) {
let record: unknown;
try { record = JSON.parse(raw); }
catch (cause) { throw machineFailure('$', 'invalid JSON', cause); }
if (!isRecord(record)) throw machineFailure('$', 'expected object');
if (record.schemaVersion !== 1) throw machineFailure('schemaVersion', 'expected 1');
for (const field of Object.keys(record)) {
if (field !== 'schemaVersion' && field !== 'machineid') throw machineFailure(field, 'unknown field');
}
if (typeof record.machineid !== 'string') throw machineFailure('machineid', 'expected string');
const match = /^(0|[1-9][0-9]*)-([1-9][0-9]{3})$/.exec(record.machineid);
if (!match || !Number.isSafeInteger(Number(match[1])) || Number(match[2]) > 9998) {
throw machineFailure('machineid', 'expected nonnegative safe timestamp and suffix 1000..9998');
}
return record.machineid;
}
let timestamp: number;
try { timestamp = options.now(); }
catch (cause) { throw machineFailure('now', 'source failed', cause); }
if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw machineFailure('now', 'expected nonnegative safe integer');
const random = sampleLocalRandom(options.random, (reason, cause) => machineFailure('random', reason, cause));
const machineid = `${timestamp}-${1000 + Math.floor(random * 8999)}`;
try { options.storage.setItem(LOCAL_MACHINE_KEY, JSON.stringify({ schemaVersion: 1, machineid })); }
catch (cause) { throw machineFailure('$', 'storage write failed', cause); }
return machineid;
}
export function createLocalLoginSources(options: Options): LocalLoginSources {
const provider = createLocalVisitorProvider(options);
let device: LoginDeviceSnapshot | undefined;
let preparing: Promise<LoginAccountIdentity> | undefined;
let resetting: Promise<LoginAccountIdentity> | undefined;
function prepareLogin(): Promise<LoginAccountIdentity> {
if (resetting) return Promise.reject(new Error('Cannot prepare login while reset is pending'));
if (preparing) return preparing;
device = undefined;
preparing = (async () => {
const cachedAccount = await provider.getAccount();
const machineid = getMachineId(options);
const snapshot = options.sampleHost();
if (!isRecord(snapshot) || !Object.prototype.hasOwnProperty.call(snapshot, 'location')
|| (snapshot.location !== null && !isRecord(snapshot.location))) {
throw new Error('Invalid local host location: expected explicit null or record');
}
const city = snapshot.returnCitySN;
if (city !== undefined) {
if (!isRecord(city)) throw new Error('Invalid local host returnCitySN: expected record');
for (const field of ['ip', 'province', 'city'] as const) {
if (typeof city[field] !== 'string') throw new Error(`Invalid local host returnCitySN.${field}: expected string`);
}
}
const account: LoginAccountIdentity = Object.freeze({ ...cachedAccount,
...(city === undefined ? {} : { province: city.province as string, city: city.city as string }),
});
const nextDevice: LoginDeviceSnapshot = Object.freeze({ location: snapshot.location,
machineid, machineroom: '', deviceLogin: Object.freeze({ enabled: false as const }),
loginPlayerId: Object.freeze({ enabled: false as const }),
...(city === undefined ? {} : { ip: city.ip as string }),
});
device = nextDevice;
return account;
})().finally(() => { preparing = undefined; });
return preparing;
}
function resetAccount(): Promise<LoginAccountIdentity> {
device = undefined;
if (preparing) return Promise.reject(new Error('Cannot reset account while prepare is pending'));
if (resetting) return Promise.reject(new Error('Cannot reset account while reset is pending'));
resetting = provider.resetAccount().finally(() => { resetting = undefined; });
return resetting;
}
function getLoginDeviceSnapshot(): LoginDeviceSnapshot {
if (device === undefined) throw new Error('Local login device requires a successful prepareLogin');
return device;
}
return Object.freeze({ prepareLogin, resetAccount, getLoginDeviceSnapshot });
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4deb8dec-916d-4dda-b709-ac5f38fa0253",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,114 @@
import type { LoginAccountIdentity } from '../../protocol/contracts/index.ts';
import { assertLocalStartupContext, type LocalStartupContext } from '../../config/local-startup.ts';
import { localAccountKey, sampleLocalRandom, type LocalStoragePort } from './local-storage.ts';
const AVATARS = Object.freeze([
'https://img2.baidu.com/it/u=1823726962,2692568839&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
'https://img0.baidu.com/it/u=320875415,2231067789&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
'https://img1.baidu.com/it/u=3407774307,2859661062&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
'https://img2.baidu.com/it/u=1161398577,3592656327&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
'https://img1.baidu.com/it/u=2061657615,4194426181&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=501',
'https://img2.baidu.com/it/u=3081347414,2363887197&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
'https://img1.baidu.com/it/u=1364384963,3982203144&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
'https://img1.baidu.com/it/u=3041194995,2386060237&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
'https://img2.baidu.com/it/u=2904976445,2884058010&fm=253&fmt=auto&app=120&f=JPEG?w=529&h=500',
'https://img0.baidu.com/it/u=1801052125,1596537112&fm=253&fmt=auto&app=120&f=JPEG?w=529&h=500',
] as const);
export interface LocalVisitorProvider {
getAccount(): Promise<LoginAccountIdentity>;
resetAccount(): Promise<LoginAccountIdentity>;
}
interface Options {
readonly context: LocalStartupContext;
readonly storage: LocalStoragePort;
readonly random: () => number;
readonly isSessionActive: () => boolean;
}
const ACCOUNT_FIELDS = Object.freeze(['openid', 'nickname', 'avatar', 'sex', 'province', 'city', 'unionid'] as const);
function wrap(key: string, path: string, reason: string, cause?: unknown): Error {
const error = new Error(`Invalid local account cache ${key} at ${path}: ${reason}`) as Error & { cause?: unknown };
if (cause !== undefined) error.cause = cause;
return error;
}
function exactRecord(value: unknown, key: string, path: string, fields: readonly string[]): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw wrap(key, path, 'expected object');
const record = value as Record<string, unknown>;
for (const field of fields) {
if (!Object.prototype.hasOwnProperty.call(record, field)) throw wrap(key, `${path}.${field}`, 'missing field');
}
for (const field of Object.keys(record)) if (!fields.includes(field)) throw wrap(key, `${path}.${field}`, 'unknown field');
return record;
}
function validateAccount(value: unknown, key: string): LoginAccountIdentity {
const account = exactRecord(value, key, 'account', ACCOUNT_FIELDS);
for (const field of ['openid', 'nickname', 'avatar', 'province', 'city', 'unionid'] as const) {
if (typeof account[field] !== 'string') throw wrap(key, `account.${field}`, 'expected string');
}
if (account.openid === '') throw wrap(key, 'account.openid', 'must not be empty');
if (account.unionid === '') throw wrap(key, 'account.unionid', 'must not be empty');
if (!Number.isInteger(account.sex)) throw wrap(key, 'account.sex', 'expected integer');
if (account.sex !== 1 && account.sex !== 2) throw wrap(key, 'account.sex', 'expected 1 or 2');
if (account.nickname === '' && account.avatar === '') throw wrap(key, 'account.nickname/avatar', 'must not both be empty');
return Object.freeze(account as unknown as LoginAccountIdentity);
}
function parseCached(raw: string, key: string): LoginAccountIdentity {
let value: unknown;
try { value = JSON.parse(raw); } catch (cause) { throw wrap(key, '$', 'invalid JSON', cause); }
const record = exactRecord(value, key, '$', ['schemaVersion', 'account']);
if (record.schemaVersion !== 1) throw wrap(key, 'schemaVersion', 'expected 1');
return validateAccount(record.account, key);
}
function sample(random: () => number, key: string): number {
return sampleLocalRandom(random, (reason, cause) => wrap(key, 'random', reason, cause));
}
export function generate(random: () => number, key: string): LoginAccountIdentity {
const id = Math.floor(sample(random, key) * 100000);
const avatar = AVATARS[Math.floor(sample(random, key) * AVATARS.length)]!;
const sex = Math.floor(sample(random, key) * 2) + 1;
return validateAccount({ openid: `testopenid_${id}`, unionid: `ylgame${id}`,
nickname: `${id}_游客`, avatar, sex, province: 'jiangxi', city: 'nanchang' }, key);
}
function storageFailure(key: string, operation: 'read' | 'write', cause: unknown): Error {
return wrap(key, '$', `storage ${operation} failed`, cause);
}
export function createLocalVisitorProvider(options: Options): LocalVisitorProvider {
assertLocalStartupContext(options.context);
const key = localAccountKey(options.context);
let pending: Promise<LoginAccountIdentity> | null = null;
async function getAccount(): Promise<LoginAccountIdentity> {
if (pending) return pending;
pending = (async () => {
let raw: string | null;
try { raw = options.storage.getItem(key); } catch (cause) { throw storageFailure(key, 'read', cause); }
if (raw !== null) return parseCached(raw, key);
const account = generate(options.random, key);
try { options.storage.setItem(key, JSON.stringify({ schemaVersion: 1, account })); }
catch (cause) { throw storageFailure(key, 'write', cause); }
return account;
})();
try { return await pending; } finally { pending = null; }
}
async function resetAccount(): Promise<LoginAccountIdentity> {
if (options.isSessionActive()) throw new Error('Cannot reset local visitor account during an active session');
if (pending) throw new Error('Cannot reset local visitor account while getAccount is pending');
const account = generate(options.random, key);
try { options.storage.setItem(key, JSON.stringify({ schemaVersion: 1, account })); }
catch (cause) { throw storageFailure(key, 'write', cause); }
return account;
}
return Object.freeze({ getAccount, resetAccount });
}
@@ -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,'');
}
@@ -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}&timestamp=${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});
}
}
@@ -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;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "27e9345c-5f75-4829-be9f-174141dec6e6",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -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();
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "df3fb4b3-bb92-40f7-b661-05a158dec617",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -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]);
}
}
@@ -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]);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "41e1b7ed-8004-4b1e-b41d-3013e5716ac2",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -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');}
}
@@ -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)]}));}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "1a646893-8674-4b0e-b9c5-115be014edb1",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -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');
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "588b5341-c0f6-4eef-bf31-b47c815db2b0",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,82 +1,51 @@
import type { ChannelIdentity } from './identity.ts';
import { resolveIdentity } from './identity.ts';
import { BUILD_IDENTITY } from './sources/defaults.ts';
import { queryStringSource } from './sources/query-string.ts';
import { nativeSettingsSource, type NativeHost } from './sources/native-settings.ts';
import { resolveActiveProfile, PROFILES, type DebugProfile, type DebugAccount } from './profiles.ts';
import { resolveServers, type ConfigFetcher, type RemoteConfig, ConfigFetchError } from './remote-config.ts';
import type { RuntimeMode } from './runtime-mode.ts';
import type { DebugAccount } from './profiles.ts';
import {
resolveRuntimeConfig,
type HostKind,
type ResolveRuntimeConfigOptions,
type RuntimeConfig,
} from './runtime-config.ts';
export interface BootstrapOptions {
mode: RuntimeMode; // 运行模式(由 resolveRuntimeMode 得到)
win: NativeHost; // 原生注入宿主(≈ window)
search: string; // location.search
fetcher: ConfigFetcher; // 远程配置抓取
isNative: boolean; // 环境判定(URL 含 index.html → true,由调用方算)
cacheBust?: () => string; // 防缓存串注入(默认 Date.now)
}
type RuntimeOptionsWithoutHost = Omit<ResolveRuntimeConfigOptions, 'hostKind'>;
export interface BootstrapResult {
mode: RuntimeMode;
isDebugger: boolean; // = (mode === 'debug')
identity: ChannelIdentity;
account?: DebugAccount; // 调试登录账号(仅 debug 模式有值;release 恒 undefined)
servers: string[];
rawConfig: RemoteConfig | null;
profile: DebugProfile;
}
/** 新调用方显式给 hostKind;isNative 分支仅保留现有 LoginFlow 的兼容入口。 */
export type BootstrapOptions = RuntimeOptionsWithoutHost & (
| { readonly hostKind: HostKind; readonly isNative?: never }
| { readonly hostKind?: never; readonly isNative: boolean }
);
/**
* 启动编排:按运行模式解析身份 + 决定服务器,产出可喂 NetClient 的结果。
* 数据源权威唯一、下游不兜底(CLAUDE.md 第二准则):服务器地址只来自 profile.server(直连)
* 或远程配置解析;取不到一律显式抛错暴露,不猜默认、不降级。
* 旧 StartupOrchestrator 的过渡外观。严格的新代码使用 RuntimeConfig;这里保留
* 可写 servers 类型以及可选的新判别字段,直到旧启动编排迁移完成。
*/
export async function resolveBootstrap(opts: BootstrapOptions): Promise<BootstrapResult> {
const isDebug = opts.mode === 'debug';
export type BootstrapResult = Omit<RuntimeConfig, 'servers' | 'source' | 'gameserver'> & {
readonly servers: string[];
readonly source?: RuntimeConfig['source'];
readonly gameserver?: RuntimeConfig['gameserver'];
readonly account?: DebugAccount;
readonly profile: Readonly<{ name: 'local' | 'prod' }>;
};
// 模式决定 profile 与身份来源:release 强制 prod,且身份只走原生(绝不读 URL)。
const profile = isDebug ? resolveActiveProfile(opts.search) : PROFILES.prod;
const identity = isDebug
? resolveIdentity([
() => BUILD_IDENTITY,
opts.isNative ? nativeSettingsSource(opts.win) : queryStringSource(opts.search),
() => profile.identity ?? {},
queryStringSource(opts.search), // 显式 URL 覆盖(最高)
])
: resolveIdentity([
() => BUILD_IDENTITY,
nativeSettingsSource(opts.win), // release:身份只取原生,URL 一律无效
]);
function bootstrapHostKind(options: BootstrapOptions): HostKind {
if (options.hostKind) return options.hostKind;
return options.isNative ? 'native-settings' : 'h5';
}
// 调试账号只在 debug 模式从 profile 取;release 不读调试配置账号。
const account = isDebug ? profile.account : undefined;
const resultBase = { mode: opts.mode, isDebugger: isDebug, identity, account, profile };
// 服务器决策:profile 显式 server 直连捷径
if (profile.server) {
return { ...resultBase, servers: [profile.server], rawConfig: null };
}
// profile 是服务器地址的权威来源:无 server 则必须有 gameserver,缺则配置错误,显式暴露。
if (!profile.gameserver) {
throw new ConfigFetchError(`profile '${profile.name}' 未配置 server 或 gameserver`);
}
const bust = opts.cacheBust ? opts.cacheBust() : String(Date.now());
const url = profile.gameserver + '?' + bust; // 防缓存,等价原 min_timestamp/ifast_random
let config: RemoteConfig;
try {
config = await opts.fetcher.fetch(url);
} catch {
throw new ConfigFetchError(`远程配置抓取失败: ${url}`);
}
const resolved = resolveServers(config, identity);
// debug 可把连接地址显式覆盖为本地测试服(仍走完整远程配置链路);release 一律用远程解析结果。
const servers = isDebug && profile.serverOverride ? [profile.serverOverride] : resolved;
if (!servers.length) {
throw new ConfigFetchError(`远程配置未给出可用服务器地址: ${url}`);
}
return { ...resultBase, servers, rawConfig: config };
/** 兼容启动编排外观;所有运行契约均由唯一的 RuntimeConfig 边界产出。 */
export async function resolveBootstrap(options: BootstrapOptions): Promise<BootstrapResult> {
const runtime = await resolveRuntimeConfig({
buildIdentity: options.buildIdentity,
startupConfig: options.startupConfig,
hostKind: bootstrapHostKind(options),
win: options.win,
search: options.search,
fetcher: options.fetcher,
localFetcher: options.localFetcher,
cacheBust: options.cacheBust,
});
return Object.freeze({
...runtime,
servers: [...runtime.servers],
profile: Object.freeze({ name: runtime.startupConfig.useLocalServer ? 'local' as const : 'prod' as const }),
});
}
@@ -14,9 +14,34 @@ function hasValue(v: unknown): boolean {
return v !== undefined && v !== null && v !== '';
}
function validateIdentity(identity: Partial<ChannelIdentity>): ChannelIdentity {
const missing = (['agentid', 'channelid', 'gameid', 'marketid', 'version'] as const)
.filter((key) => !hasValue(identity[key]));
if (missing.length > 0) {
throw new Error(`Invalid identity: missing ${missing.join(', ')}`);
}
for (const key of ['agentid', 'channelid', 'marketid'] as const) {
const value = identity[key];
if (typeof value !== 'string' && typeof value !== 'number') {
throw new Error(`Invalid identity: ${key} type`);
}
if (typeof value === 'number' && !Number.isFinite(value)) {
throw new Error(`Invalid identity: ${key} must be finite`);
}
}
if (typeof identity.gameid !== 'string') {
throw new Error('Invalid identity: gameid type');
}
if (typeof identity.version !== 'number') {
throw new Error('Invalid identity: version type');
}
if (!Number.isFinite(identity.version)) throw new Error('Invalid identity: version must be finite');
return identity as ChannelIdentity;
}
/**
* 按数组顺序合并身份,后者覆盖前者(仅覆盖有效值字段)。
* 约定首个 source 为完整 defaults,故返回视为完整 ChannelIdentity。
* 合并后逐字段校验;来源缺失必须在边界显式暴露,禁止不完整类型断言。
*/
export function resolveIdentity(sources: IdentitySource[]): ChannelIdentity {
const out: Partial<ChannelIdentity> = {};
@@ -27,5 +52,5 @@ export function resolveIdentity(sources: IdentitySource[]): ChannelIdentity {
if (hasValue(v)) (out as Record<string, unknown>)[k] = v;
}
}
return out as ChannelIdentity;
return Object.freeze(validateIdentity(out));
}
@@ -0,0 +1,102 @@
import { resolveIdentity } from './identity.ts';
import { STARTUP_CONFIG, validateStartupConfig, validateLocalServer } from './profiles.ts';
import {
resolveRuntimeConfig,
type ResolveRuntimeConfigOptions,
type RuntimeConfig,
} from './runtime-config.ts';
export interface LocalStartupContext {
readonly hostKind: 'h5';
readonly profile: 'local';
readonly config: RuntimeConfig;
}
const QUERY_CONTROLS = Object.freeze([
'gameconfig',
'agentid',
'channelid',
'marketid',
'version',
'gameid',
] as const);
function fail(field: string, reason: string): never {
throw new Error(`Invalid local startup ${field}: ${reason}`);
}
function assertLocalQuery(search: string): void {
const query = new URLSearchParams(search);
for (const field of QUERY_CONTROLS) {
if (query.getAll(field).length > 1) fail(field, 'duplicate query control');
}
const gameconfigs = query.getAll('gameconfig');
if (gameconfigs.length === 1 && gameconfigs[0] !== '') {
fail('gameconfig', 'must be empty');
}
if (query.has('gameid')) fail('gameid', 'query override is forbidden');
}
export async function resolveLocalStartup(
options: ResolveRuntimeConfigOptions,
): Promise<LocalStartupContext> {
const startupConfig = validateStartupConfig(options.startupConfig === undefined ? STARTUP_CONFIG : options.startupConfig);
if (startupConfig.mode !== 'debug') fail('mode', 'must be debug');
if (!startupConfig.useLocalServer) fail('useLocalServer', 'must be true');
if (options.hostKind !== 'h5') fail('hostKind', 'must be h5');
assertLocalQuery(options.search);
const config = await resolveRuntimeConfig({...options, startupConfig});
const context = Object.freeze({ hostKind: 'h5' as const, profile: 'local' as const, config });
assertLocalStartupContext(context);
return context;
}
export function assertLocalStartupContext(context: LocalStartupContext): void {
if (context.hostKind !== 'h5') fail('hostKind', 'must be h5');
if (context.profile !== 'local') fail('profile', 'must be local');
const { config } = context;
const startupConfig = validateStartupConfig(config.startupConfig);
if (startupConfig.mode !== 'debug') fail('mode', 'source must be debug');
if (!startupConfig.useLocalServer) fail('useLocalServer', 'source must be true');
if (config.mode !== 'debug') fail('mode', 'must be debug');
if (config.isDebugger !== true) fail('isDebugger', 'must be true');
if (config.source !== startupConfig.configSource) fail('source', 'must equal startupConfig.configSource');
const configEntry = startupConfig.configSource === 'local' ? startupConfig.localConfigResource : startupConfig.gameserver;
if (config.gameserver !== configEntry) fail('gameserver', 'must equal startupConfig selected entry');
if (!config.remoteConfig || typeof config.remoteConfig.getValue !== 'function') fail('remoteConfig', 'must provide memory view');
if (!config.rawConfig || config.rawConfig !== config.remoteConfig.raw) fail('rawConfig', 'must share remoteConfig.raw');
try {
resolveIdentity([() => config.identity]);
} catch (error) {
const reason = error instanceof Error ? error.message : 'validation failed';
const wrapped = new Error(`Invalid local startup identity: ${reason}`) as Error & {
cause?: unknown;
};
wrapped.cause = error;
throw wrapped;
}
const authoritative = startupConfig.localServers;
if (config.servers.length !== authoritative.length) {
fail('servers', 'must equal startupConfig.localServers');
}
for (let index = 0; index < authoritative.length; index += 1) {
const expected = authoritative[index];
validateLocalServer(expected, `servers[${index}]`);
if (config.servers[index] !== expected) {
fail('servers', 'must equal startupConfig.localServers');
}
}
}
export function assertLocalConnectionTarget(context: LocalStartupContext, url: string): void {
assertLocalStartupContext(context);
validateLocalServer(url, 'url');
if (!context.config.startupConfig.localServers.includes(url)) {
fail('url', 'target must occur in startupConfig.localServers');
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "1d920343-a60b-4b40-8eba-f29b4a7d53fa",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,8 +1,9 @@
import type { ChannelIdentity } from './identity.ts';
import { resolveRuntimeMode, type RuntimeMode } from './runtime-mode.ts';
/** 调试用登录玩家档案(player_login 中除渠道身份外的字段)。仅 debug 模式生效。 */
/** 调试用登录玩家档案;值只能由测试或本地启动配置显式注入。 */
export interface DebugAccount {
openid: string; // 服务器据此标识玩家(必填)
openid: string;
nickname?: string;
avatar?: string;
sex?: number;
@@ -11,39 +12,86 @@ export interface DebugAccount {
unionid?: string | number;
}
export interface DebugProfile {
name: string;
server?: string; // 显式 ws 调试服地址(给定则跳过远程抓取,纯直连)
gameserver?: string; // 远程配置 txt URL(不含防缓存串)
serverOverride?: string; // debug:抓远程配置走全链路后,把连接地址覆盖为此(联调本地游戏服用)
identity?: Partial<ChannelIdentity>; // 调试用渠道身份覆盖
account?: DebugAccount; // 调试用登录账号(仅 debug 模式生效)
isDebugger?: boolean; // 收发包日志开关(替代旧 Game_Config.Debugger.isDebugger)
export interface StartupConfig {
/** 输出全部收发包、配置文件内容及连接状态/重连原因。 */
readonly debugIO: boolean;
readonly mode: RuntimeMode;
/** 配置内容来源;local 仅允许 debug,独立于 WebSocket 服务器选择。 */
readonly configSource: 'remote' | 'local';
/** resources 下的 txt 路径,不含扩展名。 */
readonly localConfigResource: string;
readonly gameserver: string;
readonly useLocalServer: boolean;
readonly localServers: readonly string[];
}
/** 正式 gameserver:原 00_SubGame_Config.js:11。 */
export const DEFAULT_GAMESERVER = 'http://ylyxservice1.0791ts.cn/config/update_json.txt';
/** 测试 gameserver:原 00_SubGame_Config.js:9 注释。 */
const STAGING_GAMESERVER = 'https://tsgames.daoqi88.cn/config_test/beta_update_jsonv2.txt';
/** 唯一发布配置入口;mode 与服务器选择互相独立。 */
export const STARTUP_CONFIG: Readonly<StartupConfig> = Object.freeze({
debugIO: true,
mode: 'debug',
// 开发时改为 local,编辑 assets/resources/dev-config/update_jsonv2.txt 后重新预览。
configSource: 'local',
localConfigResource: 'dev-config/update_jsonv2',
gameserver: 'https://tsgames.daoqi88.cn/config_test/update_jsonv2.txt',
useLocalServer: true,
localServers: Object.freeze(['ws://127.0.0.1:3088']),
});
export const PROFILES: Record<string, DebugProfile> = {
prod: { name: 'prod', gameserver: DEFAULT_GAMESERVER, isDebugger: false },
staging: {
name: 'staging',
gameserver: STAGING_GAMESERVER,
serverOverride: 'ws://127.0.0.1:3088', // 走 staging 配置全链路,但游戏服连本地测试服
isDebugger: true,
// 调试登录账号:联调前替换为真实测试账号(openid 必填)。仅 debug 模式生效。
account: { openid: 'REPLACE_ME_test_openid', nickname: '测试号', avatar: 'https://gips3.baidu.com/it/u=4028841722,3177937756&fm=3074&app=3074&f=PNG?w=2048&h=2048', sex: 0, province: 'testprovince', city: 'testcity', unionid: 'test_unionid' },
},
local: { name: 'local', server: 'ws://127.0.0.1:3088', isDebugger: true },
};
function invalid(field: string, reason: string): never {
throw new Error('Invalid startup config ' + field + ': ' + reason);
}
/** 发布只需保持 'prod'。 */
export const ACTIVE_PROFILE = 'prod';
export function validateLocalServer(value: string, field = 'localServers'): void {
if (typeof value !== 'string' || value.trim() !== value) invalid(field, 'must be an absolute URL');
let url: URL;
try { url = new URL(value); } catch { invalid(field, 'must be an absolute URL'); }
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') invalid(field, 'protocol must be ws: or wss:');
if (url.username || url.password || value.includes('#')) invalid(field, 'credentials and fragments are forbidden');
const host = url.hostname.toLowerCase();
if (host !== 'localhost' && host !== '[::1]' && !/^127(?:\.[0-9]{1,3}){3}$/.test(host)) {
invalid(field, 'hostname must be loopback');
}
}
/** 解析当前 profile:ACTIVE_PROFILE 为基线,?profile=<name> 临时切换;未知名回退 ACTIVE_PROFILE。 */
export function resolveActiveProfile(search: string): DebugProfile {
const name = new URLSearchParams(search).get('profile') || ACTIVE_PROFILE;
return PROFILES[name] ?? PROFILES[ACTIVE_PROFILE];
/** 验证后复制为不可变快照,异步抓取期间调用方不能改变配置。 */
export function validateStartupConfig(source: Readonly<StartupConfig>): Readonly<StartupConfig> {
if (!source || typeof source !== 'object') invalid('source', 'must be an object');
const mode = resolveRuntimeMode(source.mode);
if (typeof source.debugIO !== 'boolean') invalid('debugIO', 'must be boolean');
if (source.configSource !== 'remote' && source.configSource !== 'local') invalid('configSource', 'must be remote or local');
if (source.configSource === 'local' && mode !== 'debug') invalid('configSource', 'local is only allowed in debug mode');
if (typeof source.localConfigResource !== 'string' || !/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(source.localConfigResource)) {
invalid('localConfigResource', 'must be a resources path without extension or traversal');
}
if (typeof source.useLocalServer !== 'boolean') invalid('useLocalServer', 'must be boolean');
if (typeof source.gameserver !== 'string' || source.gameserver.trim() !== source.gameserver) invalid('gameserver', 'must be http(s) URL');
let url: URL;
try { url = new URL(source.gameserver); } catch { invalid('gameserver', 'must be http(s) URL'); }
if (url.protocol !== 'http:' && url.protocol !== 'https:') invalid('gameserver', 'must be http(s) URL');
if (!Array.isArray(source.localServers) || source.localServers.length === 0) invalid('localServers', 'must be a nonempty array');
for (let index = 0; index < source.localServers.length; index++) validateLocalServer(source.localServers[index], 'localServers[' + index + ']');
return Object.freeze({mode, debugIO:source.debugIO, configSource:source.configSource, localConfigResource:source.localConfigResource, gameserver:source.gameserver, useLocalServer:source.useLocalServer, localServers:Object.freeze([...source.localServers])});
}
/** 忠实旧 min_replaceAll 链:- → /,# → :,再包成 http://...txt。 */
export function decodeGameConfig(value: string): string {
if (!value) throw new Error('gameconfig 必须是非空字符串');
const decoded = value.split('-').join('/').split('#').join(':');
return `http://${decoded}.txt`;
}
export interface ResolveGameServerOptions {
readonly configured: string;
readonly injectedGameConfig: unknown;
}
/** 注入的 gameconfig 非空时是唯一覆盖源;否则只使用 启动配置源。 */
export function resolveGameServer(options: ResolveGameServerOptions): string {
const injected = options.injectedGameConfig;
if (injected === '' || injected === undefined || injected === null) {
if (!options.configured) throw new Error('启动配置未配置 gameserver');
return options.configured;
}
if (typeof injected !== 'string') throw new Error('gameconfig 必须是字符串');
return decodeGameConfig(injected);
}
@@ -1,14 +1,26 @@
import type { ConfigFetcher, RemoteConfig } from './remote-config.ts';
import { ConfigParseError } from './remote-config.ts';
import type { ConfigFetcher } from './remote-config.ts';
import { STARTUP_CONFIG } from './profiles.ts';
import { ConfigFetchError, ConfigParseError } from './remote-config.ts';
/** 生产用 ConfigFetcher:全局 fetch 抓取远程配置 txt 并 JSON.parse。 */
type FetchImplementation = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/** 生产 fetcher:逐字复刻 get_config 的 POST、空 body、无条件 ? cache bust。 */
export class HttpConfigFetcher implements ConfigFetcher {
async fetch(url: string): Promise<RemoteConfig> {
const resp = await fetch(url, { method: 'GET' });
const text = await resp.text();
constructor(private readonly fetchImpl: FetchImplementation = globalThis.fetch.bind(globalThis)) {}
async fetch(gameserver: string, cacheBust: () => string = () => String(Date.now())): Promise<unknown> {
const url = `${gameserver}?${cacheBust()}`;
const response = await this.fetchImpl(url, { method: 'POST', body: '' });
if (response.status !== 200) {
throw new ConfigFetchError(`远程配置 HTTP 状态错误: status ${response.status}, url: ${url}`);
}
const text = await response.text();
try {
return JSON.parse(text) as RemoteConfig;
const data: unknown = JSON.parse(text);
if (STARTUP_CONFIG.debugIO) console.log('[Config REMOTE]', url, data);
return data;
} catch {
if (STARTUP_CONFIG.debugIO) console.log('[Config REMOTE]', url, { parseError: 'Invalid JSON', raw: text });
throw new ConfigParseError(`远程配置 JSON 解析失败: ${url}`);
}
}
@@ -1,71 +1,127 @@
import type { ChannelIdentity } from './identity.ts';
import { DEFAULT_LAUNCH_WAIT_TIME_MS } from './sources/defaults.ts';
/** 远程配置内层数据(键名逐字一致原 get_paravalue / getConfig_Succ)。 */
export interface MarketEntry { marketid: string | number; [k: string]: unknown; }
export interface ChannelEntry { channelid: string | number; marketlist?: MarketEntry[]; [k: string]: unknown; }
export interface GameEntry { gameid: string | number; channellist?: ChannelEntry[]; [k: string]: unknown; }
export interface AgentEntry { agentid: string | number; gamelist?: GameEntry[]; [k: string]: unknown; }
export interface RemoteConfigData { agentlist?: AgentEntry[]; [k: string]: unknown; }
export interface RemoteConfig { data?: RemoteConfigData; }
/** One in-memory view over the JSON root received by legacy getConfig_Succ. */
export interface RemoteConfig {
readonly raw: Readonly<Record<string, unknown>>;
readonly getValue: (name: string) => unknown;
}
/** 远程配置抓取抽象(生产注入 HttpConfigFetcher,测试注入 fake)。 */
export interface ConfigFetcher { fetch(url: string): Promise<RemoteConfig>; }
export interface ConfigFetcher {
fetch(gameserver: string, cacheBust?: () => string): Promise<unknown>;
}
/** Legacy initial value -> hall_config merge -> scoped top-level override. */
export function resolveLaunchWaitTime(remote: RemoteConfig): number {
let value: unknown = DEFAULT_LAUNCH_WAIT_TIME_MS;
const hall = remote.getValue('hall_config');
if (typeof hall === 'object' && hall !== null && Object.prototype.hasOwnProperty.call(hall, 'launchWaitTime')) {
value = (hall as Record<string, unknown>).launchWaitTime;
}
const override = remote.getValue('launchWaitTime');
if (override !== null && override !== undefined) value = override;
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw new Error('Invalid launchWaitTime: expected finite nonnegative milliseconds');
}
return value;
}
export class ConfigFetchError extends Error {}
export class ConfigParseError extends Error {}
/**
* 分层取参,忠实原 get_paravalue:顶层 data[key] 为默认,逐层进入匹配项;
* 某层有该 key(truthy)则更新返回值;匹配链中断则返回当前最具体值。
*/
export function getParam(config: RemoteConfig, key: string, id: ChannelIdentity): unknown {
const data = config.data;
if (!data) return null;
let value: unknown = data[key] ? data[key] : null;
const SCOPES = [
['agentlist', 'agentid'], ['gamelist', 'gameid'],
['channellist', 'channelid'], ['marketlist', 'marketid'],
] as const;
const descend = <T extends Record<string, unknown>>(
list: T[] | undefined, idKey: string, idVal: unknown,
): T | null => {
if (!list) return null;
for (const item of list) {
if (item[idKey] == idVal) { // 宽松比较,id 可能 string/number
if (item[key]) value = item[key];
return item;
type ConfigObject = Record<string, unknown>;
function requireObject(value: unknown, path: string): ConfigObject {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new ConfigParseError(`Invalid remote config ${path}: expected object`);
}
return value as ConfigObject;
}
function validateHierarchy(scope: ConfigObject, depth: number): void {
if (depth === SCOPES.length) return;
const [listName] = SCOPES[depth];
if (!Object.prototype.hasOwnProperty.call(scope, listName)) return;
const list = scope[listName];
if (!Array.isArray(list)) throw new ConfigParseError(`Invalid remote config ${listName}: expected array`);
for (const entry of list) validateHierarchy(requireObject(entry, listName), depth + 1);
}
/**
* Port of Game_Surface_3/12_Logic.js get_paravalue. Only GameData globals are
* replaced with the resolved identity argument; lookup order and truthiness stay exact.
* createRemoteConfig validates the downloaded JSON before exposing this reader.
*/
export function get_paravalue(
o_config: { readonly data?: Readonly<ConfigObject> | null },
paraname: string,
identity: Readonly<ChannelIdentity>,
): unknown {
const {agentid, gameid, channelid, marketid} = identity;
let paravalue: unknown = null;
const do_get_para = (arylist: unknown, keyname: string, keyvalue: string | number): ConfigObject | null => {
if (arylist && keyname && keyvalue) {
const list = arylist as ConfigObject[];
for (let i = 0; i < list.length; i++) {
if (list[i][keyname] == keyvalue) {
if (list[i][paraname]) paravalue = list[i][paraname];
return list[i];
}
}
}
return null;
};
const agent = descend(data.agentlist, 'agentid', id.agentid);
if (!agent) return value;
const game = descend(agent.gamelist, 'gameid', id.gameid);
if (!game) return value;
const channel = descend(game.channellist, 'channelid', id.channelid);
if (!channel) return value;
descend(channel.marketlist, 'marketid', id.marketid);
return value;
if (!o_config.data) return paravalue;
if (o_config.data[paraname]) paravalue = o_config.data[paraname];
const _o_agent = do_get_para(o_config.data.agentlist, 'agentid', agentid);
if (!_o_agent) return paravalue;
const _o_game = do_get_para(_o_agent.gamelist, 'gameid', gameid);
if (!_o_game) return paravalue;
const _o_channel = do_get_para(_o_game.channellist, 'channelid', channelid);
if (!_o_channel) return paravalue;
const _o_market = do_get_para(_o_channel.marketlist, 'marketid', marketid);
if (!_o_market) return paravalue;
return paravalue;
}
/**
* 连接相关参数键(键名逐字一致原 getConfig_Succ)。优先级即数组顺序。
* 仅取 *_server_tcp:YouleNexus 为 WebSocket-only(原 netType==0 分支);
* 原工程的 *_server_http 属 HTTP 传输模式(netType==1),本期不在范围,故有意省略。
*/
const SERVER_KEYS = ['player_server_tcp', 'visitor_server_tcp', 'game_server_tcp'] as const;
function toWs(addr: string): string {
return (addr.startsWith('ws://') || addr.startsWith('wss://')) ? addr : 'ws://' + addr;
/** All consumers share get_paravalue; no separate hierarchy or override rules. */
export function createRemoteConfig(input: unknown, identity: Readonly<ChannelIdentity>): RemoteConfig {
const raw = requireObject(input, 'root');
validateHierarchy(raw, 0);
const wrapper = {data: raw};
return Object.freeze({
raw,
getValue(name: string): unknown {
return get_paravalue(wrapper, name, identity);
},
});
}
/** 解析 ws 候选服务器列表(按 SERVER_KEYS 优先级,去重)。 */
export function resolveServers(config: RemoteConfig, id: ChannelIdentity): string[] {
const out: string[] = [];
for (const key of SERVER_KEYS) {
const v = getParam(config, key, id);
if (typeof v === 'string' && v) {
const ws = toWs(v);
if (!out.includes(ws)) out.push(ws);
}
function normalizeWebSocketUrl(value: string): string {
const url = value.startsWith('ws://') || value.startsWith('wss://') ? value : `ws://${value}`;
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value) && !/^wss?:\/\//.test(value)) {
throw new ConfigParseError('Invalid remote server: unsupported scheme');
}
return out;
try {
const parsed = new URL(url);
if (!value || /\s/.test(value) || url.includes('#') || !parsed.hostname || parsed.username || parsed.password
|| (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:')) throw new Error('invalid');
} catch {
throw new ConfigParseError('Invalid remote server: malformed WebSocket URL');
}
return url;
}
/** Legacy getConfig_Succ selects game_server_tcp when truthy, otherwise player_server_tcp. */
export function parseUrlServers(config: RemoteConfig): readonly string[] {
const raw = config.getValue('game_server_tcp') || config.getValue('player_server_tcp');
const values = typeof raw === 'string' ? [raw] : raw;
if (!Array.isArray(values) || values.length === 0 || values.some(value => typeof value !== 'string' || !value)) {
throw new ConfigParseError('Remote game_server_tcp/player_server_tcp must be a nonempty string or string array');
}
return Object.freeze(values.map(normalizeWebSocketUrl));
}
@@ -0,0 +1,2 @@
/** Explicit client lifecycle budget; no protocol fields or game rules are inferred. */
export const ROOM_LIFECYCLE_CONFIG=Object.freeze({capacity:256,maxWaitMs:30000});
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "57882ffe-3e00-46d9-ab47-362e6dd4467d",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,112 @@
import type { ChannelIdentity } from './identity.ts';
import { resolveIdentity } from './identity.ts';
import {
STARTUP_CONFIG,
validateStartupConfig,
resolveGameServer,
type StartupConfig,
} from './profiles.ts';
import {
ConfigFetchError,
ConfigParseError,
createRemoteConfig,
parseUrlServers,
type ConfigFetcher,
type RemoteConfig,
} from './remote-config.ts';
import type { RuntimeMode } from './runtime-mode.ts';
import {
getothername,
nativeIdentitySource,
type NativeHost,
type NativeHostKind,
} from './sources/native-settings.ts';
import { queryStringSource } from './sources/query-string.ts';
export type HostKind = 'h5' | NativeHostKind;
export interface RuntimeConfig {
readonly startupConfig: Readonly<StartupConfig>;
readonly mode: RuntimeMode;
readonly isDebugger: boolean;
readonly identity: Readonly<ChannelIdentity>;
readonly servers: readonly string[];
readonly source: 'remote' | 'local';
/** 实际读取入口:remote 为最终 URL,local 为 resources 资源路径。 */
readonly gameserver: string;
readonly rawConfig: RemoteConfig['raw'];
readonly remoteConfig: RemoteConfig;
}
export interface ResolveRuntimeConfigOptions {
readonly buildIdentity: Readonly<ChannelIdentity>;
readonly startupConfig?: Readonly<StartupConfig>;
readonly hostKind: HostKind;
readonly win: NativeHost;
readonly search: string;
readonly fetcher: ConfigFetcher;
/** 由引擎层注入项目 txt 读取器;仅 local 模式需要,缺失直接报错。 */
readonly localFetcher?: ConfigFetcher;
readonly cacheBust?: () => string;
}
function resolveHostIdentity(options: ResolveRuntimeConfigOptions): Readonly<ChannelIdentity> {
if (!options.buildIdentity || typeof options.buildIdentity !== 'object') throw Error('Missing buildIdentity from application composition');
const buildIdentity = resolveIdentity([() => options.buildIdentity]);
if (options.hostKind === 'h5') {
return resolveIdentity([() => buildIdentity, queryStringSource(options.search)]);
}
return resolveIdentity([
() => ({gameid:buildIdentity.gameid,version:buildIdentity.version}),
nativeIdentitySource(options.win, options.hostKind),
]);
}
function resolveInjectedGameConfig(options: ResolveRuntimeConfigOptions): unknown {
if (options.hostKind === 'h5') {
return new URLSearchParams(options.search).get('gameconfig') ?? '';
}
return getothername(options.win, options.hostKind, 'gameconfig');
}
function freezeResult(result: RuntimeConfig): RuntimeConfig {
return Object.freeze(result);
}
export async function resolveRuntimeConfig(
options: ResolveRuntimeConfigOptions,
): Promise<RuntimeConfig> {
const startupConfig = validateStartupConfig(options.startupConfig === undefined ? STARTUP_CONFIG : options.startupConfig);
const identity = resolveHostIdentity(options);
// 先选择唯一来源;本地模式不读取或应用宿主的远程 gameconfig 覆盖。
const local = startupConfig.configSource === 'local';
const gameserver = local ? startupConfig.localConfigResource : resolveGameServer({
configured: startupConfig.gameserver,
injectedGameConfig: resolveInjectedGameConfig(options),
});
const fetcher = local ? options.localFetcher : options.fetcher;
if (!fetcher) throw new ConfigFetchError('本地配置缺少 localFetcher');
let raw: unknown;
try {
raw = await fetcher.fetch(gameserver, options.cacheBust);
} catch (error) {
if (error instanceof ConfigFetchError || error instanceof ConfigParseError) throw error;
throw new ConfigFetchError(`${local ? '本地配置读取失败' : '远程配置抓取失败'}: ${gameserver}`);
}
const remoteConfig = createRemoteConfig(raw, identity);
const servers = startupConfig.useLocalServer ? startupConfig.localServers : parseUrlServers(remoteConfig);
return freezeResult({
startupConfig,
mode: startupConfig.mode,
isDebugger: startupConfig.mode === 'debug',
identity,
servers,
source: startupConfig.configSource,
gameserver,
rawConfig: remoteConfig.raw,
remoteConfig,
});
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4e90f0c8-b271-40a4-9909-68aa8f7d5974",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,20 +1,7 @@
export type RuntimeMode = 'debug' | 'release';
/**
* 手动覆盖运行模式。
* null = 自动跟随构建类型(推荐,发布前保持 null)。
* 'debug' / 'release' = 强制该模式(release 包里临时调试,或编辑器预览里验证正式行为)。
*/
export const MODE_OVERRIDE: RuntimeMode | null = null;
/**
* 解析运行模式:override 优先,否则由是否 debug 构建决定。
* isDebugBuild 由调用方读 Cocos 全局 DEBUG 传入(typeof DEBUG !== 'undefined' ? DEBUG : true)。
*/
export function resolveRuntimeMode(
isDebugBuild: boolean,
override: RuntimeMode | null = MODE_OVERRIDE,
): RuntimeMode {
if (override) return override;
return isDebugBuild ? 'debug' : 'release';
/** 应用模式仅来自显式配置,不读取 URL 或 Creator 编译标志。 */
export function resolveRuntimeMode(mode: RuntimeMode): RuntimeMode {
if (mode !== 'debug' && mode !== 'release') throw new Error('Invalid startup config mode: must be debug or release');
return mode;
}
@@ -1,13 +1,2 @@
import type { ChannelIdentity } from '../identity.ts';
/**
* 构建期默认渠道身份(旧 version.js 等价物)。
* gameid 每子游戏固定写死;agentid/channelid/marketid/version 为默认,运行期由 query/native/profile 覆盖。
*/
export const BUILD_IDENTITY: ChannelIdentity = {
agentid: 'veRa0qrBf0df2K1G4de2tgfmVxB2jxpv',
channelid: 'FtJf073aa0d6rI1xD8J1Y42fINTm0ziK',
gameid: 'jzWe0kzmN0tAcZ5MLks3rFWcxNecogyu',
marketid: 4,
version: 10000, // 数字 versionCode(联调时改成本地服要求的版本号)
};
/** Legacy launchWaitTime, in milliseconds. */
export const DEFAULT_LAUNCH_WAIT_TIME_MS = 10;
@@ -1,40 +1,70 @@
import type { ChannelIdentity, IdentitySource } from '../identity.ts';
import type { IdentitySource } from '../identity.ts';
import { logNativeCall } from '../../core/native-call-log.ts';
/** 原生注入宿主(≈ window)。settings 由原生注入;全局 app_* 为 uAgent_3 路径回退。 */
export type NativeHostKind = 'native-settings' | 'uAgent_3';
/** window.settings 与 app_* 均由原生注入,名称不得改写。 */
export interface NativeHost {
settings?: {
getothername?(name: string): string | number;
getchannelName?(): string | number;
getmarketname?(): string | number;
};
[global: string]: unknown; // app_agent / app_market 等
[global: string]: unknown;
}
function tryRead(primary: () => unknown, fallback: () => unknown): unknown {
function hasValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== '';
}
/** 忠实 Func.getothername:非 uAgent_3 的调用异常在源边界变成空串。 */
export function getothername(win: NativeHost, hostKind: NativeHostKind, name: string): unknown {
if (hostKind === 'uAgent_3') return win[`app_${name}`];
logNativeCall('settings','getothername',[name],!!win.settings?.getothername);
try {
const v = primary();
if (v !== undefined && v !== null && v !== '') return v;
} catch { /* 原生桥不存在,走回退 */ }
try { return fallback(); } catch { return undefined; }
return win.settings!.getothername!(name);
} catch {
return '';
}
}
function has(v: unknown): boolean { return v !== undefined && v !== null && v !== ''; }
/** 原生 getchannelName 的既有缺省语义只存在于此适配器。 */
export function getchannelName(win: NativeHost, hostKind: NativeHostKind): unknown {
logNativeCall('settings','getchannelName',[],!!win.settings?.getchannelName);
try {
return win.settings!.getchannelName!() || '';
} catch {
return hostKind === 'uAgent_3' ? win.app_channel : '';
}
}
/** 原生 getmarketname 的既有缺省语义只存在于此适配器。 */
export function getmarketname(win: NativeHost, hostKind: NativeHostKind): unknown {
logNativeCall('settings','getmarketname',[],!!win.settings?.getmarketname);
try {
return win.settings!.getmarketname!();
} catch {
return hostKind === 'uAgent_3' ? win.app_market : '4';
}
}
/**
* 原生模式:同步读取渠道身份。逐字对齐原 05_Func.js:
* agent → window.settings.getothername('agent') | window.app_agent
* channel → window.settings.getchannelName()
* market → window.settings.getmarketname() | window.app_market
* 只产出宿主负责的 agent/channel/market;game/version 由构建身份源负责。
* uAgent_3 全部走 app_<name>,不触碰 settings。
*/
export function nativeSettingsSource(win: NativeHost): IdentitySource {
export function nativeIdentitySource(win: NativeHost, hostKind: NativeHostKind): IdentitySource {
return () => {
const out: Partial<ChannelIdentity> = {};
const agentid = tryRead(() => win.settings?.getothername?.('agent'), () => win['app_agent']);
if (has(agentid)) out.agentid = agentid as string | number;
const channelid = tryRead(() => win.settings?.getchannelName?.(), () => undefined);
if (has(channelid)) out.channelid = channelid as string | number;
const marketid = tryRead(() => win.settings?.getmarketname?.(), () => win['app_market']);
if (has(marketid)) out.marketid = marketid as string | number;
const agentid = getothername(win, hostKind, 'agent');
const channelid = hostKind === 'uAgent_3'
? getothername(win, hostKind, 'channel')
: getchannelName(win, hostKind);
const marketid = hostKind === 'uAgent_3'
? getothername(win, hostKind, 'market')
: getmarketname(win, hostKind);
const out: Record<string, unknown> = {};
if (hasValue(agentid)) out.agentid = agentid;
if (hasValue(channelid)) out.channelid = channelid;
if (hasValue(marketid)) out.marketid = marketid;
return out;
};
}
@@ -0,0 +1,4 @@
/** Keep the full argument list (including undefined and callbacks) visible in browser devtools. */
export function logNativeCall(channel:string,method:string,args:readonly unknown[],available:boolean):void {
console.info('[NativeCall]',{channel,method,args:[...args],available});
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "0fa0b492-08ce-49dc-9e19-ef0a57a49889",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,2 @@
/** Fields consumed from the legacy get_player_task response. */
export interface TaskData {readonly taskid:number|string;readonly title:string;readonly award:number;readonly finish:number;readonly total:number;readonly state:number;}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "f8664184-a773-44c3-85cd-dacfef19adbc",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,14 @@
import type {Roomtype} from '../../sdk/contracts/roomtype.ts';
export type VipCode = string | number;
/** min_ary_indexof in the legacy source compares short codes with ==. */
export const sameVipCode=(left:VipCode,right:VipCode):boolean=>left==right;
export interface VipRoom {
readonly shortCode: VipCode;
readonly title: string;
readonly owner: string;
readonly roomcodes: { readonly roomtype: Roomtype; readonly roomlist: readonly VipCode[] } | null;
readonly playerList: readonly (readonly unknown[])[];
readonly deskState: readonly unknown[];
readonly seatCount: readonly VipCode[];
readonly forbidSelect: boolean;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "3bbb5831-3303-4a17-accf-4cd18dae1797",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,4 +1,24 @@
import type { Transport } from './transport.ts';
import { STARTUP_CONFIG } from '../config/profiles.ts';
function logConnection(state:string,ws:WebSocket,detail:Record<string,unknown>={}):void{
if(STARTUP_CONFIG.debugIO)console.log(`[WebSocket] ${state}`,{state,url:ws.url,readyState:ws.readyState,time:new Date().toISOString(),...detail});
}
/** 日志解析不改变传输内容;非法 JSON 保留原始帧供协议层报错。 */
function logFrame(tag: string, url: string, frame: unknown): void {
if (!STARTUP_CONFIG.debugIO) return;
let data = frame;
if (typeof frame === 'string') {
try { data = JSON.parse(frame); }
catch { data = { parseError: 'Invalid JSON', raw: frame }; }
}
const packet = data !== null && typeof data === 'object' ? data as Record<string, unknown> : null;
const route = packet && typeof packet.route === 'string' ? packet.route : '';
const rpc = packet && typeof packet.rpc === 'string' ? packet.rpc : '';
const com = packet && typeof packet.com === 'string' ? packet.com : '';
// console.log(`${tag}${route || rpc ? ` ${route}/${rpc}` : com ? ` ${com}` : ''}`, url, data);
}
/** 生产用 Transport:包装运行时全局 WebSocket(Cocos 原生/浏览器)。docs/protocol/01 §2。 */
export class CocosWebSocketTransport implements Transport {
@@ -10,13 +30,21 @@ export class CocosWebSocketTransport implements Transport {
connect(url: string): void {
const ws = new WebSocket(url); // url 形如 ws://ip:port
this.ws = ws;
ws.onopen = () => this.openCb?.();
ws.onmessage = (ev: MessageEvent) => this.msgCb?.(typeof ev.data === 'string' ? ev.data : String(ev.data));
ws.onclose = () => this.closeCb?.();
ws.onerror = () => { try { ws.close(); } catch { /* ignore */ } };
logConnection('连接中',ws);
ws.onopen = () => {logConnection('已连接',ws);this.openCb?.();};
ws.onmessage = (ev: MessageEvent) => {
logFrame('[收包 S→C]', url, ev.data);
this.msgCb?.(typeof ev.data === 'string' ? ev.data : String(ev.data));
};
ws.onclose = (event:CloseEvent) => {logConnection('已断开',ws,{code:event.code,reason:event.reason,wasClean:event.wasClean});this.closeCb?.();};
ws.onerror = (event:Event) => {logConnection('连接错误',ws,{eventType:event.type});try { ws.close(); } catch { /* ignore */ } };
}
send(frame: string): void { this.ws?.send(frame); }
close(): void { try { this.ws?.close(); } catch { /* ignore */ } }
send(frame: string): void {
if (!this.ws) return;
this.ws.send(frame);
logFrame('[发包 C→S]', this.ws.url, frame);
}
close(): void { if(this.ws)logConnection('客户端请求关闭',this.ws);try { this.ws?.close(); } catch { /* ignore */ } }
onOpen(cb: () => void): void { this.openCb = cb; }
onMessage(cb: (frame: string) => void): void { this.msgCb = cb; }
onClose(cb: () => void): void { this.closeCb = cb; }
@@ -0,0 +1,542 @@
import {
APP,
RECONNECT_INTERVAL_MS,
RECV_TIMEOUT_MS,
SERVER_ROTATE_EVERY,
} from '../core/constants.ts';
import type { InboundMessage, OutboundEnvelope } from '../core/types/envelope.ts';
import { decodeFrame } from './envelope-codec.ts';
import { HeartbeatWatchdog, realClock, type Clock } from './heartbeat.ts';
import { ReconnectPolicy } from './reconnect.ts';
import type { Transport } from './transport.ts';
import { STARTUP_CONFIG } from '../config/profiles.ts';
export type WireEvent =
| { readonly type: 'open'; readonly server: string }
| { readonly type: 'message'; readonly message: InboundMessage }
| { readonly type: 'slow' }
| { readonly type: 'reconnecting'; readonly server: string }
| { readonly type: 'close' };
export interface WireClientOptions {
readonly servers: readonly string[];
readonly transportFactory: () => Transport;
readonly clock?: Clock;
}
type WireListener = (event: WireEvent) => void;
function assertSwitchTarget(value: string): void {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error('switchServer requires a valid WebSocket URL');
}
if ((parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') || parsed.host.length === 0) {
throw new Error('switchServer requires a valid WebSocket URL');
}
}
function invalidEnvelope(path: string, reason: string): never {
throw new Error(`invalid outbound envelope at ${path}: ${reason}`);
}
function childPath(parent: string, key: string): string {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
? `${parent}.${key}`
: `${parent}[${JSON.stringify(key)}]`;
}
interface CapturedProperty {
readonly key: string | symbol;
readonly descriptor: PropertyDescriptor;
}
interface CapturedNode {
readonly source: object;
readonly path: string;
readonly prototype: object | null;
readonly properties: readonly CapturedProperty[];
}
function propertyPath(parent: string, key: string | symbol): string {
return typeof key === 'string' ? childPath(parent, key) : parent;
}
function assertNoInheritedToJson(prototype: object | null, path: string): void {
const visited = new Set<object>();
let current = prototype;
while (current !== null) {
if (visited.has(current)) invalidEnvelope(path, 'prototype cycle is not allowed');
visited.add(current);
if (Object.getOwnPropertyDescriptor(current, 'toJSON')) {
invalidEnvelope(path, 'inherited toJSON is not allowed');
}
current = Object.getPrototypeOf(current);
}
}
function copyDescriptor(descriptor: PropertyDescriptor): PropertyDescriptor {
if (Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
return {
configurable: descriptor.configurable,
enumerable: descriptor.enumerable,
value: descriptor.value,
writable: descriptor.writable,
};
}
return {
configurable: descriptor.configurable,
enumerable: descriptor.enumerable,
get: descriptor.get,
set: descriptor.set,
};
}
function captureNode(source: object, path: string, audits: CapturedNode[]): CapturedNode {
const prototype = Object.getPrototypeOf(source);
assertNoInheritedToJson(prototype, path);
const keys = Reflect.ownKeys(source);
const properties = keys.map((key): CapturedProperty => {
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!descriptor) invalidEnvelope(propertyPath(path, key), 'own property disappeared during validation');
return { key, descriptor: copyDescriptor(descriptor) };
});
const captured = { source, path, prototype, properties };
audits.push(captured);
return captured;
}
function capturedDataValue(
captured: CapturedNode,
key: string,
path: string,
requireEnumerable: boolean,
): unknown {
const property = captured.properties.find((candidate) => candidate.key === key);
if (!property) return invalidEnvelope(path, 'missing own data property');
const { descriptor } = property;
if (requireEnumerable && !descriptor.enumerable) {
return invalidEnvelope(path, 'property must be enumerable');
}
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
return invalidEnvelope(path, 'accessor properties are not allowed');
}
return descriptor.value;
}
function descriptorMatches(before: PropertyDescriptor, after: PropertyDescriptor): boolean {
if (before.configurable !== after.configurable || before.enumerable !== after.enumerable) {
return false;
}
const beforeIsData = Object.prototype.hasOwnProperty.call(before, 'value');
const afterIsData = Object.prototype.hasOwnProperty.call(after, 'value');
if (beforeIsData !== afterIsData) return false;
if (beforeIsData) {
return before.writable === after.writable && Object.is(before.value, after.value);
}
return before.get === after.get && before.set === after.set;
}
function verifyStableGraph(audits: readonly CapturedNode[]): void {
for (const captured of audits) {
const prototype = Object.getPrototypeOf(captured.source);
if (!Object.is(prototype, captured.prototype)) {
invalidEnvelope(captured.path, 'prototype changed during validation');
}
assertNoInheritedToJson(prototype, captured.path);
const keys = Reflect.ownKeys(captured.source);
if (
keys.length !== captured.properties.length
|| keys.some((key, index) => !Object.is(key, captured.properties[index]!.key))
) {
invalidEnvelope(captured.path, 'own keys changed during validation');
}
for (let index = 0; index < keys.length; index++) {
const key = keys[index]!;
const descriptor = Object.getOwnPropertyDescriptor(captured.source, key);
if (!descriptor || !descriptorMatches(captured.properties[index]!.descriptor, descriptor)) {
invalidEnvelope(propertyPath(captured.path, key), 'property descriptor changed during validation');
}
}
}
}
function snapshotJsonValue(
value: unknown,
path: string,
ancestors: Set<object>,
audits: CapturedNode[],
snapshots: Map<object, unknown>,
): unknown {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number') {
if (!Number.isFinite(value)) invalidEnvelope(path, 'expected finite number');
if (Object.is(value, -0)) invalidEnvelope(path, 'negative zero is not protocol JSON data');
return value;
}
if (typeof value !== 'object') {
invalidEnvelope(path, `${typeof value} is not protocol JSON data`);
}
const objectValue = value as object;
if (ancestors.has(objectValue)) invalidEnvelope(path, 'cycle is not protocol JSON data');
if (snapshots.has(objectValue)) return snapshots.get(objectValue);
ancestors.add(objectValue);
try {
const captured = captureNode(objectValue, path, audits);
if (Array.isArray(objectValue)) {
if (captured.prototype !== Array.prototype) {
invalidEnvelope(path, 'expected plain array');
}
const length = capturedDataValue(captured, 'length', childPath(path, 'length'), false);
if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 0) {
invalidEnvelope(childPath(path, 'length'), 'expected valid array length');
}
const allowedKeys = new Set<string>(['length']);
const snapshot: unknown[] = [];
Object.setPrototypeOf(snapshot, null);
snapshots.set(objectValue, snapshot);
for (let index = 0; index < (length as number); index++) {
const key = String(index);
const itemPath = `${path}[${index}]`;
allowedKeys.add(key);
if (!captured.properties.some((property) => property.key === key)) {
invalidEnvelope(itemPath, 'array hole is not protocol JSON data');
}
const itemSnapshot = snapshotJsonValue(
capturedDataValue(captured, key, itemPath, false),
itemPath,
ancestors,
audits,
snapshots,
);
Object.defineProperty(snapshot, key, {
configurable: true,
enumerable: true,
value: itemSnapshot,
writable: true,
});
}
for (const { key } of captured.properties) {
if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed');
if (!allowedKeys.has(key)) {
invalidEnvelope(childPath(path, key), 'extra array property is not allowed');
}
}
return snapshot;
}
if (captured.prototype !== Object.prototype && captured.prototype !== null) {
invalidEnvelope(path, 'expected plain object or null-prototype object');
}
const snapshot = Object.create(null) as Record<string, unknown>;
snapshots.set(objectValue, snapshot);
for (const { key } of captured.properties) {
if (typeof key === 'symbol') invalidEnvelope(path, 'symbol key is not allowed');
const nestedPath = childPath(path, key);
const nestedSnapshot = snapshotJsonValue(
capturedDataValue(captured, key, nestedPath, true),
nestedPath,
ancestors,
audits,
snapshots,
);
Object.defineProperty(snapshot, key, {
configurable: true,
enumerable: true,
value: nestedSnapshot,
writable: true,
});
}
return snapshot;
} finally {
ancestors.delete(objectValue);
}
}
function serializeOutbound(envelope: OutboundEnvelope): string {
if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) {
return invalidEnvelope('$', 'expected object');
}
const audits: CapturedNode[] = [];
const snapshots = new Map<object, unknown>();
const captured = captureNode(envelope, '$', audits);
if (captured.prototype !== Object.prototype && captured.prototype !== null) {
return invalidEnvelope('$', 'expected plain object or null-prototype object');
}
const required = new Set(['app', 'route', 'rpc', 'data']);
for (const { key } of captured.properties) {
if (typeof key === 'symbol') return invalidEnvelope('$', 'top-level symbol key is not allowed');
if (!required.has(key)) return invalidEnvelope(childPath('$', key), 'extra top-level property');
}
const app = capturedDataValue(captured, 'app', '$.app', true);
const route = capturedDataValue(captured, 'route', '$.route', true);
const rpc = capturedDataValue(captured, 'rpc', '$.rpc', true);
const data = capturedDataValue(captured, 'data', '$.data', true);
if (app !== APP) invalidEnvelope('$.app', `expected ${APP}`);
if (typeof route !== 'string' || route.length === 0) {
invalidEnvelope('$.route', 'expected non-empty string');
}
if (typeof rpc !== 'string' || rpc.length === 0) {
invalidEnvelope('$.rpc', 'expected non-empty string');
}
const snapshot = Object.create(null) as Record<string, unknown>;
snapshots.set(envelope, snapshot);
const ancestors = new Set<object>([envelope]);
let dataSnapshot: unknown;
try {
dataSnapshot = snapshotJsonValue(data, '$.data', ancestors, audits, snapshots);
} finally {
ancestors.delete(envelope);
}
const snapshotValues = new Map<string, unknown>([
['app', app],
['route', route],
['rpc', rpc],
['data', dataSnapshot],
]);
for (const { key } of captured.properties) {
Object.defineProperty(snapshot, key as string, {
configurable: true,
enumerable: true,
value: snapshotValues.get(key as string),
writable: true,
});
}
verifyStableGraph(audits);
const frame = JSON.stringify(snapshot);
if (typeof frame !== 'string') return invalidEnvelope('$', 'not JSON serializable');
return frame;
}
/**
* 只拥有 WebSocket 生命周期、帧编解码、收包看门狗和重连策略。
* 登录、踢人、切服 RPC 等业务意图均作为 message 原样交给唯一上层编排。
*/
export class WireClient {
private readonly transportFactory: () => Transport;
private readonly clock: Clock;
private readonly policy: ReconnectPolicy;
private readonly watchdog: HeartbeatWatchdog;
private readonly listeners = new Set<WireListener>();
private transport: Transport | null = null;
private reconnectTimer: unknown = null;
private reconnectTarget: string | null = null;
private reconnectToken = 0;
private generation = 0;
private started = false;
private stopped = false;
private open = false;
constructor(options: WireClientOptions) {
if (!Array.isArray(options.servers) || options.servers.length === 0) {
throw new Error('servers must be a non-empty validated list');
}
this.transportFactory = options.transportFactory;
this.clock = options.clock ?? realClock;
this.policy = new ReconnectPolicy([...options.servers], SERVER_ROTATE_EVERY);
this.watchdog = new HeartbeatWatchdog(
RECV_TIMEOUT_MS,
() => this.onReceiveTimeout(),
this.clock,
);
}
subscribe(listener: WireListener): () => void {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
}
start(): void {
if (this.started) throw new Error('WireClient already started');
if (this.stopped) throw new Error('WireClient already stopped');
this.started = true;
this.connect(this.policy.current());
}
send(envelope: OutboundEnvelope): void {
if (!this.open || this.stopped || this.transport === null) {
throw new Error('connection is not open');
}
const frame = serializeOutbound(envelope);
this.logPacket('发送', frame);
this.transport.send(frame);
}
switchServer(url: string): void {
assertSwitchTarget(url);
this.assertRunning();
this.policy.setCurrent(url);
this.disconnectAndSchedule(this.policy.current());
}
reconnectCurrent(): void {
this.assertRunning();
const server = this.policy.current();
if (this.reconnectTimer !== null && this.reconnectTarget === server) return;
this.disconnectAndSchedule(server);
}
stop(): void {
if (this.stopped) return;
this.stopped = true;
this.watchdog.stop();
this.clearReconnectTimer();
const transport = this.transport;
const hadTransport = transport !== null;
this.transport = null;
this.open = false;
this.generation++;
transport?.close();
if (hadTransport) this.emit({ type: 'close' });
}
private assertRunning(): void {
if (!this.started || this.stopped) throw new Error('WireClient is not running');
}
private emit(event: WireEvent): void {
if(STARTUP_CONFIG.debugIO&&event.type!=='message'){
const states={open:'已连接',close:'已断开',slow:'收包超时',reconnecting:'正在重连'};
console.log(`[连接状态] ${states[event.type]}`,{event:event.type,server:this.policy.current(),connected:this.open,stopped:this.stopped,
time:new Date().toISOString(),...(event.type==='slow'?{timeoutMs:RECV_TIMEOUT_MS}:{}),...(event.type==='reconnecting'?{retryAfterMs:RECONNECT_INTERVAL_MS}:{})});
}
for (const listener of [...this.listeners]) {
try {
listener(event);
} catch (error) {
console.error('[WireClient] listener error:', error, event);
}
}
}
private connect(server: string): void {
if (this.stopped) return;
const generation = ++this.generation;
this.transport = null;
this.open = false;
let transport: Transport;
try {
transport = this.transportFactory();
} catch {
this.onConnectionFailure(generation, null, false);
return;
}
this.transport = transport;
transport.onOpen(() => {
if (!this.isCurrent(generation, transport)) return;
this.open = true;
this.emit({ type: 'open', server });
});
transport.onMessage((frame) => {
if (!this.isCurrent(generation, transport) || !this.open) return;
this.onMessage(frame);
});
transport.onClose(() => {
if (!this.isCurrent(generation, transport)) return;
this.onConnectionFailure(generation, transport, false);
});
try {
transport.connect(server);
} catch {
this.onConnectionFailure(generation, transport, true);
}
}
private isCurrent(generation: number, transport: Transport): boolean {
return !this.stopped && generation === this.generation && transport === this.transport;
}
private onMessage(frame: string): void {
const decoded = decodeFrame(frame);
if (decoded.kind !== 'heartbeat') this.logPacket('接收', frame);
if (decoded.kind === 'ignore') return;
this.watchdog.feed();
if (decoded.kind !== 'message') return;
this.emit({ type: 'message', message: decoded.message });
}
private logPacket(direction: '发送' | '接收', frame: string): void {
if (STARTUP_CONFIG.debugIO) {
console.log(`[收发包] ${direction}`, {server:this.policy.current(),time:new Date().toISOString()}, frame);
}
}
private onReceiveTimeout(): void {
if (!this.open || this.stopped) return;
this.emit({ type: 'slow' });
if (!this.open || this.stopped) return;
this.disconnectAndSchedule(this.policy.onFailure());
}
private onConnectionFailure(
generation: number,
transport: Transport | null,
closeTransport: boolean,
): void {
if (this.stopped || generation !== this.generation || transport !== this.transport) return;
this.transport = null;
this.open = false;
const failureGeneration = ++this.generation;
this.watchdog.stop();
if (closeTransport && transport) transport.close();
this.emit({ type: 'close' });
if (this.stopped || failureGeneration !== this.generation) return;
this.scheduleReconnect(this.policy.onFailure());
}
private disconnectAndSchedule(server: string): void {
this.clearReconnectTimer();
const transport = this.transport;
const hadTransport = transport !== null;
this.transport = null;
this.open = false;
const disconnectGeneration = ++this.generation;
this.watchdog.stop();
transport?.close();
if (hadTransport) this.emit({ type: 'close' });
if (
this.stopped
|| disconnectGeneration !== this.generation
|| this.policy.current() !== server
) return;
this.scheduleReconnect(server);
}
private scheduleReconnect(server: string): void {
this.clearReconnectTimer();
if (this.stopped || this.policy.current() !== server) return;
const token = this.reconnectToken;
let handle: unknown = null;
handle = this.clock.setTimeout(() => {
if (
this.stopped
|| token !== this.reconnectToken
|| this.policy.current() !== server
|| this.reconnectTarget !== server
|| this.reconnectTimer !== handle
) return;
this.reconnectTimer = null;
this.reconnectTarget = null;
this.reconnectToken++;
this.connect(server);
}, RECONNECT_INTERVAL_MS);
this.reconnectTimer = handle;
this.reconnectTarget = server;
this.emit({ type: 'reconnecting', server });
}
private clearReconnectTimer(): void {
this.reconnectToken++;
if (this.reconnectTimer !== null) this.clock.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
this.reconnectTarget = null;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "613b0457-be57-44e3-a1fb-e5d086b69468",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,245 @@
import type { RuntimeConfig } from '../config/runtime-config.ts';
import type {GameRoomProjection} from '../sdk/contracts/game-room-projection.ts';
import {gameAllowsRoomAction} from '../sdk/room-action-policy.ts';
import { buildFirstSliceEnvelope } from '../protocol/first-slice-routes.ts';
import type { OutboundEnvelope } from '../core/types/envelope.ts';
import {
buildCreateRoomRequest,
type CreateRoomEnvironment,
buildExitRoomRequest,
buildJoinRoomRequest,
buildPrepareRequest,
type JoinRoomEnvironment,
type RoomIdentity,
} from '../protocol/contracts/index.ts';
import { cloneAndFreezeRoomtype, PlatformStore } from './stores/platform-store.ts';
import type { InsideRoomState, PlatformState } from './stores/platform-types.ts';
/** The complete-envelope send capability used by platform command adapters. */
export interface OutboundEnvelopeSendPort {
send(envelope: OutboundEnvelope): void;
}
export interface PlatformCommandsOptions {
/** Omitted/null explicitly means the game provides no additional restrictions. */
readonly getRoomProjection?:()=>GameRoomProjection|null;
readonly runtimeConfig: RuntimeConfig;
readonly store: PlatformStore;
readonly send: OutboundEnvelopeSendPort;
}
export interface JoinRoomCommand extends JoinRoomEnvironment {
readonly roomcode: string | number;
}
export interface CreateRoomCommand extends CreateRoomEnvironment {}
export class UnsupportedGameHostCommandError extends Error {
constructor(message: string) {
super(message);
this.name = 'UnsupportedGameHostCommandError';
}
}
/** Builds platform-owned room requests exclusively through the first-slice contracts. */
export class PlatformCommands {
constructor(private readonly options: PlatformCommandsOptions) {}
getPlayerInvitecode():void {
const playerid=this.taskPlayerId(),player=this.options.store.getState().players.entities[playerid];
const {openid,unionid}=player;
if(typeof openid!=='string'||!(typeof unionid==='string'||typeof unionid==='number'&&Number.isFinite(unionid)))throw Error('Invite query requires login openid and unionid');
this.options.send.send(buildFirstSliceEnvelope('get_player_invitecode',{
agentid:this.options.runtimeConfig.identity.agentid,playerid,unionid,openid,
}));
}
bindInvitecode(invitecode:string):void {
const playerid=this.taskPlayerId();
if(typeof invitecode!=='string')throw Error('invitecode must be a string');
this.options.send.send(buildFirstSliceEnvelope('binding_invitecode',{
agentid:this.options.runtimeConfig.identity.agentid,playerid,invitecode,
}));
}
getVipRooms(shortcodeList:readonly (string|number)[]):void {
this.taskPlayerId();
const {agentid,gameid}=this.options.runtimeConfig.identity;
this.options.send.send(buildFirstSliceEnvelope('getInfoByShortCode',{agentid,gameid,shortcodeList:[...shortcodeList]}));
}
joinVipRoom(roomcode:string|number,location:unknown):void {
const playerid=this.taskPlayerId(),state=this.options.store.getState();
const ip=state.players.entities[playerid].ip;
if(typeof ip!=='string')throw Error('VIP join requires player login ip');
const {agentid,gameid}=this.options.runtimeConfig.identity;
this.options.send.send(buildFirstSliceEnvelope('self_join_room',{agentid,playerid,gameid,roomcode,joinmode:0,location,ip}));
}
getPlayerTasks(): void {
const playerid=this.taskPlayerId();
const {agentid,gameid}=this.options.runtimeConfig.identity;
this.options.send.send(buildFirstSliceEnvelope('get_player_task',{agentid,playerid,gameid}));
}
getVipRankList(limit:number):void {
this.taskPlayerId();if(!Number.isInteger(limit)||limit<=0)throw Error('Invalid vipRankLimit');
this.options.send.send(buildFirstSliceEnvelope('getVipRankList',{agentid:this.options.runtimeConfig.identity.agentid,limit}));
}
joinOwnedRoom(roomcode:number,location:unknown):void {
const playerid=this.taskPlayerId(),state=this.options.store.getState();
if(!Number.isSafeInteger(roomcode)||roomcode<=0)throw Error('Invalid owned roomcode');
const ip=state.players.entities[playerid].ip;if(typeof ip!=='string')throw Error('Owned room join requires player login ip');
const {agentid,gameid}=this.options.runtimeConfig.identity;
this.options.send.send(buildFirstSliceEnvelope('self_join_room',{agentid,playerid,gameid,roomcode,joinmode:1,location,ip}));
}
getTreasureList():void {
this.taskPlayerId();const {agentid,gameid}=this.options.runtimeConfig.identity;
this.options.send.send(buildFirstSliceEnvelope('get_treasurelist',{agentid,gameid}));
}
redeemCard(cardno:string):void {
const playerid=this.taskPlayerId();
if(typeof cardno!=='string'||cardno===''||/^[ ]+$/.test(cardno))throw Error('Invalid cardno');
this.options.send.send(buildFirstSliceEnvelope('topup_card',{agentid:this.options.runtimeConfig.identity.agentid,playerid,cardno}));
}
claimTask(taskid:number|string):void {
const playerid=this.taskPlayerId();
const task=this.options.store.getState().tasks?.find(task=>String(task.taskid)===String(taskid));
if(!task||task.state!==1)throw Error('Task is not claimable');
// 11_GameUI claim click sends these three fields; Net._SendData adds none.
this.options.send.send(buildFirstSliceEnvelope('get_task_award',{
agentid:this.options.runtimeConfig.identity.agentid,playerid,taskid:task.taskid,
}));
}
private taskPlayerId():number {
const state=this.options.store.getState();
if(state.app.phase!=='logged-in'||state.room.kind!=='outside')throw Error('Task command requires logged-in lobby');
return this.requireAuthenticatedPlayerId(state);
}
prepare(): void {
const state = this.options.store.getState();
this.requireInsideRoom(state);
this.requireGameAction('prepare');
this.options.send.send(buildPrepareRequest(
this.options.runtimeConfig,
this.roomIdentity(state, this.requireInsideRoom(state)),
));
}
sendRoomText(text:string,announcement=false):void {
if(typeof text!=='string'||text.length===0)throw Error('room text must be nonempty');
this.sendSocial('send_text',{text,type:announcement?1:0});
}
sendRoomPhrase(text:string):void {
if(typeof text!=='string'||text.length===0)throw Error('room phrase must be nonempty');
this.sendSocial('send_text',{text});
}
sendRoomVoice(voiceurl:string,time:string|number):void {
if(typeof voiceurl!=='string'||voiceurl.length===0||!(typeof time==='number'||typeof time==='string'&&/^[0-9]+(?:\.[0-9]+)?$/.test(time))||!Number.isFinite(Number(time))||Number(time)<=0)throw Error('room voice requires uploaded URL and positive duration');
this.sendSocial('send_voice',{voiceurl,time});
}
sendRoomExpression(id:number):void {
if(!Number.isSafeInteger(id)||id<=0)throw Error('room expression requires positive ID');
this.sendSocial('send_phiz',{text:id});
}
breakRoom():void {
const room=this.requireInsideRoom(this.options.store.getState());
if(room.stage!==0||room.status!==1)throw Error('breakRoom requires pregame owner');
this.sendSocial('self_break_room',{});
}
surrenderRoom(count:number):void {
if(!Number.isFinite(count)||count<=0)throw Error('surrender count must be positive');
this.sendSocial('beanroom_surrender',{count});
}
changeRoom():void {this.sendSocial('change_room',{});}
private sendSocial(rpc:'send_text'|'send_voice'|'send_phiz'|'self_break_room'|'change_room'|'beanroom_surrender',data:Readonly<Record<string,unknown>>):void {
const state=this.options.store.getState(),room=this.requireInsideRoom(state);
if(state.app.phase!=='logged-in')throw Error('room command requires logged-in connection');
const {agentid,gameid}=this.options.runtimeConfig.identity;
this.options.send.send(buildFirstSliceEnvelope(rpc,{agentid,gameid,...this.roomIdentity(state,room),...data}));
}
applyDissolution():void {this.sendDissolution('self_apply_free_room');}
agreeDissolution():void {this.sendDissolution('self_agree_free_room');}
refuseDissolution():void {this.sendDissolution('self_refuse_free_room');}
private sendDissolution(rpc:'self_apply_free_room'|'self_agree_free_room'|'self_refuse_free_room'):void {
const state=this.options.store.getState(),room=this.requireInsideRoom(state);
if(state.app.phase!=='logged-in')throw Error('dissolution requires logged-in connection');
if(rpc==='self_apply_free_room'){
if(room.stage!==1)throw Error('dissolution application requires stage=1');
if(state.dissolution)throw Error('dissolution vote or result is already present');
this.requireGameAction('exit');
}else{
const vote=state.dissolution;
if(vote?.kind!=='active'||vote.participants.find(player=>player.seat===room.selfSeat)?.state!==-1)throw Error('dissolution response requires own waiting vote');
}
const {agentid,gameid}=this.options.runtimeConfig.identity;
this.options.send.send(buildFirstSliceEnvelope(rpc,{agentid,gameid,...this.roomIdentity(state,room)}));
}
exitRoom(): void {
const state = this.options.store.getState();
const room = this.requireInsideRoom(state);
if (room.stage !== 0 && room.infinite !== 1) {
throw new UnsupportedGameHostCommandError('room.exit requires stage=0 unless infinite=1');
}
this.requireGameAction('exit');
this.options.send.send(buildExitRoomRequest(
this.options.runtimeConfig,
this.roomIdentity(state, room),
));
}
createRoom(command: CreateRoomCommand): void {
const playerid = this.taskPlayerId();
const roomtype = cloneAndFreezeRoomtype(command.roomtype);
this.options.send.send(buildCreateRoomRequest(this.options.runtimeConfig, playerid, {...command, roomtype}));
}
joinRoom(command: JoinRoomCommand): void {
const state = this.options.store.getState();
if (state.room.kind !== 'outside') {
throw new UnsupportedGameHostCommandError('room.join requires player to be outside a room');
}
const playerid = this.requireAuthenticatedPlayerId(state);
this.options.send.send(buildJoinRoomRequest(
this.options.runtimeConfig,
{ playerid, roomcode: command.roomcode },
command,
));
}
private requireInsideRoom(state: PlatformState): InsideRoomState {
if (state.room.kind !== 'inside') {
throw new UnsupportedGameHostCommandError('room command requires player to be inside a room');
}
return state.room;
}
private requireGameAction(action:'prepare'|'exit'):void {
const projection=this.options.getRoomProjection===undefined?null:this.options.getRoomProjection();
if(!gameAllowsRoomAction(true,projection,action))throw new UnsupportedGameHostCommandError(`Game currently disallows room.${action}`);
}
private requireAuthenticatedPlayerId(state: PlatformState): number {
const playerid = state.players.selfPlayerId;
if (playerid === null) {
throw new UnsupportedGameHostCommandError('room command requires an authenticated player');
}
if (state.players.entities[playerid] === undefined) {
throw new Error('players.entities: missing authenticated player');
}
return playerid;
}
private roomIdentity(state: PlatformState, room: InsideRoomState): RoomIdentity {
return {
playerid: this.requireAuthenticatedPlayerId(state),
roomcode: room.roomcode,
};
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4098ba4c-c444-459f-9427-0412f1a15bb6",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,13 @@
import type { OutboundEnvelope } from '../core/types/envelope.ts';
export type OutboundMessage = OutboundEnvelope;
export type ConnectionIntent =
| { readonly type: 'none' }
| { readonly type: 'login'; readonly envelope: OutboundMessage }
| { readonly type: 'switch'; readonly target: string; readonly resend: OutboundMessage };
export interface ConnectionControlPort {
switchServer(target: string, resend: OutboundMessage): void;
stopForKick(data: unknown): void;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "1a6fd8e5-5d0c-4fbb-a035-ef3a4f90badd",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,452 @@
import type {NumericToolsPort,RoomCapabilities,InputResult,InputCancelReason,FeedbackOwnerPort,OwnerHandle} from '../sdk/contracts/game-capabilities.ts';
export type {NumericToolsPort} from '../sdk/contracts/game-capabilities.ts';
import {roomExitAction} from '../sdk/room-exit-action.ts';
import {requireRoomtype} from '../protocol/contracts/validation.ts';
import type {Roomtype} from '../sdk/contracts/roomtype.ts';
import { APP } from '../core/constants.ts';
import type { OutboundEnvelope } from '../core/types/envelope.ts';
import type {
GameEntry,
GameHost,
GameHostCommand,
GameSeatMapper,
PlatformGameSnapshot,
} from '../sdk/contracts/index.ts';
import { assertGameRoute } from '../sdk/runtime/game-session-host.ts';
import {
PlatformCommands,
UnsupportedGameHostCommandError,
type OutboundEnvelopeSendPort,
} from './commands.ts';
import { PlatformStore } from './stores/platform-store.ts';
import { selectGameSnapshot } from './stores/selectors.ts';
import type { PlatformState } from './stores/platform-types.ts';
export class GameSessionDisposedError extends Error {
constructor() {
super('GameHost session lease is disposed');
this.name = 'GameSessionDisposedError';
}
}
export interface CreateGameHostAdapterOptions {
readonly numericTools?:NumericToolsPort;
readonly feedback?:FeedbackOwnerPort;
readonly gameEntry: GameEntry;
readonly store: PlatformStore;
readonly commands: PlatformCommands;
readonly send: OutboundEnvelopeSendPort;
}
/** Structurally matches RuntimeSession's per-open GameHostLeasePort. */
export interface GameHostLease {
readonly host: GameHost;
invalidate(): void;
}
interface SubscriptionRecord {
active: boolean;
unsubscribeStore: (() => void) | null;
}
type SubscriptionReleaseResult =
| { readonly threw: false }
| { readonly threw: true; readonly error: unknown };
interface BoundRoomGeneration {
readonly roomcode: string;
readonly roomtype: Roomtype;
readonly selfSeat: number;
readonly seatCount: number;
}
class GameHostInvalidationError extends Error {
readonly errors: readonly unknown[];
constructor(errors: readonly unknown[]) {
super('GameHost subscription cleanup failed');
this.name = 'GameHostInvalidationError';
this.errors = Object.freeze([...errors]);
}
}
class SessionBoundGameHost {
readonly host: GameHost;
private store: PlatformStore | null;
private commands: PlatformCommands | null;
private sendPort: OutboundEnvelopeSendPort | null;
private route: string | null;
private roomGeneration: BoundRoomGeneration | null;
private readonly subscriptions = new Set<SubscriptionRecord>();
private invalidationErrors: unknown[] | null = null;
private disposed = false;
private numericTools:NumericToolsPort|null;
private feedback:FeedbackOwnerPort|null;
private numericToolsUsed=false;
private inputGeneration=0;
private multiplierGeneration=0;
private readonly cleanups=new Set<()=>void>();
private readonly pendingInputs=new Map<'input'|'multiplier',{cancel(reason:InputCancelReason):void}>();
constructor(options: CreateGameHostAdapterOptions) {
this.numericTools=options.numericTools??null;this.feedback=options.feedback??null;
const route = options.gameEntry.route;
assertGameRoute(route);
const state = options.store.getState();
if (state.room.kind !== 'inside') {
throw new Error('GameHost requires player to be inside a room');
}
const seatCount = options.gameEntry.resolveSeatCount(state.room.roomtype);
requireSeatCount(seatCount, state.room.seatPlayerIds.length);
const selfSeat = requireSeat(state.room.selfSeat, seatCount, 'self seat');
this.store = options.store;
this.commands = options.commands;
this.sendPort = options.send;
this.route = route;
this.roomGeneration = {
roomcode: state.room.roomcode,
roomtype: state.room.roomtype,
selfSeat,
seatCount,
};
const seat: GameSeatMapper = Object.freeze({
toView: (serverSeat: number): number => {
this.requireCurrentRoom();
const validated = requireSeat(serverSeat, seatCount, 'server seat');
return (validated - selfSeat + seatCount) % seatCount;
},
toServer: (viewSeat: number): number => {
this.requireCurrentRoom();
const validated = requireSeat(viewSeat, seatCount, 'view seat');
return (validated + selfSeat) % seatCount;
},
});
const capabilities:RoomCapabilities=Object.freeze({
room:Object.freeze({seat,getSnapshot:()=>this.getSnapshot(),subscribe:(listener:(snapshot:PlatformGameSnapshot)=>void)=>this.subscribe(listener),
prepare:()=>this.execute({type:'room.prepare'}),requestExit:()=>this.execute({type:roomExitAction(this.getSnapshot().room).command}),
applyDissolution:()=>this.execute({type:'room.apply-dissolution'})}),
messages:Object.freeze({send:(rpc:string,data:import('../sdk/contracts/roomtype.ts').JsonValue)=>{this.requireCurrentRoom();requireRoomtype(data,'$.game.data');this.sendGameMessage(rpc,data);}}),
ui:Object.freeze({beginLoading:()=>this.ownFeedback(port=>port.beginLoading()),showBusinessTip:(message:string,time:number)=>this.ownFeedback(port=>port.showBusinessTip(message,time)),requestNumber:(request:{message:string})=>this.requestNumber<number>(request.message,false),
requestDigitText:(request:{message:string})=>this.requestNumber<string>(request.message,true),
requestMultiplier:(request:{values:readonly number[]})=>this.requestMultiplier(request.values),
cancelNumber:()=>this.execute({type:'ui.close-number-input'}),cancelMultiplier:()=>this.execute({type:'ui.hide-multiplier'})}),
scope:Object.freeze({get active(){return owner.isActiveRoom();},defer:(cleanup:()=>void)=>this.defer(cleanup)}),
});
const owner=this;
this.host = Object.freeze({
capabilities,
seat,
getSnapshot: (): PlatformGameSnapshot => this.getSnapshot(),
subscribe: (listener: (snapshot: PlatformGameSnapshot) => void): (() => void) => (
this.subscribe(listener)
),
sendGameMessage: (rpc: string, data: unknown): void => this.sendGameMessage(rpc, data),
execute: (command: GameHostCommand): void => this.execute(command),
});
}
invalidate(): void {
if (this.disposed) return;
this.disposed = true;
const subscriptions = [...this.subscriptions];
this.subscriptions.clear();
this.store = null;
this.commands = null;
this.sendPort = null;
this.route = null;
this.feedback=null;
this.roomGeneration = null;
const errors: unknown[] = [];
for(const pending of this.pendingInputs.values())pending.cancel('scope-ended');
this.pendingInputs.clear();
for(const cleanup of [...this.cleanups].reverse()){this.cleanups.delete(cleanup);try{cleanup();}catch(error){errors.push(error);}}
const numericTools=this.numericTools;
this.numericTools=null;
this.inputGeneration++;this.multiplierGeneration++;
if(this.numericToolsUsed&&numericTools!==null){
try{numericTools.close();}catch(error){errors.push(error);}
}
this.invalidationErrors = errors;
try {
for (const record of subscriptions) {
const result = this.releaseSubscription(record);
if (result.threw) errors.push(result.error);
}
} finally {
this.invalidationErrors = null;
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new GameHostInvalidationError(errors);
}
private getSnapshot(): PlatformGameSnapshot {
return selectGameSnapshot(this.requireCurrentRoom());
}
private subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void {
this.requireActive();
if (typeof listener !== 'function') {
throw new TypeError('GameHost subscribe listener must be a function');
}
const store = this.requireStore();
let current = selectGameSnapshot(this.requireCurrentRoom());
const record: SubscriptionRecord = { active: true, unsubscribeStore: null };
const bridge = (state: PlatformState): void => {
if (this.disposed || !record.active || !this.isCurrentRoom(state)) return;
const next = selectGameSnapshot(state);
if (samePublicSnapshot(current, next)) return;
current = next;
listener(next);
};
record.unsubscribeStore = store.subscribe(bridge);
this.subscriptions.add(record);
try {
listener(current);
} catch (error) {
this.releaseSubscription(record);
throw error;
}
return (): void => {
try {
const result = this.releaseSubscription(record);
if (result.threw && this.invalidationErrors !== null) {
this.invalidationErrors.push(result.error);
}
} catch {
// The public unsubscribe contract remains non-throwing, including after invalidation.
}
};
}
private sendGameMessage(rpc: string, data: unknown): void {
this.requireCurrentRoom();
if (typeof rpc !== 'string' || rpc.trim() === '') {
throw new TypeError('GameHost rpc must be a non-empty string');
}
const sendPort = this.sendPort;
const route = this.route;
if (sendPort === null || route === null) throw new GameSessionDisposedError();
const envelope: OutboundEnvelope = { app: APP, route, rpc, data };
sendPort.send(envelope);
}
private execute(command: GameHostCommand): void {
this.requireCurrentRoom();
const commands = this.commands;
if (commands === null) throw new GameSessionDisposedError();
if (command !== null && typeof command === 'object') {
if(command.type==='ui.open-number-input'||command.type==='ui.close-number-input'
||command.type==='ui.show-multiplier'||command.type==='ui.hide-multiplier'){
const tools=this.numericTools;
if(tools===null)throw new UnsupportedGameHostCommandError('Numeric tools presentation is unavailable');
this.numericToolsUsed=true;
if(command.type==='ui.close-number-input'){this.cancelInput('input','user');this.inputGeneration++;tools.closeNumberInput();return;}
if(command.type==='ui.hide-multiplier'){this.cancelInput('multiplier','user');this.multiplierGeneration++;tools.hideMultiplier();return;}
if(typeof command.onConfirm!=='function')throw TypeError('Numeric tools require a confirmation callback');
const current=():boolean=>!this.disposed&&this.store!==null&&this.isCurrentRoom(this.store.getState());
if(command.type==='ui.open-number-input'){
this.cancelInput('input','replaced');
const generation=++this.inputGeneration;
tools.openNumberInput({message:command.message,begin0:command.begin0,onConfirm:value=>{
if(current()&&generation===this.inputGeneration)command.onConfirm(value);
}});
}else{
this.cancelInput('multiplier','replaced');
const generation=++this.multiplierGeneration;
tools.showMultiplier({values:[...command.values],onConfirm:values=>{
if(current()&&generation===this.multiplierGeneration)command.onConfirm(values);
}});
}
return;
}
if (command.type === 'room.prepare') {
commands.prepare();
return;
}
if(command.type==='room.apply-dissolution'){
commands.applyDissolution();
return;
}
if (command.type === 'room.exit') {
commands.exitRoom();
return;
}
}
throw new UnsupportedGameHostCommandError('unsupported GameHost command');
}
private ownFeedback(acquire:(port:FeedbackOwnerPort)=>OwnerHandle):OwnerHandle {
this.requireCurrentRoom();const port=this.feedback;if(!port)throw new UnsupportedGameHostCommandError('Owned feedback presentation is unavailable');
const handle=acquire(port);if(!handle||typeof handle.close!=='function')throw TypeError('Feedback must return an owner handle');
if(!this.isActiveRoom()){handle.close();throw new GameSessionDisposedError();}
let closed=false;let unregister=()=>{};
const owned=Object.freeze({close:()=>{if(closed)return;closed=true;unregister();handle.close();}});
unregister=this.defer(()=>owned.close());return owned;
}
private isActiveRoom():boolean {return !this.disposed&&this.store!==null&&this.isCurrentRoom(this.store.getState());}
private defer(cleanup:()=>void):()=>void {
this.requireCurrentRoom();if(typeof cleanup!=='function')throw TypeError('Scope cleanup must be a function');
const registered=()=>cleanup();this.cleanups.add(registered);return ()=>{this.cleanups.delete(registered);};
}
private cancelInput(kind:'input'|'multiplier',reason:InputCancelReason):void {this.pendingInputs.get(kind)?.cancel(reason);}
private requestNumber<T extends number|string>(message:string,text:boolean):Promise<InputResult<T>> {
return this.requestInput<T>('input',(tools,confirm,cancel,fail)=>{
if(typeof message!=='string')throw TypeError('Number input message must be a string');
tools.openNumberInput({message,begin0:text,onCancel:cancel,onConfirm:value=>{
if(typeof value!==(text?'string':'number')||(typeof value==='number'&&!Number.isFinite(value))){fail(TypeError('Number input returned the wrong result type'));return;}
confirm(value as T);
}});
});
}
private requestMultiplier(values:readonly number[]):Promise<InputResult<readonly number[]>> {
return this.requestInput<readonly number[]>('multiplier',(tools,confirm,cancel,fail)=>{
if(!Array.isArray(values)||!values.every(Number.isFinite))throw TypeError('Multiplier requires finite values');
tools.showMultiplier({values:Object.freeze([...values]),onCancel:cancel,onConfirm:result=>{
if(!Array.isArray(result)||!result.every(Number.isFinite)){fail(TypeError('Multiplier returned non-finite values'));return;}
confirm(Object.freeze([...result]));
}});
});
}
private requestInput<T>(kind:'input'|'multiplier',open:(tools:NumericToolsPort,confirm:(value:T)=>void,cancel:()=>void,fail:(error:unknown)=>void)=>void):Promise<InputResult<T>> {
return new Promise((resolve,reject)=>{
try{
this.requireCurrentRoom();
if(this.pendingInputs.has(kind))throw Error('An input request of this kind is already pending');
const tools=this.numericTools;if(tools===null)throw new UnsupportedGameHostCommandError('Numeric tools presentation is unavailable');
this.numericToolsUsed=true;
if(kind==='input')this.inputGeneration++;else this.multiplierGeneration++;
let settled=false;
const finish=(result:InputResult<T>):void=>{if(settled)return;settled=true;this.pendingInputs.delete(kind);resolve(Object.freeze(result));};
this.pendingInputs.set(kind,{cancel:reason=>finish({kind:'cancelled',reason})});
const fail=(error:unknown):void=>{
if(settled)return;settled=true;this.pendingInputs.delete(kind);
try{if(kind==='input')tools.closeNumberInput();else tools.hideMultiplier();}
catch(cleanup){reject(Object.assign(new Error('Input request and cleanup failed'),{errors:Object.freeze([error,cleanup])}));return;}
reject(error);
};
try{open(tools,value=>{
if(settled)return;
if(!this.isActiveRoom()){finish({kind:'cancelled',reason:'scope-ended'});return;}
settled=true;this.pendingInputs.delete(kind);
try{if(kind==='input')tools.closeNumberInput();else tools.hideMultiplier();resolve(Object.freeze({kind:'confirmed',value}));}catch(error){reject(error);}
},()=>finish({kind:'cancelled',reason:'user'}),fail);}
catch(error){fail(error);}
}catch(error){reject(error);}
});
}
private requireStore(): PlatformStore {
this.requireActive();
const store = this.store;
if (store === null) throw new GameSessionDisposedError();
return store;
}
private requireCurrentRoom(): PlatformState {
const state = this.requireStore().getState();
if (!this.isCurrentRoom(state)) throw new GameSessionDisposedError();
return state;
}
private isCurrentRoom(state: PlatformState): boolean {
const generation = this.roomGeneration;
return generation !== null
&& state.room.kind === 'inside'
&& state.room.roomcode === generation.roomcode
&& state.room.roomtype === generation.roomtype
&& state.room.selfSeat === generation.selfSeat
&& state.room.seatPlayerIds.length === generation.seatCount;
}
private requireActive(): void {
if (this.disposed) throw new GameSessionDisposedError();
}
private releaseSubscription(record: SubscriptionRecord): SubscriptionReleaseResult {
if (!record.active) return { threw: false };
record.active = false;
this.subscriptions.delete(record);
const unsubscribe = record.unsubscribeStore;
record.unsubscribeStore = null;
if (unsubscribe === null) return { threw: false };
try {
unsubscribe();
return { threw: false };
} catch (error) {
return { threw: true, error };
}
}
}
export function createGameHostAdapter(options: CreateGameHostAdapterOptions): GameHostLease {
const session = new SessionBoundGameHost(options);
return Object.freeze({
host: session.host,
invalidate: (): void => { session.invalidate(); },
});
}
function requireSeatCount(seatCount: unknown, roomSeatCount: number): asserts seatCount is number {
if (!Number.isInteger(seatCount) || (seatCount as number) <= 0) {
throw new RangeError(`GameEntry seat count must be a positive integer: ${String(seatCount)}`);
}
if (seatCount !== roomSeatCount) {
throw new RangeError(
`GameEntry seat count ${seatCount} does not match room seat count ${roomSeatCount}`,
);
}
}
function requireSeat(value: unknown, seatCount: number, label: string): number {
if (!Number.isInteger(value)) {
throw new TypeError(`${label}: expected integer seat, received ${String(value)}`);
}
const seat = value as number;
if (seat < 0 || seat >= seatCount) {
throw new RangeError(`${label}: expected seat range 0..${seatCount - 1}, received ${seat}`);
}
return seat;
}
function samePublicSnapshot(first: PlatformGameSnapshot, second: PlatformGameSnapshot): boolean {
if (first === second) return true;
if (
first.connection.phase !== second.connection.phase
|| first.self.playerId !== second.self.playerId
|| first.self.seat !== second.self.seat
|| first.room.roomcode !== second.room.roomcode
|| first.room.roomtype !== second.room.roomtype
|| first.room.stage !== second.room.stage
|| first.room.needprepare !== second.room.needprepare
|| first.room.infinite !== second.room.infinite
|| first.seats.length !== second.seats.length
) return false;
for (let index = 0; index < first.seats.length; index++) {
const left = first.seats[index]!;
const right = second.seats[index]!;
if (
left.seat !== right.seat
|| left.playerId !== right.playerId
|| left.nickname !== right.nickname
|| left.avatar !== right.avatar
|| left.online !== right.online
|| left.ready !== right.ready
) return false;
}
return true;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "92bf4247-2b4b-460d-8992-8922baf78df2",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,25 @@
export type ReadyGateName =
| 'resources'
| 'config'
| 'socket'
| 'minimum-display';
const REQUIRED_GATES: readonly ReadyGateName[] = Object.freeze([
'resources',
'config',
'socket',
'minimum-display',
]);
/** One-way startup latch: readiness is reached only after all four signals arrive. */
export class ReadyGate {
private readonly marked = new Set<ReadyGateName>();
get ready(): boolean {
return REQUIRED_GATES.every((name) => this.marked.has(name));
}
mark(name: ReadyGateName): void {
this.marked.add(name);
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "ac23132b-03ac-47c4-b6f8-93602c94f080",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,50 @@
import type { EventBus } from '../core/events.ts';
import type { NetClientEvents } from '../net/net-client.ts';
import type { InboundMessage } from '../core/types/envelope.ts';
import { roomHandlers } from '../protocol/room-handlers.ts';
import type { PlatformSession } from './session.ts';
/**
* RoomRPCBus:把 NetClient 的 'message' 事件按 route 分发到 room handlers。
*
* 监听 NetClient 'message' → 过滤 route==='room' → 查 roomHandlers[rpc](ctx, data)。
*
* 业务侧在 PlatformSession 构造后立即创建 RoomRPCBus 并 start(),
* 后续 stop() 退订(进/退房切换清理)。
*/
export class RoomRPCBus {
private handler: ((msg: InboundMessage) => void) | null = null;
constructor(
private readonly bus: EventBus<NetClientEvents>,
private readonly session: PlatformSession,
) {}
/** 开始监听 NetClient 消息事件 */
start(): void {
if (this.handler) return; // 幂等
this.handler = (msg) => this.dispatch(msg);
this.bus.on('message', this.handler);
}
/** 停止监听(切房/退出时调用) */
stop(): void {
if (this.handler) { this.bus.off('message', this.handler); this.handler = null; }
}
private dispatch(msg: InboundMessage): void {
if (msg.route !== 'room') return; // 平台三层 + 对局 route 由 Router 单独处理
const handler = (roomHandlers as unknown as Record<string, ((ctx: any, data: any) => void) | undefined>)[msg.rpc];
if (!handler) {
// 未注册 rpc:roomHandlers 的 Object.freeze 已确保此处为 undefined
// 不静默吞包:抛错便于真机端到端发现协议漂移
throw new Error(
`RoomRPCBus: 未注册的 room rpc "${msg.rpc}"(roomHandlers 不覆盖)`,
);
}
handler(
{ room: this.session.room, player: this.session.player },
msg.data as Record<string, unknown>,
);
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "b2f4361c-64f4-4a29-a5db-a4bd45b42f84",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "366456c6-a70b-47c0-8ad8-ffe01393a51e",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,99 @@
import {RoomScope,type RoomDisposer} from './room-scope.ts';
import {RoomMailbox,assertRoomMailboxOptions,type RoomMailboxOptions} from './room-mailbox.ts';
export type RoomCoordinatorState='outside'|'entering'|'active'|'failed';
export type RoomMountResult=void|RoomDisposer;
export interface RoomEntryOptions<T> {
readonly invalidate:RoomDisposer;
readonly mount:(scope:RoomScope)=>RoomMountResult|PromiseLike<RoomMountResult>;
readonly ready:()=>void;
readonly handle:(message:T)=>void;
}
export class RoomCoordinatorFailure extends Error {
readonly errors:readonly unknown[];
constructor(readonly generation:number,errors:readonly unknown[]){
super(`Room generation ${generation} failed`);this.name='RoomCoordinatorFailure';this.errors=Object.freeze([...errors]);
}
}
export interface RoomCoordinatorOptions extends RoomMailboxOptions {
readonly onFailure:(failure:RoomCoordinatorFailure)=>void;
}
interface Session<T>{scope:RoomScope;mailbox:RoomMailbox<T>;entry:RoomEntryOptions<T>;draining:boolean;}
/** Serializes room input across async scene mounting and revokes stale generations immediately. */
export class RoomCoordinator<T> {
private current:Session<T>|null=null;
private nextGeneration=0;
private status:RoomCoordinatorState='outside';
private lastFailure:RoomCoordinatorFailure|null=null;
private lastDiagnostic:RoomCoordinatorFailure|null=null;
constructor(private readonly options:RoomCoordinatorOptions){assertRoomMailboxOptions(options);if(typeof options.onFailure!=='function')throw Error('Room coordinator requires a diagnostic failure handler');}
get state():RoomCoordinatorState{return this.status;}
get scope():RoomScope|null{return this.current?.scope??null;}
get generation():number{return this.nextGeneration;}
get failure():RoomCoordinatorFailure|null{return this.lastFailure;}
get diagnosticFailure():RoomCoordinatorFailure|null{return this.lastDiagnostic;}
enter(entry:RoomEntryOptions<T>):RoomScope{
const cleanup=this.cancel();
const scope=new RoomScope(++this.nextGeneration,entry.invalidate);
const session:Session<T>={scope,entry,draining:false,mailbox:new RoomMailbox<T>(this.options,error=>this.fail(session,error))};
scope.own(()=>session.mailbox.dispose());
this.current=session;this.status='entering';this.lastFailure=null;
if(cleanup.length){this.fail(session,...cleanup);return scope;}
try{
const result=entry.mount(scope);
if(result!==undefined&&typeof result!=='function'&&typeof result.then==='function'){
void Promise.resolve(result).then(resource=>this.mounted(session,resource),error=>this.mountRejected(session,error));
}else this.mounted(session,result as RoomMountResult);
}catch(error){if(this.isCurrent(session))this.fail(session,error);else throw error;}
return scope;
}
dispatch(message:T):void{
const session=this.current;
if(!session||!session.scope.active)throw Error(`Cannot dispatch room input while ${this.status}`);
try{
session.mailbox.enqueue(message);
if(this.status==='active')this.drain(session);
}catch(error){if(this.isCurrent(session))this.fail(session,error);else throw error;}
}
/** Callers aggregate explicit cancellation cleanup errors with their existing teardown errors. */
cancel():readonly unknown[]{
const session=this.current;this.current=null;this.status='outside';
return session?.scope.dispose()??[];
}
private isCurrent(session:Session<T>):boolean{return this.current===session&&session.scope.active;}
private mounted(session:Session<T>,resource:RoomMountResult):void{
try{
if(resource!==undefined)session.scope.own(resource);
if(!this.isCurrent(session))return;
session.entry.ready();
if(!this.isCurrent(session))return;
this.status='active';this.drain(session);
}catch(error){if(this.isCurrent(session))this.fail(session,error);else this.report(new RoomCoordinatorFailure(session.scope.generation,[error]));}
}
private mountRejected(session:Session<T>,error:unknown):void{
if(this.isCurrent(session))this.fail(session,error);
// Rejected stale loads own no mount resource; their superseded scope was already released.
}
private drain(session:Session<T>):void{
if(session.draining)return;
session.draining=true;
try{session.mailbox.drain(session.entry.handle,()=>this.isCurrent(session));}
finally{session.draining=false;}
}
private fail(session:Session<T>,...errors:unknown[]):void{
if(!this.isCurrent(session))return;
this.current=null;this.status='failed';
const failure=new RoomCoordinatorFailure(session.scope.generation,[...errors,...session.scope.dispose()]);
this.lastFailure=failure;this.report(failure);
}
private report(failure:RoomCoordinatorFailure):void{
this.lastDiagnostic=failure;
try{this.options.onFailure(failure);}catch(error){
// Preserve diagnostic callback faults without creating an unhandled async rejection.
const diagnostic=new RoomCoordinatorFailure(failure.generation,[...failure.errors,error]);
this.lastDiagnostic=diagnostic;
if(this.nextGeneration===failure.generation)this.lastFailure=diagnostic;
}
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e56782fe-72cd-4748-a2b4-8907281be052",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,43 @@
export interface RoomClock {
setTimeout(callback:()=>void,milliseconds:number):unknown;
clearTimeout(handle:unknown):void;
}
export interface RoomMailboxOptions {
readonly capacity:number;
readonly maxWaitMs:number;
readonly clock:RoomClock;
}
export function assertRoomMailboxOptions(options:RoomMailboxOptions):void{
if(!Number.isSafeInteger(options.capacity)||options.capacity<1)throw Error('Room mailbox capacity must be a positive safe integer');
if(!Number.isFinite(options.maxWaitMs)||options.maxWaitMs<=0)throw Error('Room mailbox maxWaitMs must be positive and finite');
if(typeof options.clock?.setTimeout!=='function'||typeof options.clock?.clearTimeout!=='function')throw Error('Room mailbox requires a clock');
}
/** FIFO queue owned by a room scope; its first pending message starts the wait deadline. */
export class RoomMailbox<T> {
private readonly messages:T[]=[];
private timer:{handle:unknown}|null=null;
private disposed=false;
constructor(private readonly options:RoomMailboxOptions,private readonly onTimeout:(error:Error)=>void){assertRoomMailboxOptions(options);}
get size():number{return this.messages.length;}
enqueue(message:T):void{
if(this.disposed)throw Error('Room mailbox is disposed');
if(this.messages.length>=this.options.capacity)throw Error(`Room mailbox capacity exceeded (${this.options.capacity})`);
this.messages.push(message);
if(this.timer===null){this.timer={handle:this.options.clock.setTimeout(()=>{
this.timer=null;
if(!this.disposed&&this.messages.length>0)this.onTimeout(Error(`Room mailbox timed out after ${this.options.maxWaitMs} ms`));
},this.options.maxWaitMs)};}
}
drain(handle:(message:T)=>void,active:()=>boolean):void{
while(!this.disposed&&active()&&this.messages.length>0){
const message=this.messages.shift() as T;
if(this.messages.length===0)this.clearTimer();
handle(message);
}
}
dispose():void{this.disposed=true;this.messages.length=0;this.clearTimer();}
private clearTimer():void{
const timer=this.timer;this.timer=null;
if(timer!==null)this.options.clock.clearTimeout(timer.handle);
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "ca845f16-3594-4150-b744-315e56d38814",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,29 @@
export type RoomDisposer=()=>void;
/** Owns one room generation; the injected invalidator revokes the existing host lease. */
export class RoomScope {
private live=true;
private readonly disposers=new Set<RoomDisposer>();
constructor(readonly generation:number,private readonly invalidate:RoomDisposer){
if(!Number.isSafeInteger(generation)||generation<1)throw Error('Invalid room scope generation');
if(typeof invalidate!=='function')throw Error('Room scope requires a lease invalidator');
}
get active():boolean{return this.live;}
get resourceCount():number{return this.disposers.size;}
/** The returned release is idempotent and unregisters before invoking user cleanup. */
own(dispose:RoomDisposer):RoomDisposer{
if(typeof dispose!=='function')throw Error('Room scope requires a disposer');
let owned=true;
const release=()=>{if(!owned)return;owned=false;this.disposers.delete(release);dispose();};
if(this.live)this.disposers.add(release);else release();
return release;
}
dispose():readonly unknown[]{
if(!this.live)return [];
this.live=false;
const errors:unknown[]=[];
try{this.invalidate();}catch(error){errors.push(error);}
for(const release of [...this.disposers].reverse())try{release();}catch(error){errors.push(error);}
return errors;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "11761871-986b-4241-b53b-1f277f35ce1d",
"files": [],
"subMetas": {},
"userData": {}
}

Some files were not shown because too many files have changed in this diff Show More