chore(repo): stop tracking pnpm local store
This commit is contained in:
@@ -17,6 +17,12 @@ yarn-error.log*
|
||||
# ---------- Node 依赖 ----------
|
||||
**/node_modules/
|
||||
|
||||
# pnpm 本地内容寻址缓存(机器生成,可由依赖清单重建)
|
||||
/.pnpm-store/
|
||||
|
||||
# Codex / Git 隔离开发工作树(机器本地)
|
||||
/.worktrees/
|
||||
|
||||
# ---------- Cocos Creator 工程生成物 ----------
|
||||
# 由各工程自己的 .gitignore 用锚定路径负责(YouleNexus/.gitignore、templates/game-seed/.gitignore)。
|
||||
# 此处**不得**再写 **/library/ 这类无锚点模式——它会连 assets/framework 下的
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
---
|
||||
name: funplay-cocos-mcp-workflow
|
||||
description: "Edit, inspect, validate, preview, and debug Cocos Creator projects through Funplay Cocos MCP. Use when working with scenes, nodes, prefabs, assets, TypeScript, logs, screenshots, preview behavior, runtime state, or MCP connectivity."
|
||||
---
|
||||
|
||||
# Funplay Cocos MCP Workflow
|
||||
|
||||
## Instructions
|
||||
|
||||
## Operating Loop
|
||||
|
||||
1. Establish context.
|
||||
- Read `cocos://project/context` or call `get_editor_state` before assuming the project, active scene, MCP URL, selection, visible windows, or tool profile.
|
||||
- Inspect the active scene with `get_scene_info` and `get_hierarchy`; use `get_selection`, `list_scenes`, `list_assets`, or `list_prefabs` when identity or ownership is unclear.
|
||||
- Treat user-provided node and asset names as hints. Resolve the real hierarchy path, node UUID, asset UUID, or `db://assets/...` URL before editing.
|
||||
- Call `get_tool_catalog` when a required tool may be hidden by the `core`, `full`, or custom exposure profile.
|
||||
2. Choose the edit surface.
|
||||
- Edit TypeScript and ordinary project files with repository tools or the MCP file tools, then refresh the affected asset and run diagnostics.
|
||||
- Edit live scene nodes with focused scene/component tools or `execute_javascript` using `context="scene"`; save the scene when the change must persist.
|
||||
- Use `execute_javascript` with `context="editor"` for asset-db, Editor messages, project orchestration, and filesystem work that belongs in the editor process.
|
||||
- Inspect prefab ownership and references before mutation. Prefer focused prefab tools, or edit a verified linked instance and apply it back through the editor workflow.
|
||||
- Preserve an existing UI or gameplay prefab hierarchy and change only the necessary nodes, components, and serialized fields; do not rebuild the entire prefab unless explicitly requested.
|
||||
3. Execute the smallest coherent change.
|
||||
- Prefer one guarded `execute_javascript` operation for tightly related editor work, but use focused tools when they provide clearer validation or safer arguments.
|
||||
- Keep JavaScript safety checks enabled unless the code and its paths were reviewed explicitly.
|
||||
- Null-check every scene, node, component, asset, and filesystem lookup. Return concise structured before/after values, including stable UUIDs or asset URLs where useful.
|
||||
- Save or refresh only the assets and scenes intentionally changed.
|
||||
- Do not guess alternate paths, silently create replacement objects, or run self-healing fallback loops after a missing reference or unsupported editor message.
|
||||
4. Read back and validate.
|
||||
- Re-inspect the exact node, component, prefab instance, or asset after mutation; a successful command response alone is not proof of the final editor state.
|
||||
- Run `run_script_diagnostics` or `get_script_diagnostic_context` after TypeScript changes, then use `validate_scene` and project logs before claiming success.
|
||||
- For visual or runtime work, run the appropriate browser, Game View, or simulator preview and verify with runtime state, input, logs, and screenshots.
|
||||
- State exactly what was verified and what still requires a native build, device, network, store, or manual check.
|
||||
|
||||
## Scene, Prefab, and Asset Safety
|
||||
|
||||
- Do not treat Cocos `.scene`, `.prefab`, or `.meta` files as ordinary text. Prefer scene-process, prefab, and asset-db operations that preserve UUID references and editor import state.
|
||||
- If `edit_prefab_json` is used, target a verified prefab path and the smallest exact JSON path or literal replacement, then run `validate_prefab_references` and inspect the result.
|
||||
- Before structural prefab work, call `inspect_prefab`; for scene instances, call `inspect_prefab_instance` and choose deliberately between apply and revert.
|
||||
- Replacing a prefab at the same path can keep the asset UUID while changing internal object IDs and breaking serialized references, animation tracks, nested prefab links, and scene overrides.
|
||||
- Inspect dependencies with `inspect_asset_dependencies` and validate them with `validate_asset_dependencies` before and after sensitive asset changes.
|
||||
- Never copy a `.meta` file when duplicating an asset. Use `duplicate_prefab` or asset-db operations so the new asset receives its own UUID.
|
||||
|
||||
## Tool Exposure and Execution Contexts
|
||||
|
||||
- The default `core` profile exposes the main inspection, diagnostics, logs, screenshots, scene, asset, and unified JavaScript workflow.
|
||||
- The `full` profile adds focused mutation tools for nodes, components, prefabs, UI, runtime control, input simulation, files, and project preview.
|
||||
- If a named tool is unavailable under a custom profile, adapt to the exposed catalog and report the missing capability instead of pretending it ran.
|
||||
- In scene context, use the Cocos runtime and scene APIs for live hierarchy and component work. In editor context, use `Editor` APIs and messages for asset-db and extension orchestration.
|
||||
- Use `execute_scene_script` and `execute_editor_script` only as compatibility entrypoints; prefer `execute_javascript` with an explicit context for new workflows.
|
||||
|
||||
## Script and Asset Validation
|
||||
|
||||
- After external script changes, refresh the affected asset or `db://assets`, then run TypeScript no-emit diagnostics. Use diagnostic context to read focused source snippets before repairing errors.
|
||||
- Cocos import and compilation are asynchronous. After refresh, re-query diagnostics, logs, or asset info instead of assuming the first request observed the final state.
|
||||
- Read `get_recent_logs` or `search_project_logs` for import, serialization, preview, and runtime failures. Do not clear persistent project logs without explicit confirmation.
|
||||
- Use `validate_scene` as a compact final pass, not as a replacement for targeted readback of the values changed.
|
||||
|
||||
## Preview and Runtime Verification
|
||||
|
||||
- Query `get_preview_mode` before changing preview behavior. Use `run_project_preview` only when preview execution is needed and distinguish browser `localUrl` from a LAN `networkUrl`.
|
||||
- Use `get_runtime_state` for pause, frame, and time-scale state; use focused runtime or component methods only when runtime behavior must be exercised.
|
||||
- Use `capture_scene_screenshot` for scene-side composition, `capture_preview_screenshot` for game output, and `capture_editor_screenshot` for editor UI or extension panels.
|
||||
- When low-level input is needed, list editor windows first and target the preview or simulator deliberately. Prefer semantic button events when available.
|
||||
- Restore temporary runtime state such as pause or time scale before finishing unless the user explicitly wants it left changed.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- If MCP is unreachable, limit claims to safe filesystem inspection or code edits; do not claim scene, prefab, editor, preview, or runtime verification.
|
||||
- If a node lookup is ambiguous, return the matching paths and UUIDs and choose only after identifying the user-visible or prefab-owned target.
|
||||
- If editor readback and serialized text disagree, trust editor and asset-db readback first and investigate whether the wrong asset, scene instance, or stale import was inspected.
|
||||
- Fix diagnostics or new error logs caused by the change before visual or runtime validation.
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
---
|
||||
name: funplay-cocos-ui-composition
|
||||
description: "Build and revise responsive Cocos Creator UI for mobile, desktop, and web, including portrait and landscape layouts, safe areas, prefabs, Widget and Layout behavior, scrolling, text, input, animation, and performance validation."
|
||||
---
|
||||
|
||||
# Funplay Cocos UI Composition
|
||||
|
||||
## Instructions
|
||||
|
||||
## Operating Loop
|
||||
|
||||
1. Inspect before editing.
|
||||
- Confirm the active scene, Canvas, design resolution, Fit Width/Fit Height policy, target orientations, SafeArea usage, and relevant prefab asset paths.
|
||||
- Inspect the existing hierarchy, `UITransform` sizes and anchor points, `Widget` constraints and align modes, `Layout` ownership, sibling order, render order, serialized references, animation targets, and prefab instance state.
|
||||
- Treat screenshots and design coordinates as visual intent, not as permission to replace a working hierarchy.
|
||||
2. Classify each region.
|
||||
- Mark art as full-bleed or safe-area content.
|
||||
- Mark placement as edge-aligned by Widget, stretched between edges, content-sized by Layout, repeated content, scrollable content, modal, overlay, or world-space UI.
|
||||
- Decide which system owns position and size. Avoid letting Widget, Layout, animation, and manual code fight over the same property.
|
||||
3. Make the smallest coherent change.
|
||||
- Preserve the prefab root, existing children, components, names, UUID-backed references, animation tracks, nested prefab links, and scene overrides unless a specific replacement is required.
|
||||
- Modify only the necessary nodes, components, fields, and children; do not rebuild the entire prefab unless explicitly requested.
|
||||
- Prefer Cocos MCP scene, prefab, and asset-db workflows over generic text replacement for serialized assets.
|
||||
4. Read back and validate.
|
||||
- Read exact hierarchy, node UUIDs, `UITransform` sizes and anchors, Widget edges, Layout settings, sprites, labels, opacity, input blockers, event bindings, and prefab ownership back from Cocos.
|
||||
- Test layout, input, safe area, localization, animation interruption, close/reopen state, and runtime data changes.
|
||||
- Capture preview screenshots at representative aspect ratios. Use native builds on representative devices before claiming device performance or platform validation.
|
||||
|
||||
## Component Selection
|
||||
|
||||
| Component | Use it for | Configure deliberately | Avoid |
|
||||
| --- | --- | --- | --- |
|
||||
| `Canvas` | Root 2D/UI render space and design-resolution adaptation | Verify project design resolution, Fit Width/Fit Height, camera and layer behavior | Adding duplicate Canvases only to organize folders or assuming one resolution fits every aspect ratio |
|
||||
| `UITransform` | UI size, anchor point, coordinate conversion, hit testing, and render priority | Set `contentSize` and anchor point intentionally; keep scale for visual effects rather than basic layout | Treating one screenshot position as universal or changing scale when size/Widget constraints should change |
|
||||
| `Widget` | Edge, center, stretch, and parent-relative alignment | Choose top/bottom/left/right/center constraints and `ONCE`, `ON_WINDOW_RESIZE`, or `ALWAYS` based on runtime needs | Animating properties that an `ALWAYS` Widget rewrites at the end of the frame |
|
||||
| `SafeArea` | Keeping critical controls inside notches and system gesture areas | Put it on the top interaction container; allow full-bleed backgrounds outside it | Applying safe-area insets twice or placing important controls outside the safe rectangle |
|
||||
| `Layout` | Horizontal, vertical, or grid arrangement and container/child resizing | Choose type, resize mode, padding, spacing, constraint, and start axis; call `updateLayout` only when same-frame measurement is necessary | Putting Layout and Widget on the same node or manually positioning children driven by Layout |
|
||||
| `Sprite` | Icons, panels, progress fills, and sliced or tiled UI art | Use sliced frames for scalable borders, preserve SpriteFrame references, and choose fill/type intentionally | Stretching bordered art as a simple Sprite or leaving decorative nodes interactive |
|
||||
| `Label` / `RichText` | Localized text and formatted text | Set fonts, fallback coverage, alignment, wrapping, line height, overflow, and cache mode from actual update frequency | Broad `SHRINK` use on frequently changing labels or shipping without required CJK and symbol glyphs |
|
||||
| `ScrollView` + `Mask` | Content larger than a viewport | Use a dedicated view/mask and content node, enable only required axes, and configure inertia, brake, bounce, and nested input deliberately | Combining Widget and Layout on the same Content node, or creating thousands of live rows without pooling |
|
||||
| `Mask` | Rectangular, ellipse, graphics, or sprite-stencil clipping | Match the mask type to the visual requirement and keep renderer constraints in mind | Adding Sprite or Label renderers to a Mask node where Cocos requires the mask-owned Graphics/Sprite |
|
||||
| `Button`, `Toggle`, `Slider`, `EditBox` | Semantic interaction | Verify transition state, target node, event handlers, hit area, keyboard/focus behavior, and disabled state | Duplicate event bindings, tiny touch targets, or visual-only disabled states |
|
||||
| `UIOpacity` | Fading a UI subtree | Coordinate opacity with active state and input blocking | Hiding a modal visually while it still receives or blocks input |
|
||||
| `BlockInputEvents` | Preventing pointer/touch events from passing through overlays and modals | Put it on the intended blocking region and validate sibling order and active state | Assuming a visible scrim blocks gameplay input by itself |
|
||||
| `PageView` | Paged horizontal or vertical content | Recompute page and content sizes when the viewport changes and test drag thresholds | Hard-coding page positions from one device width |
|
||||
|
||||
## Design Resolution, Widget, and Safe Area
|
||||
|
||||
- Treat the design resolution as the coordinate baseline, not a physical-device whitelist. Verify the project Fit Width/Fit Height policy and any runtime `view.setDesignResolutionSize` policy before changing layout.
|
||||
- Use Widget to express attachment and stretch. Use `ON_WINDOW_RESIZE` for resizable desktop/web or large-screen layouts, and use `ALWAYS` only when continuous alignment is worth its property ownership cost.
|
||||
- When Widget owns an edge or size, change the Widget offsets or align mode rather than writing a Node position or UITransform size that will be overwritten later.
|
||||
- Keep UI scale at one for ordinary layout. Resize through `UITransform.contentSize`, Widget constraints, Layout properties, or a deliberate design-resolution policy.
|
||||
- Put decorative backgrounds outside the SafeArea interaction root so they can bleed to physical edges. Put buttons, labels, navigation, and other critical content under SafeArea.
|
||||
- SafeArea already obtains `sys.getSafeAreaRect` and adjusts through Widget. Do not add a second manual inset unless the project has a documented additional margin.
|
||||
- Re-evaluate layout on window resize, orientation change, or safe-area change when the target platform can change dimensions at runtime.
|
||||
|
||||
## Portrait and Landscape Patterns
|
||||
|
||||
- For portrait screens, organize persistent UI into Top, flexible Center, and Bottom regions. Let tall screens expand the center instead of multiplying every vertical coordinate by aspect ratio.
|
||||
- For landscape screens, use Left, Center, Right, and stable corner regions. Verify 16:9, ultrawide, 16:10, and 4:3 instead of treating landscape as one shape.
|
||||
- Separate camera/world composition from Canvas UI adaptation. A correct Widget layout does not prove the gameplay camera shows the intended world area.
|
||||
- Use alternate art or deliberate cover/crop behavior when one background cannot preserve composition across phone, tablet, and desktop aspect ratios.
|
||||
- Reposition only regions whose composition genuinely changes. Do not fork or rebuild the entire screen prefab for a few aspect-dependent offsets.
|
||||
|
||||
## Auto Layout and Dynamic Content
|
||||
|
||||
- A Layout component drives its children or container according to `ResizeMode`. Do not manually write the driven dimensions and expect them to survive the next layout update.
|
||||
- For `ResizeMode.CHILDREN`, verify the container size and the resulting child sizes. For `ResizeMode.CONTAINER`, verify anchor point and growth direction so the container expands predictably.
|
||||
- Grid Layout uses its configured cell and constraint policy. Use fixed rows or columns deliberately and do not expect heterogeneous child preferred sizes to define every cell.
|
||||
- Runtime Layout property changes normally settle on the next frame. Call `updateLayout` only when code must read the final result in the same operation.
|
||||
- Keep nested Layout chains shallow, batch data changes, pool repeated items, and avoid rebuilding a large hierarchy for every small model update.
|
||||
|
||||
## Sprites, Text, Scrolling, and Input
|
||||
|
||||
- Use sliced SpriteFrames for scalable bordered panels and buttons. Preserve caps/insets and inspect the actual SpriteFrame reference after prefab edits.
|
||||
- For Label, choose `CLAMP`, `SHRINK`, or `RESIZE_HEIGHT` from a documented overflow policy. `SHRINK` can cost more CPU when text updates; `RESIZE_HEIGHT` transfers height ownership to the label.
|
||||
- Choose Label cache mode from content behavior: avoid caching assumptions for highly dynamic text, and verify character coverage and atlas capacity for localized content.
|
||||
- Structure a ScrollView as root, masked view, and content. Enable only the needed direction, verify the content reference, and test child-button cancellation and nested scroll behavior.
|
||||
- Keep one intentional interactive target per control, bind events once, and validate the serialized target, component, handler, and custom event data.
|
||||
- Give touch controls a project-defined minimum hit area even when the visible art is smaller. Verify with preview input rather than only inspecting dimensions.
|
||||
- Synchronize modal visibility, `UIOpacity`, active state, Button interactability, and `BlockInputEvents` so hidden panels neither receive nor leak input.
|
||||
|
||||
## Animation and Prefab Safety
|
||||
|
||||
- Animate a `Visual` or `Container` child when the root is driven by Widget or Layout. Do not animate a property that layout rewrites every frame.
|
||||
- Stop or cancel an existing tween/animation before replaying it and restore deterministic position, scale, opacity, active state, and input blocking on disable or close.
|
||||
- Use unscaled scheduling or an explicit UI clock when menus and modal transitions must continue while gameplay time scale is zero.
|
||||
- Preserve existing prefab objects by default. Replacing a prefab hierarchy can break internal object IDs, animation tracks, serialized component references, nested prefabs, and scene overrides even when the asset UUID remains stable.
|
||||
- Prefer serialized references and stable semantic names over repeated `getChildByPath` lookups. If a path is required, verify it and fail clearly rather than silently creating an alternate hierarchy.
|
||||
|
||||
## Performance and Validation
|
||||
|
||||
- Profile before restructuring. Common UI costs include excessive nodes, draw-call breaks, masks/stencils, overdraw, dynamic text generation, repeated Layout work, and large unpooled lists.
|
||||
- Preserve render batching by grouping compatible sprites and materials without changing intended sibling/render order. Use dynamic atlas or static batching only after verifying asset eligibility and runtime behavior.
|
||||
- Use `get_performance_snapshot` to compare node, component, UI, depth, and memory-oriented counters before and after material changes.
|
||||
- Validate portrait at 16:9, 19.5:9 or 20:9, a cutout phone, and a portrait tablet. Validate landscape at 16:9, ultrawide, 16:10, 4:3, and both cutout sides.
|
||||
- In every profile, verify full-bleed art, safe interactive content, text overflow and glyphs, scroll bounds, modal blocking, touch targets, animation interruption, and close/reopen state.
|
||||
|
||||
## Official Cocos References
|
||||
|
||||
- [Cocos Creator 3.8 UI system](https://docs.cocos.com/creator/3.8/manual/en/ui-system/)
|
||||
- [UITransform](https://docs.cocos.com/creator/3.8/api/en/class/UITransform) and [Widget](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/widget.html)
|
||||
- [SafeArea](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/safearea.html) and [multi-resolution adaptation](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/engine/multi-resolution.html)
|
||||
- [Layout](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/layout.html) and [ScrollView](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/scrollview.html)
|
||||
- [Label](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/label.html) and [Mask](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/mask.html)
|
||||
- [Node hierarchy and UI rendering order](https://docs.cocos.com/creator/3.8/manual/en/concepts/scene/node-tree.html)
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
[InternetShortcut]
|
||||
URL=https://docs.cocos.com/creator/manual/en/scripting/setup.html#custom-script-template
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"image": {
|
||||
"type": "sprite-frame"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
|
||||
#///////////////////////////
|
||||
# Cocos Creator 3D Project
|
||||
#///////////////////////////
|
||||
/library/
|
||||
/temp/
|
||||
/local/
|
||||
/build/
|
||||
/profiles/
|
||||
/native/
|
||||
#//////////////////////////
|
||||
# NPM
|
||||
#//////////////////////////
|
||||
node_modules/
|
||||
|
||||
#//////////////////////////
|
||||
# VSCode
|
||||
#//////////////////////////
|
||||
.vscode/
|
||||
|
||||
#//////////////////////////
|
||||
# WebStorm
|
||||
#//////////////////////////
|
||||
.idea/
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "58959cf7-e02d-4a2e-848f-b45f641cb373",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# framework — 框架真源(唯一权威副本)
|
||||
|
||||
各子游戏工程的 `assets/framework` 是指向**本目录**的 junction(见 `scripts/setup-links.mjs`)。
|
||||
六层结构(依赖严格单向,详见 spec §3):core / net / protocol / platform / ui / sdk。
|
||||
|
||||
协议相关实现一律以 `docs/protocol/` 为唯一信息源(spec §0.1),不在代码或注释中内联复制协议字段。
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"ver": "1.0.1",
|
||||
"importer": "text",
|
||||
"imported": true,
|
||||
"uuid": "ae93e189-ce2d-4120-b93b-9427213a4ff1",
|
||||
"files": [
|
||||
".json"
|
||||
],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "044d4f81-135a-4531-9cb4-51f86957b563",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
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';
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export interface BootstrapResult {
|
||||
mode: RuntimeMode;
|
||||
isDebugger: boolean; // = (mode === 'debug')
|
||||
identity: ChannelIdentity;
|
||||
account?: DebugAccount; // 调试登录账号(仅 debug 模式有值;release 恒 undefined)
|
||||
servers: string[];
|
||||
rawConfig: RemoteConfig | null;
|
||||
profile: DebugProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动编排:按运行模式解析身份 + 决定服务器,产出可喂 NetClient 的结果。
|
||||
* 数据源权威唯一、下游不兜底(CLAUDE.md 第二准则):服务器地址只来自 profile.server(直连)
|
||||
* 或远程配置解析;取不到一律显式抛错暴露,不猜默认、不降级。
|
||||
*/
|
||||
export async function resolveBootstrap(opts: BootstrapOptions): Promise<BootstrapResult> {
|
||||
const isDebug = opts.mode === 'debug';
|
||||
|
||||
// 模式决定 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 一律无效
|
||||
]);
|
||||
|
||||
// 调试账号只在 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 };
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4aa4b7a4-9d53-4a40-abb2-9511a3ebeb3d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
/** 渠道身份(属性名沿用协议字段,序列化进 player_login,逐字不变)。 */
|
||||
export interface ChannelIdentity {
|
||||
agentid: string | number;
|
||||
channelid: string | number;
|
||||
gameid: string;
|
||||
marketid: string | number;
|
||||
version: number; // 协议 player_login.version 字段 = 数字 versionCode(非 "1.1" 显示串,逐字对齐源码 data.version=GameData.versionCode)
|
||||
}
|
||||
|
||||
/** 身份来源:返回部分字段覆盖。 */
|
||||
export type IdentitySource = () => Partial<ChannelIdentity>;
|
||||
|
||||
function hasValue(v: unknown): boolean {
|
||||
return v !== undefined && v !== null && v !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按数组顺序合并身份,后者覆盖前者(仅覆盖有效值字段)。
|
||||
* 约定首个 source 为完整 defaults,故返回视为完整 ChannelIdentity。
|
||||
*/
|
||||
export function resolveIdentity(sources: IdentitySource[]): ChannelIdentity {
|
||||
const out: Partial<ChannelIdentity> = {};
|
||||
for (const src of sources) {
|
||||
const part = src();
|
||||
for (const k of Object.keys(part) as (keyof ChannelIdentity)[]) {
|
||||
const v = part[k];
|
||||
if (hasValue(v)) (out as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
return out as ChannelIdentity;
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "9aeaf7dd-1e91-4642-9661-b27579958fe2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
import type { ChannelIdentity } from './identity.ts';
|
||||
|
||||
/** 调试用登录玩家档案(player_login 中除渠道身份外的字段)。仅 debug 模式生效。 */
|
||||
export interface DebugAccount {
|
||||
openid: string; // 服务器据此标识玩家(必填)
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
sex?: number;
|
||||
province?: string;
|
||||
city?: string;
|
||||
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)
|
||||
}
|
||||
|
||||
/** 正式 gameserver:原 00_SubGame_Config.js:11。 */
|
||||
export const DEFAULT_GAMESERVER = 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt';
|
||||
/** 测试 gameserver:原 00_SubGame_Config.js:9 注释。 */
|
||||
const STAGING_GAMESERVER = 'https://tsgames.daoqi88.cn/config/update_jsonv2.txt';
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
/** 发布只需保持 'prod'。 */
|
||||
export const ACTIVE_PROFILE = 'prod';
|
||||
|
||||
/** 解析当前 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];
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "e252d916-41ac-4502-9491-3cdb1c7455dc",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import type { ConfigFetcher, RemoteConfig } from './remote-config.ts';
|
||||
import { ConfigParseError } from './remote-config.ts';
|
||||
|
||||
/** 生产用 ConfigFetcher:全局 fetch 抓取远程配置 txt 并 JSON.parse。 */
|
||||
export class HttpConfigFetcher implements ConfigFetcher {
|
||||
async fetch(url: string): Promise<RemoteConfig> {
|
||||
const resp = await fetch(url, { method: 'GET' });
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return JSON.parse(text) as RemoteConfig;
|
||||
} catch {
|
||||
throw new ConfigParseError(`远程配置 JSON 解析失败: ${url}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d72a0ba0-ce64-44c3-beb9-acf23bf4237e",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
import type { ChannelIdentity } from './identity.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; }
|
||||
|
||||
/** 远程配置抓取抽象(生产注入 HttpConfigFetcher,测试注入 fake)。 */
|
||||
export interface ConfigFetcher { fetch(url: string): Promise<RemoteConfig>; }
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接相关参数键(键名逐字一致原 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;
|
||||
}
|
||||
|
||||
/** 解析 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);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "58eb5146-dce7-488c-91bf-f8ab1acbe5ec",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
// 1. 先 export type
|
||||
export type RuntimeMode = 'debug' | 'release';
|
||||
|
||||
declare const DEBUG: boolean | undefined;
|
||||
|
||||
// 2. 函数引用 RuntimeMode(此时 type 已 export)
|
||||
function readModeFromUrl(): RuntimeMode | null {
|
||||
if (typeof window === 'undefined' || !window.location) return null;
|
||||
const m = new URLSearchParams(window.location.search).get('mode');
|
||||
if (m === 'debug' || m === 'release') return m;
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectDebugBuild(): boolean {
|
||||
if (typeof DEBUG !== 'undefined') return DEBUG;
|
||||
return true; // Cocos 编辑器内 dev workflow 默认;生产 build 改 false
|
||||
}
|
||||
|
||||
// 3. 导出常量
|
||||
export const MODE_OVERRIDE: RuntimeMode | null =
|
||||
readModeFromUrl() ?? (detectDebugBuild() ? 'debug' : 'release');
|
||||
|
||||
/**
|
||||
* 解析运行模式:override 优先,否则由是否 debug 构建决定。
|
||||
*/
|
||||
export function resolveRuntimeMode(
|
||||
isDebugBuild: boolean,
|
||||
override: RuntimeMode | null = MODE_OVERRIDE,
|
||||
): RuntimeMode {
|
||||
if (override) return override;
|
||||
return isDebugBuild ? 'debug' : 'release';
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "70394e00-5fa0-4e96-8030-94fb811c31de",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "c13c3fdb-0ddf-42ad-a71c-ccb8dcd59903",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
import type { ChannelIdentity } from '../identity.ts';
|
||||
|
||||
/**
|
||||
* 构建期默认渠道身份(旧 version.js 等价物)。
|
||||
* gameid 每子游戏固定写死;agentid/channelid/marketid/version 为默认,运行期由 query/native/profile 覆盖。
|
||||
*/
|
||||
export const BUILD_IDENTITY: ChannelIdentity = {
|
||||
agentid: 'veRa0qrBf0df2K1G4de2tgfmVxB2jxpv',
|
||||
channelid: 'FtJf073aa0d6rI1xD8J1Y42fINTm0ziK',
|
||||
gameid: 'J9r4022t80pxlb01zOK76DN1WD4QewFl',
|
||||
marketid: 4,
|
||||
version: 1, // 数字 versionCode(联调时改成本地服要求的版本号)
|
||||
};
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f2c1c10d-d2bf-4477-9188-cd09ece4b2f0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
import type { ChannelIdentity, IdentitySource } from '../identity.ts';
|
||||
|
||||
/** 原生注入宿主(≈ window)。settings 由原生注入;全局 app_* 为 uAgent_3 路径回退。 */
|
||||
export interface NativeHost {
|
||||
settings?: {
|
||||
getothername?(name: string): string | number;
|
||||
getchannelName?(): string | number;
|
||||
getmarketname?(): string | number;
|
||||
};
|
||||
[global: string]: unknown; // app_agent / app_market 等
|
||||
}
|
||||
|
||||
function tryRead(primary: () => unknown, fallback: () => unknown): unknown {
|
||||
try {
|
||||
const v = primary();
|
||||
if (v !== undefined && v !== null && v !== '') return v;
|
||||
} catch { /* 原生桥不存在,走回退 */ }
|
||||
try { return fallback(); } catch { return undefined; }
|
||||
}
|
||||
|
||||
function has(v: unknown): boolean { return v !== undefined && v !== null && v !== ''; }
|
||||
|
||||
/**
|
||||
* 原生模式:同步读取渠道身份。逐字对齐原 05_Func.js:
|
||||
* agent → window.settings.getothername('agent') | window.app_agent
|
||||
* channel → window.settings.getchannelName()
|
||||
* market → window.settings.getmarketname() | window.app_market
|
||||
*/
|
||||
export function nativeSettingsSource(win: NativeHost): 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;
|
||||
return out;
|
||||
};
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "aac9397a-0b54-4a01-af51-1feed759bfc3",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import type { ChannelIdentity, IdentitySource } from '../identity.ts';
|
||||
|
||||
/** H5 模式:从 location.search 解析渠道身份覆盖。URLSearchParams 在浏览器/Cocos/Node 均可用。 */
|
||||
export function queryStringSource(search: string): IdentitySource {
|
||||
return () => {
|
||||
const q = new URLSearchParams(search);
|
||||
const out: Partial<ChannelIdentity> = {};
|
||||
const agentid = q.get('agentid'); if (agentid) out.agentid = agentid;
|
||||
const channelid = q.get('channelid'); if (channelid) out.channelid = channelid;
|
||||
const marketid = q.get('marketid'); if (marketid) out.marketid = marketid;
|
||||
const version = q.get('version'); if (version) out.version = Number(version); // 协议 version 为数字
|
||||
return out;
|
||||
};
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4a60bf34-b4bf-4ace-b3e8-cc47154dac40",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "4e11fd10-6864-41fa-b16a-03c00c782f93",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* 框架常量。协议取值参见 docs/protocol/01、02(spec §0.1:只引用、不内联协议细节)。
|
||||
*/
|
||||
export const APP = 'youle';
|
||||
|
||||
export const Route = {
|
||||
platform: 'platform',
|
||||
agent: 'agent',
|
||||
room: 'room',
|
||||
} as const;
|
||||
export type RouteName = (typeof Route)[keyof typeof Route];
|
||||
|
||||
/** 收包超时阈值(ms)。来源 docs/protocol/01 §4(ConstVal.Max.heartbeat=30000)。 */
|
||||
export const RECV_TIMEOUT_MS = 30000;
|
||||
|
||||
/** 重连定时器间隔(ms)。来源 docs/protocol/01 §7.2(GameData.timer=10000)。 */
|
||||
export const RECONNECT_INTERVAL_MS = 10000;
|
||||
|
||||
/** 发登录后等待响应的守护超时(ms)。来源 docs/protocol/01 §6.2(4000)。 */
|
||||
export const LOGIN_GUARD_MS = 4000;
|
||||
|
||||
/** 失败累计到该次数轮换一个候选服务器。来源 docs/protocol/01 §7.3(tryReconnectTimes%3)。 */
|
||||
export const SERVER_ROTATE_EVERY = 3;
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "8d847162-fbcb-4e69-8cd9-7879f10fce98",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
type Handler = (...args: any[]) => void;
|
||||
|
||||
/** 轻量类型化事件总线。订阅者抛错被隔离,不影响其它订阅者(故障隔离)。 */
|
||||
export class EventBus<Events extends Record<string, any[]> = Record<string, any[]>> {
|
||||
private map = new Map<string, Set<Handler>>();
|
||||
|
||||
on<K extends keyof Events & string>(type: K, fn: (...args: Events[K]) => void): void {
|
||||
let set = this.map.get(type);
|
||||
if (!set) { set = new Set(); this.map.set(type, set); }
|
||||
set.add(fn as Handler);
|
||||
}
|
||||
|
||||
once<K extends keyof Events & string>(type: K, fn: (...args: Events[K]) => void): void {
|
||||
const wrap = (...args: Events[K]) => { this.off(type, wrap as any); (fn as any)(...args); };
|
||||
this.on(type, wrap as any);
|
||||
}
|
||||
|
||||
off<K extends keyof Events & string>(type: K, fn: (...args: Events[K]) => void): void {
|
||||
this.map.get(type)?.delete(fn as Handler);
|
||||
}
|
||||
|
||||
emit<K extends keyof Events & string>(type: K, ...args: Events[K]): void {
|
||||
const set = this.map.get(type);
|
||||
if (!set) return;
|
||||
for (const fn of [...set]) {
|
||||
try { fn(...args); } catch (e) { console.error('[EventBus] handler error:', e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "05c9349b-6d6d-4fcc-a8f2-a52be4023a16",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export interface ReadonlyReactive<T> {
|
||||
readonly value: T;
|
||||
subscribe(fn: (value: T, prev: T) => void): Unsubscribe; // 返回退订函数
|
||||
}
|
||||
|
||||
export interface Reactive<T> extends ReadonlyReactive<T> {
|
||||
value: T; // 可写
|
||||
}
|
||||
|
||||
/** 极小响应式信号:value 读写 + 订阅。Object.is 判等(值未变不通知);订阅者抛错隔离。 */
|
||||
export function signal<T>(initial: T): Reactive<T> {
|
||||
let current = initial;
|
||||
const subs = new Set<(value: T, prev: T) => void>();
|
||||
return {
|
||||
get value(): T { return current; },
|
||||
set value(v: T) {
|
||||
if (Object.is(v, current)) return;
|
||||
const prev = current;
|
||||
current = v;
|
||||
for (const fn of [...subs]) {
|
||||
try { fn(current, prev); } catch (e) { console.error('[reactive] subscriber error:', e); }
|
||||
}
|
||||
},
|
||||
subscribe(fn: (value: T, prev: T) => void): Unsubscribe {
|
||||
subs.add(fn);
|
||||
return () => { subs.delete(fn); };
|
||||
},
|
||||
};
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "327659db-9c76-49b1-81a1-43abd4bb786c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
/** 绝对座位 → 以 mySeat 为视角的视图位(自己=0,其余环形顺延)。 */
|
||||
export function toView(mySeat: number, targetSeat: number, seatCount: number): number {
|
||||
return ((targetSeat - mySeat) % seatCount + seatCount) % seatCount;
|
||||
}
|
||||
|
||||
/** 视图位 → 绝对座位(toView 的逆运算)。 */
|
||||
export function fromView(mySeat: number, viewSeat: number, seatCount: number): number {
|
||||
return ((viewSeat + mySeat) % seatCount + seatCount) % seatCount;
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "a138ed2d-2d25-4c7e-a17d-0c0e033317d5",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "d9f1c6f4-ba37-4aa1-912b-ce606abad4d2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
import type { RouteName } from '../constants.ts';
|
||||
|
||||
/** 客户端→服务器 单层信封。来源 docs/protocol/01 §3.1。 */
|
||||
export interface OutboundEnvelope<T = unknown> {
|
||||
app: 'youle';
|
||||
route: RouteName | string; // 游戏内 route 为子游戏名
|
||||
rpc: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** 服务器→客户端 解包后的内层。来源 docs/protocol/01 §3.2。 */
|
||||
export interface InboundMessage<T = unknown> {
|
||||
route: string;
|
||||
rpc: string;
|
||||
data: T;
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c9f0165f-24d2-489b-b378-c3f411a57ab6",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* player_login 请求/响应(最小集)。完整字段以 docs/protocol/01 §6、02、04 为准(spec §0.1)。
|
||||
* 用索引签名容纳本最小集未显式列出的协议字段,避免在此内联复制全部协议字段。
|
||||
*/
|
||||
export interface LoginRequestData {
|
||||
agentid: number | string;
|
||||
gameid: number | string;
|
||||
openid: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
sex: number;
|
||||
province: string;
|
||||
city: string;
|
||||
unionid: string | number;
|
||||
version: number; // 数字 versionCode(源码 data.version=GameData.versionCode)
|
||||
channelid: number | string;
|
||||
marketid: number | string;
|
||||
[extra: string]: unknown; // location/ip/machineid/telphone 等由 Send_login 注入,见 docs/protocol/01 §6
|
||||
}
|
||||
|
||||
/** 登录响应 A 组账号资产(核心字段)。完整见 docs/protocol/04。 */
|
||||
export interface LoginResponseData {
|
||||
state: number; // 0=成功
|
||||
playerid: number;
|
||||
[extra: string]: unknown; // bean/roomcard/charm/... 及 B 组房间恢复字段 roomcode/isbattle/deskinfo
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "1f557df5-e9a5-463b-b219-516d4523af73",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "650c401f-f699-427c-b353-3c83b406e2d2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
import type { Transport } from './transport.ts';
|
||||
|
||||
/** 生产用 Transport:包装运行时全局 WebSocket(Cocos 原生/浏览器)。docs/protocol/01 §2。 */
|
||||
export class CocosWebSocketTransport implements Transport {
|
||||
private ws: WebSocket | null = null;
|
||||
private openCb?: () => void;
|
||||
private msgCb?: (frame: string) => void;
|
||||
private closeCb?: () => void;
|
||||
|
||||
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 */ } };
|
||||
}
|
||||
send(frame: string): void { this.ws?.send(frame); }
|
||||
close(): void { 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; }
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "1ca06a10-f832-4223-a99a-8667c6a636df",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
import { APP } from '../core/constants.ts';
|
||||
import type { InboundMessage } from '../core/types/envelope.ts';
|
||||
|
||||
/** 组装单层出站信封字符串。docs/protocol/01 §3.1。 */
|
||||
export function encodeOutbound(route: string, rpc: string, data: unknown): string {
|
||||
return JSON.stringify({ app: APP, route, rpc, data });
|
||||
}
|
||||
|
||||
export type DecodeResult =
|
||||
| { kind: 'handshake' } // @toconcon,忽略
|
||||
| { kind: 'heartbeat' } // @serverheartbeat,忽略不回包
|
||||
| { kind: 'serverDown' } // webserve-服务器未工作
|
||||
| { kind: 'message'; message: InboundMessage } // 正常业务内层
|
||||
| { kind: 'ignore' }; // 无法解析,安全忽略
|
||||
|
||||
/**
|
||||
* 解一帧为分类结果。**服务器协议是单层 `{route, rpc, data}`**(可能带 `app`)。
|
||||
* 入参 frame 已是服务器实发帧——传输层已取 `MessageEvent.data`(旧 12_Logic.js 里被当成
|
||||
* "外层 data" 的其实是浏览器 WebSocket MessageEvent 这个壳,非协议层)。故此处不再剥 `.data`。
|
||||
* 不做 TcpID/isSendLoginState/submit_error 过滤(那是连接级,见 net-client)。
|
||||
*/
|
||||
export function decodeFrame(frame: string): DecodeResult {
|
||||
// 特殊串按原始帧判定(与旧 12_Logic.js 对 MessageEvent.data 的判断一致)。
|
||||
if (frame === 'webserve-服务器未工作') return { kind: 'serverDown' };
|
||||
if (frame.substr(0, 9) === '@toconcon') return { kind: 'handshake' };
|
||||
|
||||
let msg: any;
|
||||
try { msg = JSON.parse(frame); } catch { return { kind: 'ignore' }; }
|
||||
// 兼容服务器偶发"再包一层字符串"(旧源码对每层都做 typeof=='string' 再 parse)。
|
||||
if (typeof msg === 'string') {
|
||||
try { msg = JSON.parse(msg); } catch { return { kind: 'ignore' }; }
|
||||
}
|
||||
if (!msg || typeof msg !== 'object') return { kind: 'ignore' };
|
||||
|
||||
if ((msg as any).com === '@serverheartbeat') return { kind: 'heartbeat' };
|
||||
if ('route' in msg && 'rpc' in msg) return { kind: 'message', message: msg as InboundMessage };
|
||||
return { kind: 'ignore' };
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "9a5b3606-a963-462b-99a9-8a92c66d854d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
export interface Clock {
|
||||
setTimeout(fn: () => void, ms: number): unknown;
|
||||
clearTimeout(handle: unknown): void;
|
||||
}
|
||||
|
||||
export const realClock: Clock = {
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
||||
clearTimeout: (h) => clearTimeout(h as any),
|
||||
};
|
||||
|
||||
/** 收包超时看门狗。docs/protocol/01 §4.1:每收一包 feed() 重置;超时调 onTimeout。 */
|
||||
export class HeartbeatWatchdog {
|
||||
private handle: unknown = null;
|
||||
constructor(private ms: number, private onTimeout: () => void, private clock: Clock = realClock) {}
|
||||
|
||||
feed(): void {
|
||||
this.stop();
|
||||
this.handle = this.clock.setTimeout(() => { this.handle = null; this.onTimeout(); }, this.ms);
|
||||
}
|
||||
stop(): void {
|
||||
if (this.handle != null) { this.clock.clearTimeout(this.handle); this.handle = null; }
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "8fc14af6-af98-41a7-9646-c132ef3b6957",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
import { EventBus } from '../core/events.ts';
|
||||
import { Route, RECV_TIMEOUT_MS, RECONNECT_INTERVAL_MS, LOGIN_GUARD_MS, SERVER_ROTATE_EVERY } from '../core/constants.ts';
|
||||
import type { Transport } from './transport.ts';
|
||||
import { encodeOutbound, decodeFrame } from './envelope-codec.ts';
|
||||
import { HeartbeatWatchdog, realClock, type Clock } from './heartbeat.ts';
|
||||
import { ReconnectPolicy } from './reconnect.ts';
|
||||
import type { InboundMessage } from '../core/types/envelope.ts';
|
||||
import type { LoginRequestData } from '../core/types/login.ts';
|
||||
|
||||
export interface NetClientEvents extends Record<string, any[]> {
|
||||
open: [];
|
||||
login: [unknown]; // player_login 响应 data
|
||||
message: [InboundMessage];
|
||||
slow: [];
|
||||
reconnecting: [string]; // 目标 server
|
||||
serverSwitch: [string]; // 新 server 地址
|
||||
kicked: [unknown];
|
||||
close: [];
|
||||
}
|
||||
|
||||
export interface NetClientOptions {
|
||||
servers: string | string[];
|
||||
transportFactory: () => Transport;
|
||||
clock?: Clock;
|
||||
bus?: EventBus<NetClientEvents>;
|
||||
debug?: boolean; // 为真时打印每帧 send/recv
|
||||
logger?: (...args: unknown[]) => void; // 日志函数,默认 console.log(便于测试注入)
|
||||
}
|
||||
|
||||
/** 网络客户端:编排 transport/codec/heartbeat/reconnect,事件驱动,忠实 docs/protocol/01。 */
|
||||
export class NetClient {
|
||||
readonly bus: EventBus<NetClientEvents>;
|
||||
private transportFactory: () => Transport;
|
||||
private clock: Clock;
|
||||
private policy: ReconnectPolicy;
|
||||
private watchdog: HeartbeatWatchdog;
|
||||
|
||||
private transport: Transport | null = null;
|
||||
private tcpId = 0; // 每次连接自增;onMessage 闭包捕获本次 id 做去重
|
||||
private identity: LoginRequestData | null = null;
|
||||
private isSendLoginState = false;
|
||||
private isLogin = false;
|
||||
private loginGuard: unknown = null;
|
||||
private reconnectTimer: unknown = null;
|
||||
private stopped = false;
|
||||
private debug: boolean;
|
||||
private logger: (...args: unknown[]) => void;
|
||||
|
||||
constructor(opts: NetClientOptions) {
|
||||
this.bus = opts.bus ?? new EventBus<NetClientEvents>();
|
||||
this.transportFactory = opts.transportFactory;
|
||||
this.clock = opts.clock ?? realClock;
|
||||
this.policy = new ReconnectPolicy(opts.servers, SERVER_ROTATE_EVERY);
|
||||
this.watchdog = new HeartbeatWatchdog(RECV_TIMEOUT_MS, () => this.onRecvTimeout(), this.clock);
|
||||
this.debug = opts.debug ?? false;
|
||||
this.logger = opts.logger ?? console.log;
|
||||
}
|
||||
|
||||
setIdentity(identity: LoginRequestData): void { this.identity = identity; }
|
||||
on = <K extends keyof NetClientEvents & string>(t: K, fn: (...a: NetClientEvents[K]) => void) => this.bus.on(t, fn);
|
||||
off = <K extends keyof NetClientEvents & string>(t: K, fn: (...a: NetClientEvents[K]) => void) => this.bus.off(t, fn);
|
||||
|
||||
start(): void { this.stopped = false; this.connect(this.policy.current()); }
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.watchdog.stop();
|
||||
if (this.reconnectTimer != null) { this.clock.clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
||||
if (this.loginGuard != null) { this.clock.clearTimeout(this.loginGuard); this.loginGuard = null; }
|
||||
this.transport?.close();
|
||||
}
|
||||
|
||||
/** 发业务包(单层信封)。 */
|
||||
send(route: string, rpc: string, data: unknown): void {
|
||||
this.sendFrame(encodeOutbound(route, rpc, data));
|
||||
}
|
||||
|
||||
/** 统一发送切面:debug 时打印出站帧。 */
|
||||
private sendFrame(frame: string): void {
|
||||
if (this.debug) this.logger('[net] →', frame);
|
||||
this.transport?.send(frame);
|
||||
}
|
||||
|
||||
private connect(url: string): void {
|
||||
const id = ++this.tcpId;
|
||||
const t = this.transportFactory();
|
||||
this.transport = t;
|
||||
t.onOpen(() => { if (id === this.tcpId) this.onOpen(); });
|
||||
t.onMessage((frame) => { if (id === this.tcpId) this.onMessage(frame); });
|
||||
t.onClose(() => { if (id === this.tcpId) this.onClose(); });
|
||||
t.connect(url);
|
||||
}
|
||||
|
||||
private onOpen(): void {
|
||||
this.bus.emit('open');
|
||||
this.sendLogin();
|
||||
}
|
||||
|
||||
private sendLogin(): void {
|
||||
if (!this.identity) return; // 无身份不发(docs §6.1:首登需身份)
|
||||
this.isSendLoginState = true;
|
||||
this.sendFrame(encodeOutbound(Route.agent, 'player_login', this.identity));
|
||||
if (this.loginGuard != null) this.clock.clearTimeout(this.loginGuard);
|
||||
this.loginGuard = this.clock.setTimeout(() => { this.loginGuard = null; this.onLoginGuardTimeout(); }, LOGIN_GUARD_MS); // docs §6.2
|
||||
}
|
||||
|
||||
private onMessage(frame: string): void {
|
||||
if (this.debug) this.logger('[net] ←', frame);
|
||||
const r = decodeFrame(frame);
|
||||
if (r.kind === 'ignore') return;
|
||||
this.watchdog.feed(); // 收任意有效帧重置看门狗 docs §4.1
|
||||
if (r.kind === 'handshake' || r.kind === 'heartbeat') return; // 忽略,心跳不回包
|
||||
if (r.kind === 'serverDown') { this.sendLogin(); return; } // docs §4.2(剥离 UI 判定,直接重发登录)
|
||||
|
||||
const msg = r.message;
|
||||
if (msg.rpc === 'submit_error') return; // docs §3.2-9
|
||||
// 服务器踢人:发事件并终止,**不自动重连**(对齐真实客户端 kick 弹窗停住;否则会空转重踢)。
|
||||
if (msg.rpc === 'kick_server') { this.bus.emit('kicked', msg.data); this.stop(); return; }
|
||||
if (this.isSendLoginState) { // docs §3.2-10 登录态门控
|
||||
if (msg.rpc === 'player_login') {
|
||||
this.isSendLoginState = false; this.isLogin = true;
|
||||
if (this.loginGuard != null) { this.clock.clearTimeout(this.loginGuard); this.loginGuard = null; }
|
||||
this.policy.reset();
|
||||
this.bus.emit('login', msg.data);
|
||||
}
|
||||
return; // 其它一律丢弃
|
||||
}
|
||||
// 服务器切换指令 docs §7.4
|
||||
if (msg.rpc === 'connect_roomserver' && (msg.data as any)?.roomserver) {
|
||||
this.switchServer((msg.data as any).roomserver); return;
|
||||
}
|
||||
if (msg.rpc === 'connect_agentserver' && (msg.data as any)?.agentserver) {
|
||||
this.switchServer((msg.data as any).agentserver); return;
|
||||
}
|
||||
this.bus.emit('message', msg);
|
||||
}
|
||||
|
||||
private switchServer(addr: string): void {
|
||||
this.policy.setCurrent(addr);
|
||||
this.bus.emit('serverSwitch', addr);
|
||||
this.reconnectNow(); // 关闭当前→重连到新地址→重登
|
||||
}
|
||||
|
||||
private onRecvTimeout(): void { this.bus.emit('slow'); this.reconnectNow(); } // docs §4.1
|
||||
private onLoginGuardTimeout(): void { this.reconnectNow(); } // docs §6.2
|
||||
|
||||
private onClose(): void {
|
||||
this.watchdog.stop();
|
||||
if (this.stopped) return;
|
||||
const next = this.policy.onFailure();
|
||||
this.bus.emit('reconnecting', next);
|
||||
if (this.reconnectTimer != null) this.clock.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = this.clock.setTimeout(() => { this.reconnectTimer = null; this.connect(next); }, RECONNECT_INTERVAL_MS); // docs §7.2 间隔 10s
|
||||
}
|
||||
|
||||
/** 主动关闭当前连接,交由 onClose 走重连。 */
|
||||
private reconnectNow(): void {
|
||||
this.isSendLoginState = false;
|
||||
if (this.loginGuard != null) { this.clock.clearTimeout(this.loginGuard); this.loginGuard = null; }
|
||||
this.transport?.close();
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "9f2f2e15-c16c-4fe5-80dd-a4d112e72d56",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
/** 候选服务器轮询。docs/protocol/01 §7.3。 */
|
||||
export class ReconnectPolicy {
|
||||
private servers: string[];
|
||||
private index = 0;
|
||||
private failCount = 0;
|
||||
constructor(servers: string | string[], private rotateEvery: number) {
|
||||
this.servers = Array.isArray(servers) ? servers.slice() : [servers];
|
||||
}
|
||||
current(): string { return this.servers[this.index]; }
|
||||
/** 记一次连接失败,按 rotateEvery 轮换候选,返回新的 current。 */
|
||||
onFailure(): string {
|
||||
this.failCount++;
|
||||
if (this.servers.length > 1 && this.failCount % this.rotateEvery === 0) {
|
||||
this.index = (this.index + 1) % this.servers.length;
|
||||
}
|
||||
return this.current();
|
||||
}
|
||||
reset(): void { this.failCount = 0; }
|
||||
/** 服务器切换指令:改用指定地址。docs/protocol/01 §7.4。 */
|
||||
setCurrent(server: string): void { this.servers = [server]; this.index = 0; this.failCount = 0; }
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "75821133-061e-4dd4-b2eb-919dac8ced43",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* WebSocket 抽象(依赖注入点,D2)。
|
||||
* 生产:Cocos/浏览器 WebSocket adapter(见 cocos-transport.ts)。
|
||||
* 测试:内存 FakeTransport(模拟 server)。
|
||||
*/
|
||||
export interface Transport {
|
||||
send(frame: string): void;
|
||||
close(): void;
|
||||
onOpen(cb: () => void): void;
|
||||
onMessage(cb: (frame: string) => void): void;
|
||||
onClose(cb: () => void): void;
|
||||
/** 发起连接(建立后应触发 onOpen)。 */
|
||||
connect(url: string): void;
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "0d676082-c50c-43be-88b2-7e56510048fd",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "2161a491-f85a-46a9-baa6-91c6e1041451",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* 原生↔H5 异步桥(WebViewJavascriptBridge,WVJB)+ window.settings 同步取值。
|
||||
*
|
||||
* 严格对齐原 gameabc 项目(05_Func.js:2627 起)的外部契约:
|
||||
* - 14 个 handler 名白名单(与原生 app 逐字对齐)
|
||||
* - `window.WVJBCallbacks` / `WebViewJavascriptBridgeReady` 事件名
|
||||
* - `window.settings.getothername(name)` 同步取值 API
|
||||
* - 回调签名 `(data, responseCallback)` 与数据结构
|
||||
*
|
||||
* 内部命名(接口/函数/常量)走现代专业风格,与上述外部契约清晰隔离。
|
||||
*
|
||||
* 架构边界:本模块只暴露受限 facade,不允许 framework 业务代码直接读
|
||||
* window/bridge 内部。
|
||||
*/
|
||||
|
||||
/** window 上的原生注入对象(05_Func.js:2467-2471 同步取值)。 */
|
||||
declare global {
|
||||
interface Window {
|
||||
settings?: {
|
||||
/** 同步取一个原生配置项,无则返回空串 */
|
||||
getothername(name: string): string;
|
||||
};
|
||||
/** WVJB 启动前的全局回调队列(外部契约,不能改名) */
|
||||
WVJBCallbacks?: Array<(bridge: NativeBridgeChannel) => void>;
|
||||
/** WebView 注入 WVJB 的事件名(外部契约) */
|
||||
WebViewJavascriptBridgeReady?: Array<() => void>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原生桥通道契约 —— WVJB bridge 实例的最小子集。
|
||||
* 内部命名为 "Channel" 以避免缩写、明确"双向通信通道"语义。
|
||||
*/
|
||||
export interface NativeBridgeChannel {
|
||||
/** H5 注册供原生侧调用的 handler */
|
||||
registerHandler(name: string, handler: NativeHandler): void;
|
||||
/** H5 主动调用原生侧 */
|
||||
callHandler(name: string, data: unknown, responseCallback?: (resp: unknown) => void): void;
|
||||
}
|
||||
|
||||
/** H5 注册供原生调用的 handler 函数签名(外部 WVJB 协议约定) */
|
||||
export type NativeHandler = (data: unknown, responseCallback: NativeResponseCallback) => unknown;
|
||||
|
||||
/** 响应回调:原生侧通过此把结果回传给 H5 */
|
||||
export type NativeResponseCallback = (responseData: unknown) => void;
|
||||
|
||||
/**
|
||||
* 14 个已注册 handler 名白名单(05_Func.js:2651+ 原项目实装)。
|
||||
* 与原生 app 端逐字对齐 —— app 按这些名字调,改名 app 也要改。
|
||||
*
|
||||
* 字面量联合类型:registerHandler/callHandler 入参必须在此集合内,
|
||||
* 拼写错误编译期就发现,集合外名字运行时显式抛错(第二准则:不静默兜底)。
|
||||
*/
|
||||
export const NATIVE_HANDLER_NAMES = [
|
||||
'getVideoinfo',
|
||||
'sharelogin',
|
||||
'sharesuccess',
|
||||
'gameui_play_voice',
|
||||
'gameui_stop_voice',
|
||||
'getphoneinfo',
|
||||
'getAddressBook',
|
||||
'phonestate',
|
||||
'appservice',
|
||||
'getaudiourl',
|
||||
'getBattery',
|
||||
'getwifiLevel',
|
||||
'getnetwork',
|
||||
'shakeEnd',
|
||||
] as const;
|
||||
|
||||
export type NativeHandlerName = (typeof NATIVE_HANDLER_NAMES)[number];
|
||||
|
||||
function isNativeHandlerName(name: string): name is NativeHandlerName {
|
||||
return (NATIVE_HANDLER_NAMES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
/** createBridge 初始化选项 */
|
||||
export interface BridgeOptions {
|
||||
/** 通道就绪回调(原生侧 WebView bridgeReady 时触发一次) */
|
||||
onReady(channel: NativeBridgeChannel): void;
|
||||
/**
|
||||
* 初始化超时的兜底(可省略)。
|
||||
* 原生侧可能永不触发 WebViewJavascriptBridgeReady,业务可在此降级。
|
||||
*/
|
||||
onTimeout?(reason: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 受限 Bridge facade —— 框架业务访问原生能力的唯一入口。
|
||||
*
|
||||
* 创建后立即尝试初始化:
|
||||
* 1. 把 setup 回调 push 到 window.WVJBCallbacks(原生侧约定的入口)
|
||||
* 2. 原生侧 WebView bridgeReady 时遍历 WVJBCallbacks 调用每个 cb → onReady 被触发
|
||||
*
|
||||
* 一旦通道就绪,onReady 被调用一次。
|
||||
*/
|
||||
export interface Bridge {
|
||||
/** 当前通道是否已就绪 */
|
||||
isReady(): boolean;
|
||||
/** H5 注册供原生侧调用(白名单校验) */
|
||||
registerHandler(name: NativeHandlerName, handler: NativeHandler): void;
|
||||
/** H5 调用原生侧(白名单校验) */
|
||||
callHandler(name: NativeHandlerName, data: unknown, cb?: NativeResponseCallback): void;
|
||||
}
|
||||
|
||||
export function createBridge(opts: BridgeOptions): Bridge {
|
||||
const g = globalThis as any;
|
||||
// 外部契约:把 setup cb push 到 window.WVJBCallbacks
|
||||
if (!g.WVJBCallbacks) g.WVJBCallbacks = [];
|
||||
let ready = false;
|
||||
g.WVJBCallbacks.push((channel: NativeBridgeChannel) => {
|
||||
ready = true;
|
||||
// 缓存通道供 callHandler 委托(避免 onReady 闭包外传)
|
||||
g.__activeChannel = channel;
|
||||
opts.onReady(channel);
|
||||
});
|
||||
|
||||
return {
|
||||
isReady() { return ready; },
|
||||
registerHandler(name, handler) {
|
||||
if (!isNativeHandlerName(name)) {
|
||||
throw new Error(`registerHandler: handler "${name}" 不在白名单(第二准则:不静默兜底)`);
|
||||
}
|
||||
if (!ready) throw new Error('NativeBridge not ready: 通道未初始化,请先等 onReady 回调');
|
||||
g.__activeChannel.registerHandler(name, handler);
|
||||
},
|
||||
callHandler(name, data, cb) {
|
||||
if (!isNativeHandlerName(name)) {
|
||||
throw new Error(`callHandler: handler "${name}" 不在白名单(第二准则:不静默兜底)`);
|
||||
}
|
||||
if (!ready) throw new Error('NativeBridge not ready: 通道未初始化,请先等 onReady 回调');
|
||||
g.__activeChannel.callHandler(name, data, cb);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 window 取原生注入的配置项(05_Func.js:2467-2471 的 getothername)。
|
||||
*
|
||||
* @param name 配置项名(渠道/包信息/gameserver 覆盖等)
|
||||
* @returns 原生侧返回值;缺失时返回空串(对齐原项目:无则空字符串而非 undefined)
|
||||
* @throws 原生侧未注入 window.settings 时显式抛错(不静默返回空串——避免下游误用)
|
||||
*/
|
||||
export function getNativeSetting(name: string): string {
|
||||
const settings = (globalThis as any).window?.settings;
|
||||
if (!settings || typeof settings.getothername !== 'function') {
|
||||
throw new Error(
|
||||
`getNativeSetting("${name}"): window.settings 未注入(原生侧未加载或非 WebView 环境)`,
|
||||
);
|
||||
}
|
||||
return settings.getothername(name);
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2eb9409f-115d-4a7d-bdc4-30a333b57a28",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
import type { ReadonlyReactive } from '../core/reactive.ts';
|
||||
import type { PlayerState, AppState, RoomState } from './stores/types.ts';
|
||||
|
||||
export interface ReadonlyPlayerStore { readonly state: ReadonlyReactive<PlayerState>; }
|
||||
export interface ReadonlyRoomStore { readonly state: ReadonlyReactive<RoomState>; }
|
||||
export interface ReadonlyAppStore { readonly state: ReadonlyReactive<AppState>; }
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "0d43387e-71ca-4d3d-a7c4-0fd7ef62e8ac",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
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>,
|
||||
);
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b2f4361c-64f4-4a29-a5db-a4bd45b42f84",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
import type { EventBus } from '../core/events.ts';
|
||||
import type { NetClientEvents } from '../net/net-client.ts';
|
||||
import type { LoginResponseData } from '../core/types/login.ts';
|
||||
import { AppStore } from './stores/app-store.ts';
|
||||
import { PlayerStore } from './stores/player-store.ts';
|
||||
import { RoomStore } from './stores/room-store.ts';
|
||||
|
||||
/**
|
||||
* PlatformSession:平台会话编排(C 规范 §6.1 启动流程)。
|
||||
*
|
||||
* 订阅 NetClient 事件总线,把 login/连接事件落到三个响应式 Store:
|
||||
* - 'open' → phase = 'connected'
|
||||
* - 'login'(data) → 解析 state:state==0 → 三 Store 填充 + phase=loggedIn;
|
||||
* state!=0 → phase=loginFailed(不写入 playerid/roomcode)
|
||||
* - 'reconnecting' → phase = 'reconnecting'
|
||||
* - 'slow' → phase = 'slow'
|
||||
* - 'kicked' → phase = 'kicked'(不再重连,由 NetClient 自身决定)
|
||||
*
|
||||
* 不持有业务态——只做事件→Store 的扇出。Store 是状态唯一来源。
|
||||
*/
|
||||
export class PlatformSession {
|
||||
readonly app = new AppStore();
|
||||
readonly player = new PlayerStore();
|
||||
readonly room = new RoomStore();
|
||||
|
||||
constructor(bus: EventBus<NetClientEvents>) {
|
||||
bus.on('open', () => this.app.setPhase('connected'));
|
||||
bus.on('login', (data) => this.onLogin(data));
|
||||
bus.on('reconnecting', () => this.app.setPhase('reconnecting'));
|
||||
bus.on('slow', () => this.app.setPhase('slow'));
|
||||
bus.on('kicked', () => this.app.setPhase('kicked'));
|
||||
}
|
||||
|
||||
private onLogin(data: unknown): void {
|
||||
const d = data as LoginResponseData & { state?: number };
|
||||
if (d.state !== 0) {
|
||||
// 登录失败:仅记 phase,不污染 playerid/roomcode 等敏感字段
|
||||
this.app.setPhase('loginFailed');
|
||||
return;
|
||||
}
|
||||
this.player.applyLogin(d);
|
||||
this.app.applyLogin(d);
|
||||
if (d.roomcode) {
|
||||
this.room.applyRecovery(d as unknown as Parameters<RoomStore['applyRecovery']>[0]);
|
||||
} else {
|
||||
this.room.clear();
|
||||
}
|
||||
this.app.setPhase('loggedIn');
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "6831840f-689a-4fb2-bfb5-c23c45fdb59c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
import type { EventBus } from '../core/events.ts';
|
||||
import type { NetClientEvents } from '../net/net-client.ts';
|
||||
import type { ChannelIdentity } from '../config/identity.ts';
|
||||
import type { BootstrapResult } from '../config/bootstrap.ts';
|
||||
import type { PlatformSession } from './session.ts';
|
||||
import type { RoomRPCBus } from './room-rpc-bus.ts';
|
||||
|
||||
/**
|
||||
* StartupOrchestrator:把启动链路串起来的纯逻辑编排器。
|
||||
*
|
||||
* 流程:bootstrap() → onLogin()(由 PlatformSession 在收到 login 事件后调用)→ ready
|
||||
*
|
||||
* 不直接构造 NetClient / RoomRPCBus(依赖注入),便于单测覆盖全部阶段。
|
||||
* 不主动连 WS/发包——NetClient 由业务侧触发 start(),本类只编排阶段信号。
|
||||
*
|
||||
* 启动门控四条件(C 规范 §6.1):资源(外部)/ WS onopen(NetClient.on)/ 配置(本类 bootstrap)/ 加载计时器(外部)。
|
||||
* waitForReady() 合并后三个为「就绪信号」,UI 层 await 进入登录页。
|
||||
*/
|
||||
export interface StartupOptions {
|
||||
session: PlatformSession;
|
||||
bus: EventBus<NetClientEvents>;
|
||||
/** 返回 bootstrap 结果(远程配置 + 身份 + 服务器列表) */
|
||||
getBootstrapResult: () => Promise<BootstrapResult>;
|
||||
/** 构造 NetClient 的工厂(注入避免硬依赖真 WS) */
|
||||
makeNetClient: () => { bus: EventBus<NetClientEvents> };
|
||||
/** 用于 session 初始化的身份字段 */
|
||||
identity: ChannelIdentity;
|
||||
/** 构造 RoomRPCBus 的工厂 */
|
||||
roomBusFactory: (session: PlatformSession, bus: EventBus<NetClientEvents>) => RoomRPCBus;
|
||||
}
|
||||
|
||||
export class StartupOrchestrator {
|
||||
bootstrapResult: BootstrapResult | null = null;
|
||||
private readyResolve: (() => void) | null = null;
|
||||
private readyPromise: Promise<void>;
|
||||
private roomBus: RoomRPCBus | null = null;
|
||||
private netClient: { bus: EventBus<NetClientEvents> } | null = null;
|
||||
|
||||
constructor(private readonly opts: StartupOptions) {
|
||||
// 立即构造一个 pending Promise,UI 层可提前 await
|
||||
this.readyPromise = new Promise<void>((resolve) => {
|
||||
this.readyResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/** 阶段 1:拉取并解析远程配置,失败显式抛错 */
|
||||
async bootstrap(): Promise<BootstrapResult> {
|
||||
const result = await this.opts.getBootstrapResult();
|
||||
this.bootstrapResult = result;
|
||||
// 把身份 + 服务器写入 AppStore(session 在构造时已订阅 login 等事件,
|
||||
// 这里直接驱动 AppStore.setServers/setIdentity 以提前有数据)
|
||||
this.opts.session.app.setServers(result.servers);
|
||||
this.opts.session.app.setIdentity(result.identity);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 阶段 2/3:构造 NetClient + RoomRPCBus,准备就绪(但不主动 start 连接;
|
||||
* 业务侧在收到 phase=connected 后再 netClient.start())。
|
||||
*
|
||||
* 通常 PlatformSession 收到 'login' 事件后调用此方法——login 事件
|
||||
* 表明 NetClient 已 start 且 server 已响应 player_login。
|
||||
*/
|
||||
async onLogin(): Promise<void> {
|
||||
// 构造 NetClient(业务侧会调用 start() 真正建立 WS)
|
||||
this.netClient = this.opts.makeNetClient();
|
||||
// 构造 RoomRPCBus 并立即 start,把 room route 的包挂到 bus
|
||||
this.roomBus = this.opts.roomBusFactory(this.opts.session, this.netClient.bus);
|
||||
this.roomBus.start();
|
||||
// 触发 ready 信号
|
||||
if (this.readyResolve) { this.readyResolve(); this.readyResolve = null; }
|
||||
}
|
||||
|
||||
/** UI 层等待「bootstrap + login + 房间 rpc 通道就绪」 */
|
||||
async waitForReady(): Promise<void> {
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
||||
isReady(): boolean { return this.readyResolve === null; }
|
||||
|
||||
/** 清理:停止 RoomRPCBus + 关闭 NetClient */
|
||||
shutdown(): void {
|
||||
this.roomBus?.stop();
|
||||
this.roomBus = null;
|
||||
this.netClient = null;
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "6a645fcf-0721-4c74-b14b-850aec6a0746",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "3b90ff8e-dffa-4479-ab4b-581366f6978b",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
import { signal, type ReadonlyReactive } from '../../core/reactive.ts';
|
||||
import type { AppState, ConnectionPhase } from './types.ts';
|
||||
import type { ReadonlyAppStore } from '../readonly.ts';
|
||||
import type { ChannelIdentity } from '../../config/identity.ts';
|
||||
import type { LoginResponseData } from '../../core/types/login.ts';
|
||||
|
||||
/** AppState 初始值。 */
|
||||
const INITIAL: AppState = {
|
||||
identity: null,
|
||||
servers: [],
|
||||
agentid: '',
|
||||
channelid: '',
|
||||
agentname: '',
|
||||
agentmode: 0,
|
||||
gameversion: 0,
|
||||
phase: 'idle',
|
||||
};
|
||||
|
||||
/** applyLogin 入参契约:A 组公共字段(agentid/channelid/agentname/agentmode/gameversion)。 */
|
||||
export interface AppLoginData {
|
||||
agentid?: string | number;
|
||||
channelid?: string | number;
|
||||
agentname?: string;
|
||||
agentmode?: number;
|
||||
gameversion?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AppStore:应用级状态(= 旧 GameData 子集)。
|
||||
* - identity/servers 由 config/bootstrap 启动编排时写入
|
||||
* - phase 由 NetClient 事件总线经 PlatformSession 驱动
|
||||
* - agentid/channelid/agentname/agentmode/gameversion 由 login 回包回填
|
||||
*/
|
||||
export class AppStore implements ReadonlyAppStore {
|
||||
private s = signal<AppState>({ ...INITIAL });
|
||||
get state(): ReadonlyReactive<AppState> { return this.s; }
|
||||
|
||||
setIdentity(identity: ChannelIdentity): void {
|
||||
this.s.value = { ...this.s.value, identity };
|
||||
}
|
||||
|
||||
setServers(servers: string[]): void {
|
||||
this.s.value = { ...this.s.value, servers };
|
||||
}
|
||||
|
||||
/** phase 由 PlatformSession 从 NetClient 事件总线驱动,session.ts 唯一调用方。 */
|
||||
setPhase(phase: ConnectionPhase): void {
|
||||
this.s.value = { ...this.s.value, phase };
|
||||
}
|
||||
|
||||
/**
|
||||
* login 回填 A 组字段。语义分两类:
|
||||
* - 身份字段(agentid/channelid/agentmode/gameversion):缺省时保留旧值(避免身份错乱)
|
||||
* - 显示字段(agentname):缺省时重置为空(避免显示旧值)
|
||||
*/
|
||||
applyLogin(raw: LoginResponseData | AppLoginData): void {
|
||||
const r = raw as AppLoginData;
|
||||
this.s.value = {
|
||||
...this.s.value,
|
||||
agentid: r.agentid ?? this.s.value.agentid,
|
||||
channelid: r.channelid ?? this.s.value.channelid,
|
||||
agentmode: r.agentmode ?? this.s.value.agentmode,
|
||||
gameversion: r.gameversion ?? this.s.value.gameversion,
|
||||
agentname: r.agentname ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c3961eae-ab46-4dc1-8822-25cdd39025db",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
import { signal, type ReadonlyReactive } from '../../core/reactive.ts';
|
||||
import type { PlayerState } from './types.ts';
|
||||
import type { ReadonlyPlayerStore } from '../readonly.ts';
|
||||
import type { LoginResponseData } from '../../core/types/login.ts';
|
||||
|
||||
const INITIAL: PlayerState = {
|
||||
playerid: 0, nickname: '', avatar: '', sex: 0,
|
||||
bean: 0, roomcard: 0, score: 0, charm: 0,
|
||||
taskstate: 0, advanced: 0,
|
||||
bankpower: 0, bank: 0, bankpwd: 0,
|
||||
ip: '', sign: null, tel: null,
|
||||
invitecode: null, initCard: 0, initBean: 0,
|
||||
};
|
||||
|
||||
/** 玩家自身状态(= 旧 C_Player)。 */
|
||||
export class PlayerStore implements ReadonlyPlayerStore {
|
||||
private s = signal<PlayerState>({ ...INITIAL });
|
||||
get state(): ReadonlyReactive<PlayerState> { return this.s; }
|
||||
|
||||
/** 从 login 响应 A 组填充。字段名对齐 protocol/04(直接取服务器下发值)。 */
|
||||
applyLogin(raw: LoginResponseData): void {
|
||||
const r = raw as any;
|
||||
this.s.value = {
|
||||
...this.s.value,
|
||||
playerid: r.playerid ?? 0,
|
||||
nickname: r.nickname ?? '', avatar: r.avatar ?? '', sex: r.sex ?? 0,
|
||||
bean: r.bean ?? 0, roomcard: r.roomcard ?? 0, score: r.score ?? 0, charm: r.charm ?? 0,
|
||||
taskstate: r.taskstate ?? 0, advanced: r.advanced ?? 0,
|
||||
bankpower: r.bankpower ?? 0, bank: r.bank ?? 0, bankpwd: r.bankpwd ?? 0,
|
||||
ip: r.ip ?? '', sign: r.sign ?? null, tel: r.tel ?? null,
|
||||
invitecode: r.invitecode ?? null, initCard: r.initCard ?? 0, initBean: r.initBean ?? 0,
|
||||
...(r.openid != null ? { openid: String(r.openid) } : {}),
|
||||
...(r.unionid != null ? { unionid: r.unionid } : {}),
|
||||
...(r.province != null ? { province: String(r.province) } : {}),
|
||||
...(r.city != null ? { city: String(r.city) } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "df507042-7da9-4688-9e89-b8b220b6b71b",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
import { signal, type ReadonlyReactive } from '../../core/reactive.ts';
|
||||
import type { RoomState, PlayerState } from './types.ts';
|
||||
import type { ReadonlyRoomStore } from '../readonly.ts';
|
||||
|
||||
/** RoomState 初始值:所有座位为空。 */
|
||||
const INITIAL: RoomState = {
|
||||
inRoom: false,
|
||||
roomcode: null,
|
||||
isbattle: 0,
|
||||
roommode: 0,
|
||||
seat: -1,
|
||||
isowner: 0,
|
||||
asetcount: 0,
|
||||
makewar: 0,
|
||||
needprepare: 0,
|
||||
infinite: 0,
|
||||
state: 0,
|
||||
players: [],
|
||||
deskinfo: null,
|
||||
};
|
||||
|
||||
/** RoomStore 的入参契约(B 组,从 login 响应或 rpc 回包填充)。 */
|
||||
export interface RoomRecoveryData {
|
||||
roomcode?: string | null;
|
||||
isbattle?: number;
|
||||
roommode?: number;
|
||||
seat?: number;
|
||||
isowner?: number;
|
||||
asetcount?: number;
|
||||
makewar?: number;
|
||||
needprepare?: number;
|
||||
infinite?: number;
|
||||
players?: Array<PlayerState | null>;
|
||||
deskinfo?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* RoomStore:房间/牌桌状态(= 旧 Desk)。
|
||||
* login 含 roomcode 时或在房状态下填充;离开/未在房时 clear()。
|
||||
*/
|
||||
export class RoomStore implements ReadonlyRoomStore {
|
||||
private s = signal<RoomState>({ ...INITIAL });
|
||||
get state(): ReadonlyReactive<RoomState> { return this.s; }
|
||||
|
||||
applyRecovery(raw: RoomRecoveryData): void {
|
||||
this.s.value = {
|
||||
...this.s.value,
|
||||
inRoom: true,
|
||||
roomcode: raw.roomcode ?? null,
|
||||
isbattle: raw.isbattle ?? 0,
|
||||
roommode: raw.roommode ?? 0,
|
||||
seat: raw.seat ?? -1,
|
||||
isowner: raw.isowner ?? 0,
|
||||
asetcount: raw.asetcount ?? 0,
|
||||
makewar: raw.makewar ?? 0,
|
||||
needprepare: raw.needprepare ?? 0,
|
||||
infinite: raw.infinite ?? 0,
|
||||
players: raw.players ?? [],
|
||||
// deskinfo 原样透传(C 规范 §5 边界判据 2):不深拷贝、不解析
|
||||
deskinfo: raw.deskinfo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 离开房间/未在房,复位。 */
|
||||
clear(): void {
|
||||
this.s.value = { ...INITIAL };
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "9cba57c5-ce0c-4345-8ee2-0448112aa312",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
import type { ChannelIdentity } from '../../config/identity.ts';
|
||||
|
||||
/** PlayerState(C_Player A 组,login 始终下发;openid 等仅 deviceLogin 下发,做可选)。 */
|
||||
export interface PlayerState {
|
||||
playerid: number;
|
||||
nickname: string; avatar: string; sex: number;
|
||||
openid?: string; unionid?: string | number; province?: string; city?: string;
|
||||
bean: number; roomcard: number; score: number; charm: number;
|
||||
taskstate: number; advanced: number;
|
||||
bankpower: number; bank: number; bankpwd: number;
|
||||
ip: string; sign: string | null; tel: string | null;
|
||||
invitecode: string | null; initCard: number | string; initBean: number | string;
|
||||
}
|
||||
|
||||
export type ConnectionPhase =
|
||||
| 'idle' | 'connecting' | 'connected' | 'loggedIn'
|
||||
| 'reconnecting' | 'slow' | 'kicked' | 'loginFailed';
|
||||
|
||||
/** AppState(GameData 子集:身份 + 服务器 + 代理/版本 + 连接相位)。 */
|
||||
export interface AppState {
|
||||
identity: ChannelIdentity | null; // 来自 bootstrap
|
||||
servers: string[];
|
||||
agentid: string | number; // login 回填(旧 GameData.AgentId,protocol/04:183)
|
||||
channelid: string | number; // login 回填(旧 GameData.ChannelId,protocol/04:184)
|
||||
agentname: string;
|
||||
agentmode: number;
|
||||
gameversion: number;
|
||||
phase: ConnectionPhase;
|
||||
}
|
||||
|
||||
/** RoomState(Desk B 组,仅在房/重连时下发)。 */
|
||||
export interface RoomState {
|
||||
inRoom: boolean;
|
||||
roomcode: string | null;
|
||||
isbattle: number; // 0未开局/1已开局
|
||||
roommode: number; // 0普通/1星星场
|
||||
seat: number; // 自己的座位(-1 未入座)
|
||||
isowner: number; // 1房主/0非
|
||||
asetcount: number; makewar: number; needprepare: number; infinite: number;
|
||||
/**
|
||||
* 房间解散状态:0=正常 1=投票中 2=已解散。
|
||||
* 业务侧触发清理/离开。
|
||||
*/
|
||||
state: 0 | 1 | 2;
|
||||
/**
|
||||
* 按座位下标的玩家数组。空座位用 null 占位(不是 undefined、不是空对象),
|
||||
* 长度由 maxPlayerCount 决定,由 RoomStore.applyRecovery 校验填充。
|
||||
*/
|
||||
players: Array<PlayerState | null>;
|
||||
/**
|
||||
* 子游戏对局快照,opaque 透传(C 规范 §5 边界判据 2:平台层不持有、不解析)。
|
||||
*/
|
||||
deskinfo: unknown;
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "04c35baf-db68-4431-85cc-0591d00679cc",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "12ec1513-c5c6-4b9e-8ef5-662a22075f89",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import type { IGameModule } from '../sdk/index.ts';
|
||||
|
||||
/**
|
||||
* 当前激活的子游戏槽(= 旧 Game_Modify 的运行时角色)。
|
||||
*
|
||||
* 框架在进房时 setGame(),离房时 clear()。Router 据此分发对局 route 的包。
|
||||
* 单例设计:同一时刻只允许一个子游戏激活(与旧 12_Logic 一致)。
|
||||
*/
|
||||
export class ActiveGame {
|
||||
private game: IGameModule | null = null;
|
||||
|
||||
get(): IGameModule | null { return this.game; }
|
||||
|
||||
set(game: IGameModule): void { this.game = game; }
|
||||
|
||||
clear(): void { this.game = null; }
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d8981b12-16b5-49a7-95d6-a51eb735462a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import type { LoginRequestData, LoginResponseData } from '../core/types/login.ts';
|
||||
|
||||
/** 构造 player_login 请求 data。字段以 docs/protocol/01 §6、02 为准。 */
|
||||
export function buildLoginRequest(identity: LoginRequestData): LoginRequestData {
|
||||
return { ...identity };
|
||||
}
|
||||
|
||||
export interface ParsedLogin {
|
||||
ok: boolean; // state===0
|
||||
playerid: number;
|
||||
inRoom: boolean; // 含 roomcode → 需恢复房间
|
||||
hasBattle: boolean; // isbattle===1 或含 deskinfo → 需重连对局
|
||||
deskinfo: unknown; // 子游戏对局快照(原样透传,见 docs/protocol/05)
|
||||
raw: LoginResponseData;
|
||||
}
|
||||
|
||||
/** 解析 player_login 响应(最小集)。完整字段见 docs/protocol/04。 */
|
||||
export function parseLoginResponse(data: LoginResponseData): ParsedLogin {
|
||||
const roomcode = (data as any).roomcode;
|
||||
const deskinfo = (data as any).deskinfo;
|
||||
return {
|
||||
ok: data.state === 0,
|
||||
playerid: data.playerid,
|
||||
inRoom: roomcode != null && roomcode !== '',
|
||||
hasBattle: (data as any).isbattle === 1 || deskinfo != null,
|
||||
deskinfo,
|
||||
raw: data,
|
||||
};
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2a6f7d59-e84e-4ff7-9061-1a092191e1e5",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
import type { RoomStore } from '../platform/stores/room-store.ts';
|
||||
import type { PlayerStore } from '../platform/stores/player-store.ts';
|
||||
|
||||
/**
|
||||
* Room handler 上下文:注入 RoomStore + 自己的 PlayerStore(部分 handler 需要)。
|
||||
*
|
||||
* 业务侧(PlatformSession)在收到 room route rpc 时,从 NetClient 事件总线
|
||||
* 取出 Envelope{route:'room', rpc, data} → Router 派发 → 查 roomHandlers[rpc](ctx, data)。
|
||||
*/
|
||||
export interface RoomHandlerContext {
|
||||
room: RoomStore;
|
||||
player: PlayerStore;
|
||||
}
|
||||
|
||||
/** 单个 handler 的数据形参 —— 各 rpc 不同,这里只规定最基础形状。 */
|
||||
export type RoomRpcData = Record<string, unknown>;
|
||||
|
||||
/** 单个 handler 的签名 */
|
||||
export type RoomHandler = (ctx: RoomHandlerContext, data: RoomRpcData) => void;
|
||||
|
||||
/**
|
||||
* 把任意 rpc 名映射到对应 handler;未注册的显式抛错(第二准则:不静默吞包)。
|
||||
*
|
||||
* 为避免构造后被外部写入不安全 handler,采用 Object.freeze。
|
||||
*/
|
||||
function defineHandlers(table: Record<string, RoomHandler>): Readonly<Record<string, RoomHandler>> {
|
||||
const wrapped: Record<string, RoomHandler> = {};
|
||||
for (const [k, v] of Object.entries(table)) wrapped[k] = v;
|
||||
return Object.freeze(wrapped);
|
||||
}
|
||||
|
||||
function seatIndex(seat: unknown, max: number): number {
|
||||
if (typeof seat !== 'number' || !Number.isInteger(seat) || seat < 1 || seat > max) {
|
||||
throw new Error(
|
||||
`Room handler: 无效座位 seat=${String(seat)}(1 基,有效范围 1..${max})`,
|
||||
);
|
||||
}
|
||||
return seat - 1; // 1 基 → 0 基
|
||||
}
|
||||
|
||||
export const roomHandlers = defineHandlers({
|
||||
/** 自己加入房间(通常在 login/self_join_room 走完) */
|
||||
self_join_room(ctx, data) {
|
||||
const seat = seatIndex(data.seat, ctx.room.state.value.players.length || 8);
|
||||
// 自己加入:把 seat 写入(其它 fields 由 login/applyRecovery 统一管理)
|
||||
(ctx.room.state.value as any).seat = seat + 1;
|
||||
},
|
||||
|
||||
/** 第三方加入:把对应 seat 位置填上 PlayerState */
|
||||
other_join_room(ctx, data) {
|
||||
const len = ctx.room.state.value.players.length;
|
||||
const i = seatIndex(data.seat, len);
|
||||
const next = [...ctx.room.state.value.players];
|
||||
next[i] = {
|
||||
playerid: data.playerid as number,
|
||||
nickname: data.nickname as string,
|
||||
avatar: (data.avatar as string) ?? '',
|
||||
sex: (data.sex as number) ?? 0,
|
||||
bean: (data.bean as number) ?? 0,
|
||||
roomcard: (data.roomcard as number) ?? 0,
|
||||
score: 0, charm: 0, taskstate: 0, advanced: 0,
|
||||
bankpower: 0, bank: 0, bankpwd: 0,
|
||||
ip: '', sign: null, tel: null,
|
||||
invitecode: null, initCard: 0, initBean: 0,
|
||||
};
|
||||
(ctx.room.state.value as any).players = next;
|
||||
},
|
||||
|
||||
/** 自己退出:清空自己座位;PlayerStore 不动(退出房间不抹掉登录态) */
|
||||
self_exit_room(ctx, data) {
|
||||
const len = ctx.room.state.value.players.length;
|
||||
const i = seatIndex(data.seat, len);
|
||||
const next = [...ctx.room.state.value.players];
|
||||
next[i] = null;
|
||||
(ctx.room.state.value as any).players = next;
|
||||
},
|
||||
|
||||
/** 第三方退出:清空对应座位 */
|
||||
other_exit_room(ctx, data) {
|
||||
const len = ctx.room.state.value.players.length;
|
||||
const i = seatIndex(data.seat, len);
|
||||
const next = [...ctx.room.state.value.players];
|
||||
next[i] = null;
|
||||
(ctx.room.state.value as any).players = next;
|
||||
},
|
||||
|
||||
/** 自己发起解散投票:state=1 */
|
||||
self_apply_free_room(ctx, _data) {
|
||||
(ctx.room.state.value as any).state = 1;
|
||||
},
|
||||
|
||||
/** 第三方发起解散投票:state=1 */
|
||||
other_apply_free_room(ctx, _data) {
|
||||
(ctx.room.state.value as any).state = 1;
|
||||
},
|
||||
|
||||
/** 同意解散:不改变 state(agreeList 由其他协议累计) */
|
||||
self_agree_free_room(_ctx, _data) { /* no-op */ },
|
||||
other_agree_free_room(_ctx, _data) { /* no-op */ },
|
||||
|
||||
/** 拒绝解散:不改变 state */
|
||||
self_refuse_free_room(_ctx, _data) { /* no-op */ },
|
||||
other_refuse_free_room(_ctx, _data) { /* no-op */ },
|
||||
|
||||
/** 离线/上线:RoomStore 不持有 offline 字段,handler 是 no-op(业务侧订阅子游戏 IGameModule 钩子) */
|
||||
other_offline(_ctx, _data) { /* no-op */ },
|
||||
other_online(_ctx, _data) { /* no-op */ },
|
||||
|
||||
/** 换座位:从一个座位换到另一个 */
|
||||
change_seat(ctx, data) {
|
||||
const len = ctx.room.state.value.players.length;
|
||||
const from = seatIndex(data.from, len);
|
||||
const to = seatIndex(data.to, len);
|
||||
if (from === to) return;
|
||||
const next = [...ctx.room.state.value.players];
|
||||
[next[from], next[to]] = [next[to], next[from]];
|
||||
(ctx.room.state.value as any).players = next;
|
||||
},
|
||||
|
||||
/** 解散投票结束:state=2(业务侧触发清场逻辑) */
|
||||
free_room(ctx, _data) {
|
||||
(ctx.room.state.value as any).state = 2;
|
||||
},
|
||||
});
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "54af01a5-c0dc-4df2-8792-c609f4a74015",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
import type { InboundMessage } from '../core/types/envelope.ts';
|
||||
import { Route } from '../core/constants.ts';
|
||||
import type { ActiveGame } from './active-game.ts';
|
||||
|
||||
/** 平台层 handler 表:key = rpc 字符串(与协议逐字对齐)。 */
|
||||
export type PlatformHandlers = Record<string, (data: unknown) => void>;
|
||||
|
||||
/**
|
||||
* Router:收包路由分界(C §4.1 的红线)。
|
||||
*
|
||||
* 分发规则:
|
||||
* - route ∈ {platform, agent, room} → 平台 handlers(RpcList 覆盖的包)
|
||||
* - route = <game route>(= activeGame.route)→ activeGame.onReceive
|
||||
* - 其他或未注册:显式抛错(第二准则:不静默吞包)
|
||||
*
|
||||
* 不持有状态——只做消息分发。状态由 PlatformSession/子游戏维护。
|
||||
*/
|
||||
export class Router {
|
||||
constructor(
|
||||
private readonly opts: {
|
||||
activeGame: ActiveGame;
|
||||
platformHandlers: PlatformHandlers;
|
||||
},
|
||||
) {}
|
||||
|
||||
dispatch(msg: InboundMessage): void {
|
||||
const { route, rpc, data } = msg;
|
||||
// 平台三层 route:platform/agent/room 全部走平台 handlers
|
||||
if (route === Route.platform || route === Route.agent || route === Route.room) {
|
||||
const handler = this.opts.platformHandlers[rpc];
|
||||
if (!handler) {
|
||||
throw new Error(
|
||||
`Router 收到未注册的平台 rpc: route=${route} rpc=${rpc}(platformHandlers 未注册,第二准则禁止兜底)`,
|
||||
);
|
||||
}
|
||||
handler(data);
|
||||
return;
|
||||
}
|
||||
// 对局 route:必须与 activeGame.route 严格匹配
|
||||
const game = this.opts.activeGame.get();
|
||||
if (!game) {
|
||||
throw new Error(
|
||||
`Router 收到对局包但无激活子游戏: route=${route} rpc=${rpc}(未进房或子游戏未注册)`,
|
||||
);
|
||||
}
|
||||
if (game.route !== route) {
|
||||
throw new Error(
|
||||
`Router route 与 activeGame.route 不匹配: msg.route=${route} activeGame.route=${game.route}`,
|
||||
);
|
||||
}
|
||||
game.onReceive(rpc, data);
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b7886dd6-a54a-48de-880f-bf4c50d86a75",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
import { Route } from '../core/constants.ts';
|
||||
export { Route };
|
||||
|
||||
/**
|
||||
* 协议层 rpc 名(与 docs/protocol/02、03 及 projects/Game_Surface_3/js/00_Surface/02_Const.js 逐字对齐)。
|
||||
*
|
||||
* 字面量联合类型:业务侧发包(NetClient.send)/收包 handler(platformHandlers + roomHandlers + sdk onReceive)入参必须在此集合内,
|
||||
* 拼错编译期就发现;未注册的 rpc 运行时显式抛错(第二准则:不静默兜底)。
|
||||
*/
|
||||
export const Rpc = {
|
||||
// ---- 登录与连接 ----
|
||||
player_login: 'player_login',
|
||||
kick_server: 'kick_server',
|
||||
connect_roomserver: 'connect_roomserver',
|
||||
connect_agentserver: 'connect_agentserver',
|
||||
agentserver_game: 'agentserver_game',
|
||||
|
||||
// ---- 房间生命周期(room route)----
|
||||
create_room: 'create_room',
|
||||
self_join_room: 'self_join_room',
|
||||
other_join_room: 'other_join_room',
|
||||
self_exit_room: 'self_exit_room',
|
||||
other_exit_room: 'other_exit_room',
|
||||
self_break_room: 'self_break_room',
|
||||
other_break_room: 'other_break_room',
|
||||
player_prepare: 'player_prepare',
|
||||
change_seat: 'change_seat',
|
||||
change_room: 'change_room',
|
||||
switchRoomList: 'switchRoomList',
|
||||
|
||||
// ---- 对局(room route)----
|
||||
self_makewar: 'self_makewar',
|
||||
other_makewar: 'other_makewar',
|
||||
over_game: 'over_game',
|
||||
get_player_grade1: 'get_player_grade1',
|
||||
get_player_grade2: 'get_player_grade2',
|
||||
send_gift: 'send_gift',
|
||||
other_send_gift: 'other_send_gift',
|
||||
beanroom_surrender: 'beanroom_surrender',
|
||||
send_phiz: 'send_phiz',
|
||||
broadcast: 'broadcast',
|
||||
other_offline: 'other_offline',
|
||||
other_online: 'other_online',
|
||||
|
||||
// ---- 解散投票(room route)----
|
||||
self_apply_free_room: 'self_apply_free_room',
|
||||
other_apply_free_room: 'other_apply_free_room',
|
||||
self_agree_free_room: 'self_agree_free_room',
|
||||
other_agree_free_room: 'other_agree_free_room',
|
||||
self_refuse_free_room: 'self_refuse_free_room',
|
||||
other_refuse_free_room: 'other_refuse_free_room',
|
||||
free_room: 'free_room',
|
||||
|
||||
// ---- 聊天 / 语音 / 电话(room route)----
|
||||
send_voice: 'send_voice',
|
||||
play_voice: 'play_voice',
|
||||
send_text: 'send_text',
|
||||
receive_chat: 'receive_chat',
|
||||
call_phone: 'call_phone',
|
||||
other_callphone: 'other_callphone',
|
||||
hangup_phone: 'hangup_phone',
|
||||
other_hangup: 'other_hangup',
|
||||
kick_offline: 'kick_offline',
|
||||
|
||||
// ---- 资产 / 经济(agent/room route)----
|
||||
update_roomcard: 'update_roomcard',
|
||||
update_bean: 'update_bean',
|
||||
update_charm: 'update_charm',
|
||||
giveCoin: 'giveCoin',
|
||||
change_star: 'change_star',
|
||||
topup_card: 'topup_card',
|
||||
get_paylist: 'get_paylist',
|
||||
pay_succ: 'pay_succ',
|
||||
get_treasurelist: 'get_treasurelist',
|
||||
|
||||
// ---- 任务(agent route)----
|
||||
get_player_task: 'get_player_task',
|
||||
player_finish_task: 'player_finish_task',
|
||||
get_task_award: 'get_task_award',
|
||||
can_award: 'can_award',
|
||||
|
||||
// ---- 社交 / 邀请 / 分享(agent route)----
|
||||
binding_invitecode: 'binding_invitecode',
|
||||
get_player_invitecode: 'get_player_invitecode',
|
||||
share_room: 'share_room',
|
||||
get_share_room: 'get_share_room',
|
||||
quick_enter_share_room: 'quick_enter_share_room',
|
||||
|
||||
// ---- 房间列表 / 高级(agent route)----
|
||||
advanced_roomlist: 'advanced_roomlist',
|
||||
advanced_createroom: 'advanced_createroom',
|
||||
query_player2: 'query_player2',
|
||||
|
||||
// ---- 银行 / 资料(agent route)----
|
||||
set_bankpwd: 'set_bankpwd',
|
||||
|
||||
// ---- 手机绑定 / 验证码(agent route)----
|
||||
binding_phone: 'binding_phone',
|
||||
send_phone_checkcode: 'send_phone_checkcode',
|
||||
send_phone_code_wechat: 'send_phone_code_wechat',
|
||||
submit_phoneinfo: 'submit_phoneinfo',
|
||||
|
||||
// ---- 位置 / 反馈(agent route)----
|
||||
submit_location: 'submit_location',
|
||||
submit_opinion: 'submit_opinion',
|
||||
|
||||
// ---- 签名 / 短码房间(agent route)----
|
||||
setSign: 'setSign',
|
||||
getInfoByShortCode: 'getInfoByShortCode',
|
||||
optBanList: 'optBanList',
|
||||
getShortCodeRankList: 'getShortCodeRankList',
|
||||
|
||||
// ---- VIP / 黑名单 / 白名单(agent route)----
|
||||
setAllCharm: 'setAllCharm',
|
||||
getVipRankList: 'getVipRankList',
|
||||
playerBehavior: 'playerBehavior',
|
||||
setVipForbidSelect: 'setVipForbidSelect',
|
||||
getPlayerWhiteList: 'getPlayerWhiteList',
|
||||
optWhiteList: 'optWhiteList',
|
||||
|
||||
// ---- H5 专用(agent route,由 joinRoom 入口直达)----
|
||||
joinRoom: 'joinRoom',
|
||||
} as const;
|
||||
|
||||
/** 全部 rpc 名的字面量联合类型(编译期校验入参) */
|
||||
export type RpcName = (typeof Rpc)[keyof typeof Rpc];
|
||||
|
||||
/** Route + Rpc 二元组的契约(用于 typed send / typed receive) */
|
||||
export interface Envelope<T = unknown> {
|
||||
route: typeof Route[keyof typeof Route];
|
||||
rpc: RpcName;
|
||||
data: T;
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "49733e35-e0cc-4db2-8264-4e8988c94a92",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import { Route } from '../core/constants.ts';
|
||||
import type { RpcName } from './routes.ts';
|
||||
|
||||
/**
|
||||
* RpcRouteMap:rpc → route 的映射表(按协议分类)。
|
||||
*
|
||||
* - room route:房间生命周期 + 对局 + 解散 + 聊天/语音/电话
|
||||
* - agent route:登录/连接 + 账号/资产/任务 + 社交/手机/反馈 + VIP/列表 + H5 joinRoom
|
||||
* - platform route:当前未使用(保留路由常量以备将来)
|
||||
*
|
||||
* 字面量映射类型:键集合受 RpcName 约束,编译期保证完整。
|
||||
* 未列出的 rpc 编译期即报错(漏报 = 编译失败)。
|
||||
*/
|
||||
export interface RpcRouteMap {
|
||||
[rpc: string]: typeof Route[keyof typeof Route];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查一个 rpc 的目标 route(运行时安全网:未列出的 rpc 显式抛错)。
|
||||
*/
|
||||
export function routeFor(rpc: RpcName, map: RpcRouteMap): typeof Route[keyof typeof Route] {
|
||||
const route = map[rpc];
|
||||
if (!route) {
|
||||
throw new Error(
|
||||
`routeFor: 未映射 rpc "${rpc}"(RPC_ROUTE 表里漏报;第二准则:不静默兜底)`,
|
||||
);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
/** Typed envelope: 与旧 Envelope<T> 对齐,业务侧可直接传给 NetClient.send */
|
||||
export interface TypedEnvelope<T = unknown> {
|
||||
route: typeof Route[keyof typeof Route];
|
||||
rpc: RpcName;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* typedSend: 用映射表构造一个 typed envelope。
|
||||
*
|
||||
* 业务侧(NetClient 的 sendTyped 包装):
|
||||
* netClient.sendRaw(typedSend(MAP, 'self_join_room', { seat: 1 }).rpc 与 route 与 data)
|
||||
*
|
||||
* 注:本函数只构造 envelope,不直接 send——send 由 NetClient 负责。
|
||||
*/
|
||||
export function typedSend<T = unknown>(
|
||||
map: RpcRouteMap,
|
||||
rpc: RpcName,
|
||||
data: T,
|
||||
): TypedEnvelope<T> {
|
||||
return { route: routeFor(rpc, map), rpc, data };
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "5cf5a8d4-c476-4821-928b-d2802b05b650",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "63599a5c-873f-4c0e-aa44-f0b766988db5",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user