poi-plugin-mcp 0.2.13 → 0.2.15
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/README.md +151 -101
- package/index.js +1 -0
- package/lib/bridge-controller.js +1 -0
- package/lib/poi-action-events.js +3 -0
- package/lib/poi-http-bridge.js +7 -0
- package/lib/poi-telemetry.js +58 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,151 +1,201 @@
|
|
|
1
1
|
# poi-plugin-mcp
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
authenticated input.
|
|
3
|
+
`poi-plugin-mcp` 是运行在 Poi 内的本地数据桥。它把当前舰队、舰娘、装备、
|
|
4
|
+
资源、任务、陆航和相关主数据通过本机 HTTP 与 MCP 提供给本机工具。
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
默认监听:
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
```none
|
|
9
|
+
http://127.0.0.1:17777
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
服务只绑定本机回环地址 `127.0.0.1`,不对局域网或公网监听。本文介绍用于
|
|
13
|
+
读取 Poi 状态的 MCP 工具、MCP 资源和 HTTP 数据端点。
|
|
14
|
+
|
|
15
|
+
## 安装与重启
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
在 Poi 插件管理器中安装 npm 包:
|
|
18
|
+
|
|
19
|
+
```none
|
|
12
20
|
poi-plugin-mcp
|
|
13
21
|
```
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
也可以完全退出 Poi 后,在 Poi 插件目录中手动安装:
|
|
16
24
|
|
|
17
25
|
```powershell
|
|
18
26
|
cd "$env:APPDATA\poi\plugins"
|
|
19
27
|
npm install poi-plugin-mcp
|
|
20
28
|
```
|
|
21
29
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
## Development Install
|
|
30
|
+
安装或更新后完整退出并重新启动 Poi。在插件设置中确认“`MCP 数据桥`”已启用,
|
|
31
|
+
然后进入游戏;需要读取账号数据的端点要等 Poi store 初始化完成。
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
如果 MCP 客户端要使用 stdio 方式,可另外全局安装同一个 npm 包,使
|
|
34
|
+
`poi-mcp` 命令进入 `PATH`:
|
|
27
35
|
|
|
28
36
|
```powershell
|
|
29
|
-
npm install
|
|
30
|
-
|
|
31
|
-
npm pack --dry-run --ignore-scripts
|
|
37
|
+
npm install -g poi-plugin-mcp
|
|
38
|
+
Get-Command poi-mcp
|
|
32
39
|
```
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
exit Poi before a manual npm installation into its plugin directory.
|
|
41
|
+
## 健康检查与端口发现
|
|
36
42
|
|
|
37
|
-
|
|
43
|
+
默认端口的检查命令:
|
|
38
44
|
|
|
39
|
-
|
|
45
|
+
```powershell
|
|
46
|
+
Invoke-RestMethod http://127.0.0.1:17777/health
|
|
47
|
+
```
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
- manually starting and stopping the local bridge
|
|
43
|
-
- enabling WebView input, which defaults to off
|
|
49
|
+
正常响应:
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
```json
|
|
52
|
+
{"status":"ok"}
|
|
53
|
+
```
|
|
48
54
|
|
|
49
|
-
|
|
55
|
+
插件启动后会把实际端口写入:
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
| `/basic` | Admiral profile |
|
|
55
|
-
| `/fleets` | Raw Poi fleet data |
|
|
56
|
-
| `/ships` | Raw owned ship instance data |
|
|
57
|
-
| `/equipment` | Raw owned equipment instance data |
|
|
58
|
-
| `/resources` | Resource array |
|
|
59
|
-
| `/quests` | Active quests and quest records |
|
|
60
|
-
| `/quest-list` | Latest complete quest-page telemetry |
|
|
61
|
-
| `/quest-action` | Latest successful quest start or stop request |
|
|
62
|
-
| `/equipment-action` | Latest successful equipment mutation request |
|
|
63
|
-
| `/fleet-action` | Latest successful fleet-position mutation request |
|
|
64
|
-
| `/action-events` | Bounded generic KanColle API action-event stream |
|
|
65
|
-
| `/airbase` | Land base air squadron data |
|
|
66
|
-
| `/names` | Ship, equipment, and mission name maps |
|
|
67
|
-
| `/master` | Master ship, equipment, ship type, equipment type, mission, and bounded equipment compatibility data |
|
|
68
|
-
| `/event` | Event ship tag definitions plus owned ships' current sally area |
|
|
69
|
-
| `/planner` | Ship Info deck planner areas and ship assignments |
|
|
70
|
-
| `/battle` | Observed battle packets, official settlement, and compact Prophet state |
|
|
71
|
-
| `/screenshot` | In-memory PNG capture of the game WebView |
|
|
72
|
-
| `/input/status` | Whether authenticated WebView input is enabled |
|
|
73
|
-
| `/input/lease/*` | Acquire, renew, release, inspect, or revoke the one input lease |
|
|
74
|
-
| `/input` | Authenticated, serialized WebView input |
|
|
75
|
-
| `/all` | Combined basic runtime data |
|
|
76
|
-
|
|
77
|
-
`/screenshot` accepts `GET` only. It uses Poi's existing
|
|
78
|
-
`screenshot::get` WebContents capture path and returns PNG base64 in JSON. It
|
|
79
|
-
does not save a file, write the clipboard, capture the desktop, or appear in
|
|
80
|
-
MCP resources and tools. Its response disables CORS and uses
|
|
81
|
-
`Cache-Control: no-store`.
|
|
82
|
-
|
|
83
|
-
### WebView Input
|
|
84
|
-
|
|
85
|
-
`GET /input/status` returns only `{"enabled":true|false}`. Input requires both
|
|
86
|
-
`Authorization: Bearer <token>` and one active expiring lease. A static token
|
|
87
|
-
alone cannot authorize `POST /input`. Requests accept at most 64 KiB of JSON
|
|
88
|
-
and work only while WebView input is enabled in the plugin settings. Input
|
|
89
|
-
routes disable CORS and return `Cache-Control: no-store`.
|
|
90
|
-
|
|
91
|
-
Each POST accepts exactly one operation:
|
|
57
|
+
```none
|
|
58
|
+
%USERPROFILE%\.poi-mcp\port
|
|
59
|
+
```
|
|
92
60
|
|
|
93
|
-
|
|
94
|
-
|
|
61
|
+
修改过端口时,应以该文件为准:
|
|
62
|
+
|
|
63
|
+
```powershell
|
|
64
|
+
$port = (Get-Content "$HOME\.poi-mcp\port" -Raw).Trim()
|
|
65
|
+
Invoke-RestMethod "http://127.0.0.1:$port/health"
|
|
95
66
|
```
|
|
96
67
|
|
|
97
|
-
|
|
98
|
-
current game WebView. Supported buttons are `left`, `middle`, and `right`.
|
|
68
|
+
根路径 `/` 返回 404 不代表服务故障,请使用 `/health`。
|
|
99
69
|
|
|
100
|
-
|
|
101
|
-
|
|
70
|
+
## MCP 接入
|
|
71
|
+
|
|
72
|
+
插件提供两种 MCP 连接方式:
|
|
73
|
+
|
|
74
|
+
- **HTTP MCP**:客户端直接连接 Poi 内的 `POST /mcp`。
|
|
75
|
+
- **stdio MCP**:客户端启动 npm 安装的 `poi-mcp` 命令;该命令读取端口文件,
|
|
76
|
+
再访问 Poi 的本地数据 API。
|
|
77
|
+
|
|
78
|
+
两种方式都要求 Poi 正在运行且插件已加载。
|
|
79
|
+
|
|
80
|
+
### HTTP MCP
|
|
81
|
+
|
|
82
|
+
MCP URL:
|
|
83
|
+
|
|
84
|
+
```none
|
|
85
|
+
http://127.0.0.1:17777/mcp
|
|
102
86
|
```
|
|
103
87
|
|
|
104
|
-
|
|
105
|
-
|
|
88
|
+
这是 JSON-RPC over HTTP 端点,请由 MCP 客户端向它发送 `POST` 请求。Codex
|
|
89
|
+
配置示例:
|
|
106
90
|
|
|
107
|
-
```
|
|
108
|
-
|
|
91
|
+
```toml
|
|
92
|
+
[mcp_servers.poi]
|
|
93
|
+
url = "http://127.0.0.1:17777/mcp"
|
|
109
94
|
```
|
|
110
95
|
|
|
111
|
-
|
|
112
|
-
`End`, `Enter`, `Escape`, `Home`, `PageDown`, `PageUp`, `Space`, `Tab`, and the
|
|
113
|
-
four arrow keys.
|
|
96
|
+
使用 JSON 配置的 MCP 客户端可采用:
|
|
114
97
|
|
|
115
98
|
```json
|
|
116
|
-
{
|
|
99
|
+
{
|
|
100
|
+
"mcpServers": {
|
|
101
|
+
"poi": {
|
|
102
|
+
"url": "http://127.0.0.1:17777/mcp"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
117
106
|
```
|
|
118
107
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
sequence, so concurrent requests cannot interleave their WebView events.
|
|
108
|
+
若修改过端口,请把 URL 中的 `17777` 替换为端口文件中的值。
|
|
109
|
+
|
|
110
|
+
### stdio CLI
|
|
123
111
|
|
|
124
|
-
|
|
112
|
+
`poi-mcp` 是 MCP stdio 服务命令,不是交互式终端程序。MCP 客户端应负责启动
|
|
113
|
+
并通过标准输入输出与它通信。
|
|
125
114
|
|
|
126
|
-
|
|
115
|
+
Codex 配置示例:
|
|
127
116
|
|
|
128
|
-
```
|
|
129
|
-
|
|
117
|
+
```toml
|
|
118
|
+
[mcp_servers.poi]
|
|
119
|
+
command = "poi-mcp"
|
|
120
|
+
args = []
|
|
130
121
|
```
|
|
131
122
|
|
|
132
|
-
|
|
133
|
-
`tools/list`, and `tools/call`. Resource URIs mirror the HTTP endpoints,
|
|
134
|
-
including `poi://ships`, `poi://equipment`, `poi://resources`, `poi://master`,
|
|
135
|
-
`poi://event`, and `poi://planner`.
|
|
123
|
+
通用 JSON 配置示例:
|
|
136
124
|
|
|
137
|
-
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"mcpServers": {
|
|
128
|
+
"poi": {
|
|
129
|
+
"command": "poi-mcp",
|
|
130
|
+
"args": []
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
138
135
|
|
|
139
|
-
-
|
|
140
|
-
|
|
141
|
-
- `search_equipment`
|
|
142
|
-
- `get_resources`
|
|
143
|
-
- `get_all`
|
|
136
|
+
如果桌面客户端找不到 npm 的全局命令,请先运行 `Get-Command poi-mcp`,再把
|
|
137
|
+
配置中的 `command` 改为返回的完整可执行文件路径。
|
|
144
138
|
|
|
145
|
-
##
|
|
139
|
+
## MCP 工具
|
|
146
140
|
|
|
147
|
-
|
|
141
|
+
| 工具 | 用途 |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `get_fleet_status` | 按 `fleetId`(1-4)读取一支舰队及舰娘、装备实例 |
|
|
144
|
+
| `search_ships` | 按可选的 `minLevel`、`maxLevel`、`minMorale` 筛选持有舰娘 |
|
|
145
|
+
| `search_equipment` | 按可选的 `minLevel` 筛选持有装备 |
|
|
146
|
+
| `get_resources` | 读取当前资源数组 |
|
|
147
|
+
| `get_all` | 读取基础汇总;`include` 可选 `master`、`event`、`planner` |
|
|
148
|
+
|
|
149
|
+
## MCP 资源
|
|
150
|
+
|
|
151
|
+
可通过 `resources/list` 和 `resources/read` 读取:
|
|
152
|
+
|
|
153
|
+
```none
|
|
154
|
+
poi://basic
|
|
155
|
+
poi://fleets
|
|
156
|
+
poi://ships
|
|
157
|
+
poi://equipment
|
|
158
|
+
poi://resources
|
|
159
|
+
poi://quests
|
|
160
|
+
poi://airbase
|
|
161
|
+
poi://names
|
|
162
|
+
poi://master
|
|
163
|
+
poi://event
|
|
164
|
+
poi://planner
|
|
165
|
+
poi://all
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## 数据端点
|
|
169
|
+
|
|
170
|
+
主要的本地 HTTP 数据查询端点:
|
|
171
|
+
|
|
172
|
+
| 端点 | 内容 |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `GET /health` | bridge 健康状态 |
|
|
175
|
+
| `GET /basic` | 提督基础信息 |
|
|
176
|
+
| `GET /fleets` | 舰队数据 |
|
|
177
|
+
| `GET /ships` | 持有舰娘实例 |
|
|
178
|
+
| `GET /equipment` | 持有装备实例 |
|
|
179
|
+
| `GET /resources` | 当前资源 |
|
|
180
|
+
| `GET /quests` | 当前任务与任务记录 |
|
|
181
|
+
| `GET /airbase` | 基地航空队 |
|
|
182
|
+
| `GET /names` | 舰娘、装备和远征名称映射 |
|
|
183
|
+
| `GET /master` | 舰娘、装备、类型、远征等主数据 |
|
|
184
|
+
| `GET /event` | 活动标签定义与持有舰娘出击标签 |
|
|
185
|
+
| `GET /planner` | Ship Info 配装规划数据 |
|
|
186
|
+
| `GET /battle` | 已观测战斗数据、结算与简化战斗状态 |
|
|
187
|
+
| `GET /all` | 基础运行数据汇总 |
|
|
188
|
+
| `POST /mcp` | HTTP MCP JSON-RPC 入口 |
|
|
189
|
+
|
|
190
|
+
## 开发验证
|
|
191
|
+
|
|
192
|
+
在源码目录执行:
|
|
148
193
|
|
|
149
194
|
```powershell
|
|
150
|
-
npm
|
|
195
|
+
npm install
|
|
196
|
+
npm test
|
|
197
|
+
npm pack --dry-run --ignore-scripts
|
|
151
198
|
```
|
|
199
|
+
|
|
200
|
+
`npm test` 应运行 `node --test test/*.test.js`;打包预览应包含
|
|
201
|
+
`index.js`、`lib`、`mcp-server.js` 和本 README。
|
package/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const controller = createBridgeController({
|
|
|
7
7
|
getQuestList: telemetry.getQuestList,
|
|
8
8
|
getQuestAction: telemetry.getQuestAction,
|
|
9
9
|
getEquipmentAction: telemetry.getEquipmentAction,
|
|
10
|
+
getUnsetSlot: telemetry.getUnsetSlot,
|
|
10
11
|
getFleetAction: telemetry.getFleetAction,
|
|
11
12
|
getActionEvents: telemetry.getActionEvents,
|
|
12
13
|
getBattleTelemetry: telemetry.getBattleTelemetry,
|
package/lib/bridge-controller.js
CHANGED
|
@@ -33,6 +33,7 @@ function createBridgeController(options = {}) {
|
|
|
33
33
|
getQuestList: options.getQuestList,
|
|
34
34
|
getQuestAction: options.getQuestAction,
|
|
35
35
|
getEquipmentAction: options.getEquipmentAction,
|
|
36
|
+
getUnsetSlot: options.getUnsetSlot,
|
|
36
37
|
getFleetAction: options.getFleetAction,
|
|
37
38
|
getActionEvents: options.getActionEvents,
|
|
38
39
|
getBattleTelemetry: options.getBattleTelemetry,
|
package/lib/poi-action-events.js
CHANGED
|
@@ -22,6 +22,7 @@ const EXACT_PATHS = new Set([
|
|
|
22
22
|
'/kcsapi/api_get_member/ship2',
|
|
23
23
|
'/kcsapi/api_get_member/ship3',
|
|
24
24
|
'/kcsapi/api_get_member/slot_item',
|
|
25
|
+
'/kcsapi/api_get_member/unsetslot',
|
|
25
26
|
'/kcsapi/api_get_member/useitem',
|
|
26
27
|
'/kcsapi/api_req_air_corps/set_action',
|
|
27
28
|
'/kcsapi/api_req_air_corps/set_plane',
|
|
@@ -33,6 +34,7 @@ const EXACT_PATHS = new Set([
|
|
|
33
34
|
'/kcsapi/api_req_hensei/preset_select',
|
|
34
35
|
'/kcsapi/api_req_hokyu/charge',
|
|
35
36
|
'/kcsapi/api_req_kaisou/powerup',
|
|
37
|
+
'/kcsapi/api_req_kaisou/slot_select',
|
|
36
38
|
'/kcsapi/api_req_kaisou/slot_deprive',
|
|
37
39
|
'/kcsapi/api_req_kaisou/slotset',
|
|
38
40
|
'/kcsapi/api_req_kaisou/slotset_ex',
|
|
@@ -67,6 +69,7 @@ const ALLOWED_PREFIXES = [
|
|
|
67
69
|
|
|
68
70
|
const SHARED_TELEMETRY_TIMESTAMP_PATHS = new Set([
|
|
69
71
|
'/kcsapi/api_get_member/questlist',
|
|
72
|
+
'/kcsapi/api_get_member/unsetslot',
|
|
70
73
|
'/kcsapi/api_req_quest/start',
|
|
71
74
|
'/kcsapi/api_req_quest/stop',
|
|
72
75
|
'/kcsapi/api_req_kaisou/slotset',
|
package/lib/poi-http-bridge.js
CHANGED
|
@@ -40,6 +40,8 @@ function createPoiDataBridge(options = {}) {
|
|
|
40
40
|
const getQuestAction = options.getQuestAction || (() => ({ available: false, generation: 0 }))
|
|
41
41
|
const getEquipmentAction = options.getEquipmentAction ||
|
|
42
42
|
(() => ({ available: false, generation: 0 }))
|
|
43
|
+
const getUnsetSlot = options.getUnsetSlot ||
|
|
44
|
+
(() => ({ available: false, generation: 0 }))
|
|
43
45
|
const getFleetAction = options.getFleetAction ||
|
|
44
46
|
(() => ({ available: false, generation: 0 }))
|
|
45
47
|
const getActionEvents = options.getActionEvents ||
|
|
@@ -367,6 +369,11 @@ function createPoiDataBridge(options = {}) {
|
|
|
367
369
|
return
|
|
368
370
|
}
|
|
369
371
|
|
|
372
|
+
if (endpoint === '/unsetslot') {
|
|
373
|
+
sendJson(res, 200, getUnsetSlot())
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
|
|
370
377
|
if (endpoint === '/fleet-action') {
|
|
371
378
|
sendJson(res, 200, getFleetAction())
|
|
372
379
|
return
|
package/lib/poi-telemetry.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { createPoiActionEvents } = require('./poi-action-events')
|
|
2
2
|
|
|
3
3
|
const QUEST_LIST_PATH = '/kcsapi/api_get_member/questlist'
|
|
4
|
+
const UNSET_SLOT_PATH = '/kcsapi/api_get_member/unsetslot'
|
|
4
5
|
const QUEST_ACTION_PATHS = new Set([
|
|
5
6
|
'/kcsapi/api_req_quest/start',
|
|
6
7
|
'/kcsapi/api_req_quest/stop',
|
|
@@ -49,6 +50,8 @@ function createPoiTelemetry(options = {}) {
|
|
|
49
50
|
let questAction = null
|
|
50
51
|
let equipmentActionGeneration = 0
|
|
51
52
|
let equipmentAction = null
|
|
53
|
+
let unsetSlotGeneration = 0
|
|
54
|
+
let unsetSlot = null
|
|
52
55
|
let fleetActionGeneration = 0
|
|
53
56
|
let fleetAction = null
|
|
54
57
|
let battleGeneration = 0
|
|
@@ -63,6 +66,10 @@ function createPoiTelemetry(options = {}) {
|
|
|
63
66
|
captureQuestList(detail, actionEvent)
|
|
64
67
|
return
|
|
65
68
|
}
|
|
69
|
+
if (detail.path === UNSET_SLOT_PATH) {
|
|
70
|
+
captureUnsetSlot(detail, actionEvent)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
66
73
|
if (QUEST_ACTION_PATHS.has(detail.path)) {
|
|
67
74
|
captureQuestAction(detail, actionEvent)
|
|
68
75
|
return
|
|
@@ -166,6 +173,24 @@ function createPoiTelemetry(options = {}) {
|
|
|
166
173
|
}
|
|
167
174
|
}
|
|
168
175
|
|
|
176
|
+
function captureUnsetSlot(detail, actionEvent) {
|
|
177
|
+
const bySlotType = normalizeUnsetSlotBody(detail.body)
|
|
178
|
+
if (bySlotType == null) return
|
|
179
|
+
|
|
180
|
+
unsetSlotGeneration += 1
|
|
181
|
+
unsetSlot = {
|
|
182
|
+
available: true,
|
|
183
|
+
generation: unsetSlotGeneration,
|
|
184
|
+
capturedAt: actionEvent
|
|
185
|
+
? actionEvent.capturedAt
|
|
186
|
+
: now().toISOString(),
|
|
187
|
+
bySlotType,
|
|
188
|
+
equipmentIds: Object.values(bySlotType)
|
|
189
|
+
.flatMap((groups) => Object.values(groups))
|
|
190
|
+
.flat(),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
169
194
|
function captureFleetAction(detail, actionEvent) {
|
|
170
195
|
const apiResult = actionEvent
|
|
171
196
|
? actionEvent.apiResult
|
|
@@ -252,6 +277,9 @@ function createPoiTelemetry(options = {}) {
|
|
|
252
277
|
getEquipmentAction() {
|
|
253
278
|
return equipmentAction || { available: false, generation: 0 }
|
|
254
279
|
},
|
|
280
|
+
getUnsetSlot() {
|
|
281
|
+
return unsetSlot || { available: false, generation: 0 }
|
|
282
|
+
},
|
|
255
283
|
getFleetAction() {
|
|
256
284
|
return fleetAction || { available: false, generation: 0 }
|
|
257
285
|
},
|
|
@@ -264,6 +292,36 @@ function createPoiTelemetry(options = {}) {
|
|
|
264
292
|
}
|
|
265
293
|
}
|
|
266
294
|
|
|
295
|
+
function normalizeUnsetSlotBody(body) {
|
|
296
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return null
|
|
297
|
+
const source = body.api_data &&
|
|
298
|
+
typeof body.api_data === 'object' &&
|
|
299
|
+
!Array.isArray(body.api_data)
|
|
300
|
+
? body.api_data
|
|
301
|
+
: body
|
|
302
|
+
const output = {}
|
|
303
|
+
let totalIds = 0
|
|
304
|
+
|
|
305
|
+
for (const [slotType, groups] of Object.entries(source)) {
|
|
306
|
+
if (!/^api_slottype\d+$/.test(slotType)) continue
|
|
307
|
+
if (!groups || typeof groups !== 'object' || Array.isArray(groups)) continue
|
|
308
|
+
const normalizedGroups = {}
|
|
309
|
+
for (const [groupId, ids] of Object.entries(groups)) {
|
|
310
|
+
if (!/^\d+$/.test(groupId) || !Array.isArray(ids)) continue
|
|
311
|
+
const normalizedIds = ids
|
|
312
|
+
.map(positiveIntegerOrNull)
|
|
313
|
+
.filter((id) => id != null)
|
|
314
|
+
totalIds += normalizedIds.length
|
|
315
|
+
if (totalIds > 10000) return null
|
|
316
|
+
normalizedGroups[groupId] = normalizedIds
|
|
317
|
+
}
|
|
318
|
+
if (Object.keys(normalizedGroups).length > 0) {
|
|
319
|
+
output[slotType] = normalizedGroups
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return Object.keys(output).length > 0 ? output : null
|
|
323
|
+
}
|
|
324
|
+
|
|
267
325
|
function normalizeFleetPostBody(postBody) {
|
|
268
326
|
if (!postBody || typeof postBody !== 'object') return null
|
|
269
327
|
const fleetId = boundedInteger(postBody.api_id, 1, 4)
|