@cx00123/lunetale-game-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/package.json +43 -0
- package/src/index.d.ts +49 -0
- package/src/index.js +234 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lunetale SDK contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Lunetale 小游戏 AI / VIP 插件
|
|
2
|
+
|
|
3
|
+
为 Lunetale 内的网页小游戏提供 AI 对话、真实 VIP/SVIP 查询。**当前原生接入支持 Android;iOS 未接入。** 游戏不需要 App 用户 ID、App 登录 token 或模型 API Key。
|
|
4
|
+
|
|
5
|
+
## 1. 安装与最小接入
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @cx00123/lunetale-game-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import { createGameClient, GameSDKError } from '@cx00123/lunetale-game-sdk'
|
|
13
|
+
|
|
14
|
+
// Android 内无需填写域名、用户、游戏 ID 或密钥。
|
|
15
|
+
const game = createGameClient()
|
|
16
|
+
const membership = await game.getVIP()
|
|
17
|
+
console.log(membership.isVip, membership.tier, membership.cost)
|
|
18
|
+
|
|
19
|
+
const input = {
|
|
20
|
+
requestId: crypto.randomUUID(),
|
|
21
|
+
messages: [
|
|
22
|
+
{ role: 'system', content: '你是岛上的向导,用简短中文回答。' },
|
|
23
|
+
{ role: 'user', content: '我应该先探索哪里?' },
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const reply = await game.chat(input)
|
|
28
|
+
document.querySelector('#reply').textContent = reply.content
|
|
29
|
+
console.log(reply.charged, reply.balance)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error instanceof GameSDKError) {
|
|
32
|
+
// NETWORK / 42001 时保留 input,稍后 game.chat(input)。
|
|
33
|
+
// 不要为了重试生成新编号,否则可能作为新问题再次收费。
|
|
34
|
+
console.warn(error.code, error.message, error.requestId)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 页面退出时可调用;只清除本页内存/等待,不表示服务端撤销或退款。
|
|
39
|
+
// game.dispose()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
无打包工具时,也可以在顶层 HTML 中通过 ESM CDN 导入(建议固定版本并在生产环境自行打包托管):
|
|
43
|
+
|
|
44
|
+
```html
|
|
45
|
+
<script type="module">
|
|
46
|
+
import { createGameClient } from 'https://cdn.jsdelivr.net/npm/@cx00123/lunetale-game-sdk@0.1.0/src/index.js'
|
|
47
|
+
const game = createGameClient()
|
|
48
|
+
const vip = await game.getVIP()
|
|
49
|
+
document.querySelector('#vip').textContent = vip.isVip ? '会员' : '普通用户'
|
|
50
|
+
</script>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
SDK 不包含 UI,也不会自动执行模型回复。请用文本绑定或 `textContent` 展示;不要把回复直接写入 `innerHTML`、`eval`。
|
|
54
|
+
|
|
55
|
+
## 2. 平台管理员准备
|
|
56
|
+
|
|
57
|
+
1. 发布支持本 SDK 的后端并完成游戏定向迁移;安装新版 Android App。
|
|
58
|
+
2. 「App 管理 → 大模型管理」建立并启用模型组,配置服务端上游密钥。
|
|
59
|
+
3. 「App 管理 → 游戏管理 → 编辑」填写游戏 HTTPS 地址,配置:
|
|
60
|
+
- `aiEnabled`:AI 开关(老游戏迁移默认关闭)。
|
|
61
|
+
- `aiGroup`:模型组名称或 ID;网页不能覆盖模型。
|
|
62
|
+
- `aiCost`:每次成功调用积分,最多 4 位小数,0 表示免费。**积分复用 App 能量余额,不新建钱包。**
|
|
63
|
+
- `aiMaxTokens`:输出上限,1–8192,默认 1024。
|
|
64
|
+
- `aiSystemPrompt`:前置服务端系统提示词,不是密钥或安全权限规则。
|
|
65
|
+
- `vipOnly`:AI 仅限有效 VIP/SVIP;会员也按该游戏配置扣费,不自动免费。
|
|
66
|
+
- `runtimeOrigin`:最终运行页面的**精确 HTTPS origin**,例如 `https://games.example.com`,不带路径、末尾 `/`、通配符、用户名或密码。空值使用游戏 HTML 地址的 origin;跨域中转到 CDN 时需登记最终 CDN origin。
|
|
67
|
+
4. 游戏要上架。SDK 必须运行在该 origin 的**顶层页面**;iframe、官网连接页和未登记来源没有原生 AI 桥。
|
|
68
|
+
5. 后台「AI / VIP 插件接入」可查看简版说明。普通后台预览没有玩家授权,也不会偷偷消耗管理员或玩家积分。
|
|
69
|
+
|
|
70
|
+
授权粒度是账号 + 游戏 + origin,不是 CDN 路径。同一 CDN origin 下的页面与脚本处于同一个浏览器信任域;不可信制作者应使用独立 origin。平台只应登记受信任内容,不应在游戏页面加载不可信第三方脚本。
|
|
71
|
+
|
|
72
|
+
## 3. 授权与续期
|
|
73
|
+
|
|
74
|
+
### Android(已接入)
|
|
75
|
+
|
|
76
|
+
`SDK → Android 主框架来源校验 → Flutter 当前账号 → 后端授权码 → SDK 兑换专用凭证`
|
|
77
|
+
|
|
78
|
+
- Flutter 用自己的 App JWT 调用 `POST /lunetale/game/authorize`;原生桥不接受网页指定 userId、gameId、模型、价格或任意 URL。
|
|
79
|
+
- 授权码随机生成,**60 秒、只能兑换一次**,数据库只存其哈希。
|
|
80
|
+
- Android 通过消息桥交付授权码,不把 App JWT 或授权码追加到游戏 URL。相比 fragment,原生消息桥避免额外跳转并保持已有 hash 路由。
|
|
81
|
+
- SDK 兑换**2 小时**游戏专用签名凭证,只放内存;不会写 localStorage、sessionStorage、Cookie 或云存档。
|
|
82
|
+
- 后端检查专用签名/受众/有效期、绑定账号与游戏、精确 origin、账号状态、游戏上架与 AI/VIP 权限。修改 JSON 中的用户、游戏、价格或模型不能越权(未知字段直接拒绝)。
|
|
83
|
+
- 成功请求完成时,如果凭证**尚未过期且剩余 ≤10 分钟**,响应中携带新的 2 小时凭证,SDK 自动替换。续期失败不把已经完成的扣费请求伪装成失败;下次可重新授权。
|
|
84
|
+
- 已过期:SDK 通过 App 当前登录态重新签发授权码,最多补授权重试一次;App 登录失效则要求用户重新登录。请求取消/账号切换/页面导航后的迟到桥回调不会发送到新页面。
|
|
85
|
+
- App 的正常后端退出登录接口撤销该用户全部小游戏凭证;禁用/注销账号、游戏下架或更换 runtimeOrigin 后请求也会被拒绝。仅断网清除本机登录态无法承诺即时服务端撤销,未撤销凭证仍以服务端有效期为准。
|
|
86
|
+
- Android 的 Runtime HTTP 请求由 Flutter 固定路径代理;网页即使知道当前开发 API 地址,也不能要求代理访问任意 URL。HTTPS 游戏连接本地 HTTP 开发 API 不需要启用 WebView 混合内容。生产仍必须使用 HTTPS API。
|
|
87
|
+
|
|
88
|
+
### 独立浏览器 / 外部可信登录入口
|
|
89
|
+
|
|
90
|
+
本 SDK 支持读取 `#lt_code=<60秒授权码>` 并**同步立即从地址栏删除**;保留其它 hash 片段。已有 hash 路由可在末尾追加 `<_code=...`。不要用 URL query 传授权码,不要传 App JWT。
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
const game = createGameClient({
|
|
94
|
+
apiBase: 'https://api.example.com', // 如代理有 /api 前缀,请包含此前缀
|
|
95
|
+
// 可选:过期后由你自己的可信登录入口重新授权,只返回授权码。
|
|
96
|
+
// authorize: async () => ({ code, origin, expiresAt }),
|
|
97
|
+
})
|
|
98
|
+
const vip = await game.getVIP()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
可信入口在自己的已登录环境调用 `/lunetale/game/authorize`(`x-token: App JWT`,body 为 `{gameId}`),校验跳转目标等于返回的 origin,再将授权码放进已登记游戏 URL 的 fragment。该入口必须隔离于第三方游戏,不能让游戏拿到 JWT。
|
|
102
|
+
|
|
103
|
+
**当前交付未新增独立官网玩家登录系统。** 顶层官网提供文档入口;离开 Android 后如果没有可信授权入口,SDK 返回 `AUTH_REQUIRED`,不会伪造游客身份、VIP 或免费 AI。普通网页刷新后丢失内存凭证,需要新授权码;这是预期行为。可用 `authorize` 回调接入已有可信入口,跨窗口通信须严格校验 origin/source,禁止 `postMessage('*')` 传授权码。
|
|
104
|
+
|
|
105
|
+
CDN 的 HTML、图片等静态文件仍可公开访问;这些凭证保护的是账号、VIP 与计费接口,**不是静态文件防下载机制**。
|
|
106
|
+
|
|
107
|
+
## 4. SDK API
|
|
108
|
+
|
|
109
|
+
| 方法 | 返回 / 行为 |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| `createGameClient(options?)` | 创建一个顶层页面客户端;调用时同步取走并清除 fragment 授权码 |
|
|
112
|
+
| `ready()` | 完成授权;其它方法也会按需自动授权 |
|
|
113
|
+
| `getVIP()` | `{isVip,tier,expiresAt,balance,aiEnabled,cost,vipOnly}` |
|
|
114
|
+
| `isVIP()` | `boolean`;有效 VIP、SVIP 都是 true,查询失败会抛异常,不伪装为 false |
|
|
115
|
+
| `chat({requestId?,messages})` | `{requestId,content,charged,balance}`;非流式完整回答 |
|
|
116
|
+
| `dispose()` | 清除本页凭证并取消客户端等待,实例不能再次使用;不是退款或服务端撤销 |
|
|
117
|
+
|
|
118
|
+
`messages`:1–64 条,role 为 `system/user/assistant`,内容非空,UTF-8 内容合计最多 64 KiB,最后一条必须是 user。每轮需要游戏自己提供所需的历史;SDK 不自动管理历史。服务端额外前置管理员系统提示词。
|
|
119
|
+
|
|
120
|
+
`requestId`:16–64 位字母、数字、`_`、`-`,推荐 `crypto.randomUUID()`。省略时 SDK 自动生成;错误对象携带该编号。推荐显式创建并保存 `input`,保证网络重试不改变编号或历史消息。
|
|
121
|
+
|
|
122
|
+
### 计费 / 并发 / 重试
|
|
123
|
+
|
|
124
|
+
- 使用**请求开始时**的游戏价格;上游成功且结果与积分事务一起提交后才收费。余额不足、无权限、模型失败、超时不收费。
|
|
125
|
+
- 同一账号最多一个进行中的小游戏 AI 请求,且新请求间隔至少 2 秒(含免费调用);这是全后端实例共享的数据库控制。
|
|
126
|
+
- 相同账号 + 游戏 + requestId + 原始 messages 重试返回原成功结果,**不重复调用模型或扣费**。原成功响应中的 balance 是当次历史余额,当前余额请调用 getVIP。
|
|
127
|
+
- 相同 requestId 更换内容会被拒绝。不同的实际问题必须生成新编号。
|
|
128
|
+
- 响应丢失不代表未扣费:`NETWORK` 或 `42001` 请保留原 input,稍后重试。SDK 不会无限自动重试付费请求。
|
|
129
|
+
- 模型最长等待约 2 分钟;记录超过 3 分钟仍 pending 视为未完成终态,不再自动执行该编号。`42002` 确认本编号未提交扣费,可用新编号重试。
|
|
130
|
+
- 成功结果与扣费原子提交;故障遗留的 pending 记录没有扣费。服务端保存成功回复用于幂等恢复,不保存原始 messages,仅保存内容哈希。
|
|
131
|
+
- 页面刷新后 SDK 不保存请求队列。若游戏需要跨刷新恢复,把 requestId 和原始 messages 存在**该游戏自己的待同步记录**中(不包含任何凭证),重新授权后重试。已有 App localStorage 云存档能力独立工作,SDK 不收集账号存储。
|
|
132
|
+
- 页面离开、网络断开和 `dispose()` 只停止客户端等待,不能撤回已在服务端成功提交的生成和扣费。
|
|
133
|
+
|
|
134
|
+
### 错误码
|
|
135
|
+
|
|
136
|
+
| code | 含义 / 处理 |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| `AUTH_REQUIRED` | 专用凭证失效,App 内自动补授权一次后仍失败则要求重新登录/重新打开 |
|
|
139
|
+
| `40201` | 积分不足,引导用户在 App 充值 |
|
|
140
|
+
| `40301` | 需要 VIP/SVIP,由 App 的会员购买流程处理 |
|
|
141
|
+
| `42001` | 原编号仍在处理,稍后用相同 input 重试 |
|
|
142
|
+
| `42002` | 原编号失败且未扣费,可创建新编号 |
|
|
143
|
+
| `42003` | 原编号已绑定不同消息,修复编号复用问题 |
|
|
144
|
+
| `42901` | 有进行中的请求或请求过于频繁,等待后重试 |
|
|
145
|
+
| `7` | 游戏配置/参数/服务不可用,展示 message 并联系平台 |
|
|
146
|
+
| `NETWORK` | 结果未知,保留原 requestId 重试 |
|
|
147
|
+
| `NATIVE / UNSUPPORTED / ORIGIN / CONFIG / VALIDATION / PROTOCOL / DISPOSED / UNKNOWN` | 原生能力、运行环境、来源、参数、协议或生命周期问题 |
|
|
148
|
+
|
|
149
|
+
不要用客户端 `isVip` 值替代后端权限检查,不要在积分不足时偷偷生成新 requestId 重试。
|
|
150
|
+
|
|
151
|
+
## 5. HTTP 契约
|
|
152
|
+
|
|
153
|
+
统一 `{code,data,msg}`。业务失败通常 HTTP 200 + 非零 code,专用凭证无效 HTTP 401。
|
|
154
|
+
|
|
155
|
+
| 方法 | 路径 | 鉴权 | 请求 |
|
|
156
|
+
| --- | --- | --- | --- |
|
|
157
|
+
| POST | `/lunetale/game/authorize` | App `x-token` | `{gameId}` → `{code,expiresAt,origin}` |
|
|
158
|
+
| POST | `/lunetale/game/revoke` | App `x-token` | `{gameId}`,撤销该账号该游戏全部专用授权 |
|
|
159
|
+
| POST | `/lunetale/game/runtime/exchange` | `Origin` + 一次性 code | `{code}` → `{accessToken,expiresAt,gameId}` |
|
|
160
|
+
| GET | `/lunetale/game/runtime/vip` | `Origin` + `Authorization: Bearer <游戏凭证>` | VIPStatus,可选 credential |
|
|
161
|
+
| POST | `/lunetale/game/runtime/chat` | 同上 | `{requestId,messages}` → ChatResult,可选 credential |
|
|
162
|
+
|
|
163
|
+
`credential` 是自动续期数据,形状同 exchange;SDK 自动消费并从公开返回对象中去掉。不允许客户端传 gameId/userId/model/cost/maxTokens 给 runtime/chat。Runtime 凭证签名使用用途隔离的密钥,不能用于 App 或后台接口。
|
|
164
|
+
|
|
165
|
+
浏览器自动发送 Origin,游戏不可自行设置。专用 runtime 三个端点提供无 Cookie 的 HTTPS 跨域预检,实际请求仍严格校验凭证绑定来源;不会扩大普通 App/后台接口 CORS 权限。反向代理不能移除 Origin 或记录 Authorization、授权 body 和对话正文。
|
|
166
|
+
|
|
167
|
+
## 6. 人工验收建议
|
|
168
|
+
|
|
169
|
+
由平台和制作者在目标 Android/后端环境验证:普通账号/VIP/SVIP、会员过期、余额不足、0 积分、重复请求不重复扣费、相同 ID 不同消息、上游超时、返回/刷新/快速重进、换账号、游戏下架、错误 origin、iframe 隔离、授权码重复兑换/过期、10 分钟阈值续期及新授权后恢复原请求。
|
|
170
|
+
|
|
171
|
+
此包的静态语法/类型检查不代表 App、模型调用、真实扣费或会员功能已验收。部署前请完成真实环境人工联调。
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cx00123/lunetale-game-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lunetale HTML game AI chat and VIP runtime SDK for Android WebView and authorized web games",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"import": "./src/index.js",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src/index.js",
|
|
17
|
+
"src/index.d.ts",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"lunetale",
|
|
28
|
+
"html-game",
|
|
29
|
+
"ai",
|
|
30
|
+
"vip",
|
|
31
|
+
"webview"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"registry": "https://registry.npmjs.org/"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"check": "node --check src/index.js && tsc --noEmit --strict --lib es2022,dom src/index.d.ts"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^7.0.2"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type Message = { role: 'system' | 'user' | 'assistant'; content: string }
|
|
2
|
+
export interface AuthorizationGrant {
|
|
3
|
+
code: string
|
|
4
|
+
expiresAt?: string
|
|
5
|
+
origin?: string
|
|
6
|
+
apiBase?: string
|
|
7
|
+
}
|
|
8
|
+
export interface GameClientOptions {
|
|
9
|
+
/** HTTPS API base, including any reverse-proxy prefix; unnecessary inside Android. */
|
|
10
|
+
apiBase?: string
|
|
11
|
+
/** Trusted external launcher reauthorization. Do not put App JWTs in game code. */
|
|
12
|
+
authorize?: () => Promise<AuthorizationGrant>
|
|
13
|
+
}
|
|
14
|
+
export interface VIPStatus {
|
|
15
|
+
isVip: boolean
|
|
16
|
+
tier: 'normal' | 'vip' | 'svip'
|
|
17
|
+
expiresAt: string | null
|
|
18
|
+
balance: number
|
|
19
|
+
aiEnabled: boolean
|
|
20
|
+
cost: number
|
|
21
|
+
vipOnly: boolean
|
|
22
|
+
}
|
|
23
|
+
export interface ChatInput {
|
|
24
|
+
/** Preserve this ID AND the exact messages for network/pending retries. */
|
|
25
|
+
requestId?: string
|
|
26
|
+
messages: Message[]
|
|
27
|
+
}
|
|
28
|
+
export interface ChatResult {
|
|
29
|
+
requestId: string
|
|
30
|
+
content: string
|
|
31
|
+
charged: number
|
|
32
|
+
/** Balance at the original successful transaction; replay does not refresh it. */
|
|
33
|
+
balance: number
|
|
34
|
+
}
|
|
35
|
+
export class GameSDKError extends Error {
|
|
36
|
+
code: string | number
|
|
37
|
+
requestId?: string
|
|
38
|
+
constructor(code: string | number, message: string, options?: { requestId?: string; cause?: unknown })
|
|
39
|
+
}
|
|
40
|
+
export interface GameClient {
|
|
41
|
+
ready(): Promise<void>
|
|
42
|
+
getVIP(): Promise<VIPStatus>
|
|
43
|
+
/** Both active VIP and SVIP count as true. */
|
|
44
|
+
isVIP(): Promise<boolean>
|
|
45
|
+
chat(input: ChatInput): Promise<ChatResult>
|
|
46
|
+
/** Clears memory and cancels local waiting, not a server-side revoke/refund. */
|
|
47
|
+
dispose(): void
|
|
48
|
+
}
|
|
49
|
+
export function createGameClient(options?: GameClientOptions): GameClient
|
package/src/index.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/** Credentials are private closure state; importing this module performs no I/O. */
|
|
2
|
+
export class GameSDKError extends Error {
|
|
3
|
+
constructor(code, message, { requestId, cause } = {}) {
|
|
4
|
+
super(message, { cause })
|
|
5
|
+
this.name = 'GameSDKError'
|
|
6
|
+
this.code = code
|
|
7
|
+
this.requestId = requestId
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const transports = new WeakMap()
|
|
12
|
+
function nativeTransport(bridge) {
|
|
13
|
+
if (transports.has(bridge)) return transports.get(bridge)
|
|
14
|
+
const pending = new Map()
|
|
15
|
+
bridge.onmessage = (event) => {
|
|
16
|
+
let message
|
|
17
|
+
try { message = JSON.parse(event.data) } catch { return }
|
|
18
|
+
const task = pending.get(message.id)
|
|
19
|
+
if (!task) return
|
|
20
|
+
pending.delete(message.id)
|
|
21
|
+
clearTimeout(task.timer)
|
|
22
|
+
if (message.error) task.reject(new GameSDKError('NATIVE', message.error))
|
|
23
|
+
else task.resolve(message.data)
|
|
24
|
+
}
|
|
25
|
+
const transport = {
|
|
26
|
+
call(message, owner, timeout) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const id = crypto.randomUUID()
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
pending.delete(id)
|
|
31
|
+
reject(new GameSDKError('NETWORK', '请求超时,请保留 requestId 重试'))
|
|
32
|
+
}, timeout)
|
|
33
|
+
pending.set(id, { resolve, reject, timer, owner })
|
|
34
|
+
try { bridge.postMessage(JSON.stringify({ ...message, id })) } catch {
|
|
35
|
+
pending.delete(id)
|
|
36
|
+
clearTimeout(timer)
|
|
37
|
+
reject(new GameSDKError('NATIVE', '无法连接 App 游戏接口'))
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
dispose(owner) {
|
|
42
|
+
for (const [id, task] of pending) {
|
|
43
|
+
if (task.owner !== owner) continue
|
|
44
|
+
clearTimeout(task.timer)
|
|
45
|
+
task.reject(new GameSDKError('DISPOSED', '游戏客户端已关闭'))
|
|
46
|
+
pending.delete(id)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
transports.set(bridge, transport)
|
|
51
|
+
return transport
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Remove the code synchronously, before any network wait; preserve other hash parameters.
|
|
55
|
+
function takeFragmentCode() {
|
|
56
|
+
const raw = window.location.hash.slice(1)
|
|
57
|
+
const parts = raw.split('&')
|
|
58
|
+
let code
|
|
59
|
+
const kept = parts.filter((part) => {
|
|
60
|
+
const at = part.indexOf('=')
|
|
61
|
+
if (at < 0 || part.slice(0, at) !== 'lt_code') return true
|
|
62
|
+
try { code = decodeURIComponent(part.slice(at + 1)) } catch { code = '' }
|
|
63
|
+
return false
|
|
64
|
+
})
|
|
65
|
+
if (kept.length !== parts.length) {
|
|
66
|
+
const url = new URL(window.location.href)
|
|
67
|
+
url.hash = kept.join('&')
|
|
68
|
+
window.history.replaceState(window.history.state, '', url)
|
|
69
|
+
}
|
|
70
|
+
return code
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createGameClient(options = {}) {
|
|
74
|
+
if (typeof window === 'undefined' || window.top !== window.self) {
|
|
75
|
+
throw new GameSDKError('UNSUPPORTED', 'SDK 只能在游戏顶层浏览器页面使用')
|
|
76
|
+
}
|
|
77
|
+
const fragmentCode = takeFragmentCode()
|
|
78
|
+
const bridge = window.LunetaleGameRuntime
|
|
79
|
+
const native = bridge && typeof bridge.postMessage === 'function' ? nativeTransport(bridge) : null
|
|
80
|
+
const owner = {}
|
|
81
|
+
const controllers = new Set()
|
|
82
|
+
const timeout = 160000
|
|
83
|
+
let credential = null
|
|
84
|
+
let authorizing = null
|
|
85
|
+
let initialCode = fragmentCode
|
|
86
|
+
let apiBase = options.apiBase || ''
|
|
87
|
+
let disposed = false
|
|
88
|
+
|
|
89
|
+
function checkOpen() {
|
|
90
|
+
if (disposed) throw new GameSDKError('DISPOSED', '游戏客户端已关闭')
|
|
91
|
+
}
|
|
92
|
+
function acceptCredential(value) {
|
|
93
|
+
if (!value || typeof value.accessToken !== 'string' || !Number.isFinite(Date.parse(value.expiresAt))) {
|
|
94
|
+
throw new GameSDKError('PROTOCOL', '游戏授权响应格式错误')
|
|
95
|
+
}
|
|
96
|
+
// Concurrent responses must not replace a newer credential with an older one.
|
|
97
|
+
if (!credential || Date.parse(value.expiresAt) >= Date.parse(credential.expiresAt)) credential = value
|
|
98
|
+
}
|
|
99
|
+
async function transport(action, payload, token = '') {
|
|
100
|
+
checkOpen()
|
|
101
|
+
let status
|
|
102
|
+
let body
|
|
103
|
+
if (native) {
|
|
104
|
+
const result = await native.call({ method: 'runtime', action, payload, token }, owner, timeout)
|
|
105
|
+
status = result?.status
|
|
106
|
+
body = result?.body
|
|
107
|
+
} else {
|
|
108
|
+
let base
|
|
109
|
+
try { base = new URL(apiBase) } catch {
|
|
110
|
+
throw new GameSDKError('CONFIG', '独立网页必须设置可信后端 apiBase')
|
|
111
|
+
}
|
|
112
|
+
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) {
|
|
113
|
+
throw new GameSDKError('CONFIG', 'apiBase 必须是无凭证、无查询参数的 HTTPS 地址')
|
|
114
|
+
}
|
|
115
|
+
const controller = new AbortController()
|
|
116
|
+
controllers.add(controller)
|
|
117
|
+
const timer = setTimeout(() => controller.abort(), timeout)
|
|
118
|
+
try {
|
|
119
|
+
const response = await fetch(`${base.href.replace(/\/$/, '')}/lunetale/game/runtime/${action}`, {
|
|
120
|
+
method: action === 'vip' ? 'GET' : 'POST',
|
|
121
|
+
headers: {
|
|
122
|
+
'Content-Type': 'application/json',
|
|
123
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
124
|
+
},
|
|
125
|
+
body: action === 'vip' ? undefined : JSON.stringify(payload),
|
|
126
|
+
signal: controller.signal,
|
|
127
|
+
credentials: 'omit',
|
|
128
|
+
cache: 'no-store',
|
|
129
|
+
redirect: 'error',
|
|
130
|
+
referrerPolicy: 'no-referrer',
|
|
131
|
+
})
|
|
132
|
+
status = response.status
|
|
133
|
+
body = await response.json()
|
|
134
|
+
} catch {
|
|
135
|
+
throw new GameSDKError('NETWORK', '游戏服务连接失败,请保留 requestId 重试')
|
|
136
|
+
} finally {
|
|
137
|
+
clearTimeout(timer)
|
|
138
|
+
controllers.delete(controller)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
checkOpen()
|
|
142
|
+
if (status === 401) throw new GameSDKError('AUTH_REQUIRED', '请重新授权游戏')
|
|
143
|
+
if (status !== 200 || !body || typeof body.code !== 'number') {
|
|
144
|
+
throw new GameSDKError('PROTOCOL', '游戏服务响应异常')
|
|
145
|
+
}
|
|
146
|
+
if (body.code !== 0) throw new GameSDKError(body.code, body.msg || '游戏请求失败')
|
|
147
|
+
return body.data
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function authorize() {
|
|
151
|
+
checkOpen()
|
|
152
|
+
if (authorizing) return authorizing
|
|
153
|
+
const work = (async () => {
|
|
154
|
+
let grant
|
|
155
|
+
if (initialCode) {
|
|
156
|
+
grant = { code: initialCode }
|
|
157
|
+
initialCode = null
|
|
158
|
+
} else if (native) {
|
|
159
|
+
grant = await native.call({ method: 'authorize' }, owner, 30000)
|
|
160
|
+
} else if (typeof options.authorize === 'function') {
|
|
161
|
+
// Caller must use a trusted login launcher. Never embed an App JWT in game JS.
|
|
162
|
+
grant = await options.authorize()
|
|
163
|
+
} else {
|
|
164
|
+
throw new GameSDKError('AUTH_REQUIRED', '请从 Lunetale App 打开游戏或重新通过可信入口授权')
|
|
165
|
+
}
|
|
166
|
+
checkOpen()
|
|
167
|
+
if (!grant || !/^[a-f0-9]{64}$/.test(grant.code)) throw new GameSDKError('PROTOCOL', '授权码格式错误')
|
|
168
|
+
if (grant.origin && grant.origin !== window.location.origin) throw new GameSDKError('ORIGIN', '游戏授权来源不匹配')
|
|
169
|
+
if (grant.apiBase) apiBase = grant.apiBase
|
|
170
|
+
acceptCredential(await transport('exchange', { code: grant.code }))
|
|
171
|
+
})()
|
|
172
|
+
authorizing = work
|
|
173
|
+
try { await work } finally { if (authorizing === work) authorizing = null }
|
|
174
|
+
}
|
|
175
|
+
async function request(action, payload) {
|
|
176
|
+
checkOpen()
|
|
177
|
+
if (!credential || Date.parse(credential.expiresAt) <= Date.now()) await authorize()
|
|
178
|
+
const used = credential.accessToken
|
|
179
|
+
let data
|
|
180
|
+
try {
|
|
181
|
+
data = await transport(action, payload, used)
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (!(error instanceof GameSDKError) || error.code !== 'AUTH_REQUIRED') throw error
|
|
184
|
+
// Retry authentication once only. Chat payload/requestId remains identical.
|
|
185
|
+
if (credential?.accessToken === used) credential = null
|
|
186
|
+
if (!credential) await authorize()
|
|
187
|
+
data = await transport(action, payload, credential.accessToken)
|
|
188
|
+
}
|
|
189
|
+
if (data?.credential) acceptCredential(data.credential)
|
|
190
|
+
const { credential: ignored, ...result } = data || {}
|
|
191
|
+
void ignored
|
|
192
|
+
return result
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return Object.freeze({
|
|
196
|
+
async ready() {
|
|
197
|
+
checkOpen()
|
|
198
|
+
if (!credential || Date.parse(credential.expiresAt) <= Date.now()) await authorize()
|
|
199
|
+
},
|
|
200
|
+
getVIP() { return request('vip') },
|
|
201
|
+
async isVIP() { return (await request('vip')).isVip === true },
|
|
202
|
+
async chat(input) {
|
|
203
|
+
const requestId = input?.requestId || crypto.randomUUID()
|
|
204
|
+
try {
|
|
205
|
+
if (!/^[A-Za-z0-9_-]{16,64}$/.test(requestId) || !Array.isArray(input?.messages) ||
|
|
206
|
+
input.messages.length < 1 || input.messages.length > 64) {
|
|
207
|
+
throw new GameSDKError('VALIDATION', '需要1至64条消息和16至64位 requestId')
|
|
208
|
+
}
|
|
209
|
+
const messages = input.messages.map(({ role, content }) => {
|
|
210
|
+
if (!['system', 'user', 'assistant'].includes(role) || typeof content !== 'string' || !content.trim()) {
|
|
211
|
+
throw new GameSDKError('VALIDATION', '消息角色或内容无效')
|
|
212
|
+
}
|
|
213
|
+
return { role, content }
|
|
214
|
+
})
|
|
215
|
+
if (messages.at(-1).role !== 'user' ||
|
|
216
|
+
messages.reduce((size, item) => size + new TextEncoder().encode(item.content).length, 0) > 65536) {
|
|
217
|
+
throw new GameSDKError('VALIDATION', '最后一条消息必须为user,内容合计不超过64KiB')
|
|
218
|
+
}
|
|
219
|
+
return await request('chat', { requestId, messages })
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error instanceof GameSDKError) { error.requestId = requestId; throw error }
|
|
222
|
+
throw new GameSDKError('UNKNOWN', '游戏聊天请求失败', { requestId })
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
dispose() {
|
|
226
|
+
disposed = true
|
|
227
|
+
credential = null
|
|
228
|
+
initialCode = null
|
|
229
|
+
native?.dispose(owner)
|
|
230
|
+
for (const controller of controllers) controller.abort()
|
|
231
|
+
controllers.clear()
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
}
|