poi-plugin-mcp 0.2.11 → 0.2.13
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 +20 -13
- package/index.js +2 -0
- package/lib/bridge-controller.js +2 -0
- package/lib/poi-action-events.js +288 -0
- package/lib/poi-http-bridge.js +206 -9
- package/lib/poi-input-lease.js +226 -0
- package/lib/poi-input.js +97 -0
- package/lib/poi-telemetry.js +76 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,20 +23,16 @@ Then restart Poi and enable `MCP Data Bridge` if needed.
|
|
|
23
23
|
|
|
24
24
|
## Development Install
|
|
25
25
|
|
|
26
|
-
When working from this repository,
|
|
26
|
+
When working from this repository, verify the package from the repo root:
|
|
27
27
|
|
|
28
28
|
```powershell
|
|
29
29
|
npm install
|
|
30
|
-
npm
|
|
30
|
+
npm test
|
|
31
|
+
npm pack --dry-run --ignore-scripts
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
The installer creates:
|
|
36
|
-
|
|
37
|
-
```text
|
|
38
|
-
%APPDATA%\poi\plugins\node_modules\poi-plugin-mcp -> <repo>\packages\poi-plugin-mcp
|
|
39
|
-
```
|
|
34
|
+
Publish or install the generated package through Poi's plugin manager. Fully
|
|
35
|
+
exit Poi before a manual npm installation into its plugin directory.
|
|
40
36
|
|
|
41
37
|
## Settings
|
|
42
38
|
|
|
@@ -64,6 +60,8 @@ token is never returned by an HTTP endpoint.
|
|
|
64
60
|
| `/quest-list` | Latest complete quest-page telemetry |
|
|
65
61
|
| `/quest-action` | Latest successful quest start or stop request |
|
|
66
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 |
|
|
67
65
|
| `/airbase` | Land base air squadron data |
|
|
68
66
|
| `/names` | Ship, equipment, and mission name maps |
|
|
69
67
|
| `/master` | Master ship, equipment, ship type, equipment type, mission, and bounded equipment compatibility data |
|
|
@@ -72,6 +70,7 @@ token is never returned by an HTTP endpoint.
|
|
|
72
70
|
| `/battle` | Observed battle packets, official settlement, and compact Prophet state |
|
|
73
71
|
| `/screenshot` | In-memory PNG capture of the game WebView |
|
|
74
72
|
| `/input/status` | Whether authenticated WebView input is enabled |
|
|
73
|
+
| `/input/lease/*` | Acquire, renew, release, inspect, or revoke the one input lease |
|
|
75
74
|
| `/input` | Authenticated, serialized WebView input |
|
|
76
75
|
| `/all` | Combined basic runtime data |
|
|
77
76
|
|
|
@@ -83,10 +82,11 @@ MCP resources and tools. Its response disables CORS and uses
|
|
|
83
82
|
|
|
84
83
|
### WebView Input
|
|
85
84
|
|
|
86
|
-
`GET /input/status` returns only `{"enabled":true|false}`.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
90
|
|
|
91
91
|
Each POST accepts exactly one operation:
|
|
92
92
|
|
|
@@ -97,6 +97,13 @@ Each POST accepts exactly one operation:
|
|
|
97
97
|
Click coordinates use a canonical 1200x720 layout and are scaled to Poi's
|
|
98
98
|
current game WebView. Supported buttons are `left`, `middle`, and `right`.
|
|
99
99
|
|
|
100
|
+
```json
|
|
101
|
+
{"operation":"drag","fromX":247,"fromY":178,"toX":202,"toY":178,"durationMs":400,"button":"left"}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Drag endpoints use the same canonical layout. Duration must be from 50 through
|
|
105
|
+
2000 milliseconds.
|
|
106
|
+
|
|
100
107
|
```json
|
|
101
108
|
{"operation":"key","event":"keyDown","key":"Enter"}
|
|
102
109
|
```
|
package/index.js
CHANGED
|
@@ -7,6 +7,8 @@ const controller = createBridgeController({
|
|
|
7
7
|
getQuestList: telemetry.getQuestList,
|
|
8
8
|
getQuestAction: telemetry.getQuestAction,
|
|
9
9
|
getEquipmentAction: telemetry.getEquipmentAction,
|
|
10
|
+
getFleetAction: telemetry.getFleetAction,
|
|
11
|
+
getActionEvents: telemetry.getActionEvents,
|
|
10
12
|
getBattleTelemetry: telemetry.getBattleTelemetry,
|
|
11
13
|
})
|
|
12
14
|
const settingsClass = createSettingsClass(controller)
|
package/lib/bridge-controller.js
CHANGED
|
@@ -33,6 +33,8 @@ function createBridgeController(options = {}) {
|
|
|
33
33
|
getQuestList: options.getQuestList,
|
|
34
34
|
getQuestAction: options.getQuestAction,
|
|
35
35
|
getEquipmentAction: options.getEquipmentAction,
|
|
36
|
+
getFleetAction: options.getFleetAction,
|
|
37
|
+
getActionEvents: options.getActionEvents,
|
|
36
38
|
getBattleTelemetry: options.getBattleTelemetry,
|
|
37
39
|
inputEnabled: settings.inputEnabled,
|
|
38
40
|
inputToken,
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
const crypto = require('node:crypto')
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CAPACITY = 256
|
|
4
|
+
const DEFAULT_LIMIT = 64
|
|
5
|
+
const MAX_CAPACITY = 256
|
|
6
|
+
const MAX_OBJECT_KEYS = 32
|
|
7
|
+
const MAX_ARRAY_ITEMS = 16
|
|
8
|
+
const MAX_STRING_LENGTH = 256
|
|
9
|
+
const MAX_RESPONSE_DEPTH = 4
|
|
10
|
+
|
|
11
|
+
const EXACT_PATHS = new Set([
|
|
12
|
+
'/kcsapi/api_start2/getData',
|
|
13
|
+
'/kcsapi/api_port/port',
|
|
14
|
+
'/kcsapi/api_get_member/base_air_corps',
|
|
15
|
+
'/kcsapi/api_get_member/deck',
|
|
16
|
+
'/kcsapi/api_get_member/kdock',
|
|
17
|
+
'/kcsapi/api_get_member/mapinfo',
|
|
18
|
+
'/kcsapi/api_get_member/material',
|
|
19
|
+
'/kcsapi/api_get_member/ndock',
|
|
20
|
+
'/kcsapi/api_get_member/practice',
|
|
21
|
+
'/kcsapi/api_get_member/questlist',
|
|
22
|
+
'/kcsapi/api_get_member/ship2',
|
|
23
|
+
'/kcsapi/api_get_member/ship3',
|
|
24
|
+
'/kcsapi/api_get_member/slot_item',
|
|
25
|
+
'/kcsapi/api_get_member/useitem',
|
|
26
|
+
'/kcsapi/api_req_air_corps/set_action',
|
|
27
|
+
'/kcsapi/api_req_air_corps/set_plane',
|
|
28
|
+
'/kcsapi/api_req_air_corps/supply',
|
|
29
|
+
'/kcsapi/api_req_battle_midnight/battle',
|
|
30
|
+
'/kcsapi/api_req_battle_midnight/sp_midnight',
|
|
31
|
+
'/kcsapi/api_req_hensei/change',
|
|
32
|
+
'/kcsapi/api_req_hensei/combined',
|
|
33
|
+
'/kcsapi/api_req_hensei/preset_select',
|
|
34
|
+
'/kcsapi/api_req_hokyu/charge',
|
|
35
|
+
'/kcsapi/api_req_kaisou/powerup',
|
|
36
|
+
'/kcsapi/api_req_kaisou/slot_deprive',
|
|
37
|
+
'/kcsapi/api_req_kaisou/slotset',
|
|
38
|
+
'/kcsapi/api_req_kaisou/slotset_ex',
|
|
39
|
+
'/kcsapi/api_req_kaisou/unsetslot_all',
|
|
40
|
+
'/kcsapi/api_req_kousyou/createitem',
|
|
41
|
+
'/kcsapi/api_req_kousyou/createship',
|
|
42
|
+
'/kcsapi/api_req_kousyou/createship_speedchange',
|
|
43
|
+
'/kcsapi/api_req_kousyou/destroyitem2',
|
|
44
|
+
'/kcsapi/api_req_kousyou/destroyship',
|
|
45
|
+
'/kcsapi/api_req_kousyou/getship',
|
|
46
|
+
'/kcsapi/api_req_kousyou/remodel_slot',
|
|
47
|
+
'/kcsapi/api_req_map/next',
|
|
48
|
+
'/kcsapi/api_req_map/select_eventmap_rank',
|
|
49
|
+
'/kcsapi/api_req_map/start',
|
|
50
|
+
'/kcsapi/api_req_member/get_practice_enemyinfo',
|
|
51
|
+
'/kcsapi/api_req_mission/result',
|
|
52
|
+
'/kcsapi/api_req_mission/start',
|
|
53
|
+
'/kcsapi/api_req_nyukyo/speedchange',
|
|
54
|
+
'/kcsapi/api_req_nyukyo/start',
|
|
55
|
+
'/kcsapi/api_req_practice/battle',
|
|
56
|
+
'/kcsapi/api_req_practice/battle_result',
|
|
57
|
+
'/kcsapi/api_req_practice/midnight_battle',
|
|
58
|
+
'/kcsapi/api_req_quest/clearitemget',
|
|
59
|
+
'/kcsapi/api_req_quest/start',
|
|
60
|
+
'/kcsapi/api_req_quest/stop',
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
const ALLOWED_PREFIXES = [
|
|
64
|
+
'/kcsapi/api_req_sortie/',
|
|
65
|
+
'/kcsapi/api_req_combined_battle/',
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
const SHARED_TELEMETRY_TIMESTAMP_PATHS = new Set([
|
|
69
|
+
'/kcsapi/api_get_member/questlist',
|
|
70
|
+
'/kcsapi/api_req_quest/start',
|
|
71
|
+
'/kcsapi/api_req_quest/stop',
|
|
72
|
+
'/kcsapi/api_req_kaisou/slotset',
|
|
73
|
+
'/kcsapi/api_req_kaisou/slotset_ex',
|
|
74
|
+
'/kcsapi/api_req_kaisou/slot_deprive',
|
|
75
|
+
'/kcsapi/api_req_kaisou/unsetslot_all',
|
|
76
|
+
'/kcsapi/api_req_hensei/change',
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
function createPoiActionEvents(options = {}) {
|
|
80
|
+
const now = options.now || (() => new Date())
|
|
81
|
+
const inferredNow = options.inferredNow || (() => new Date())
|
|
82
|
+
const sessionId = boundedId(options.sessionId || crypto.randomUUID(), 'sessionId')
|
|
83
|
+
const capacity = boundedInteger(
|
|
84
|
+
options.capacity == null ? DEFAULT_CAPACITY : options.capacity,
|
|
85
|
+
1,
|
|
86
|
+
MAX_CAPACITY,
|
|
87
|
+
'capacity',
|
|
88
|
+
)
|
|
89
|
+
const events = []
|
|
90
|
+
let latestGeneration = 0
|
|
91
|
+
|
|
92
|
+
function capture(detail) {
|
|
93
|
+
if (!isCapturableDetail(detail)) return null
|
|
94
|
+
const explicitApiResult = readApiResult(detail)
|
|
95
|
+
const apiResult = explicitApiResult == null && hasUnwrappedResponseBody(detail)
|
|
96
|
+
? 1
|
|
97
|
+
: explicitApiResult
|
|
98
|
+
if (apiResult !== 1) return null
|
|
99
|
+
|
|
100
|
+
latestGeneration += 1
|
|
101
|
+
const eventNow = explicitApiResult != null ||
|
|
102
|
+
SHARED_TELEMETRY_TIMESTAMP_PATHS.has(detail.path)
|
|
103
|
+
? now
|
|
104
|
+
: inferredNow
|
|
105
|
+
const event = Object.freeze({
|
|
106
|
+
generation: latestGeneration,
|
|
107
|
+
capturedAt: timestamp(eventNow()),
|
|
108
|
+
path: detail.path,
|
|
109
|
+
apiResult,
|
|
110
|
+
postBody: sanitizePostBody(detail.postBody),
|
|
111
|
+
responseSummary: sanitizeResponse(detail.body),
|
|
112
|
+
})
|
|
113
|
+
events.push(event)
|
|
114
|
+
if (events.length > capacity) events.splice(0, events.length - capacity)
|
|
115
|
+
return event
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function read(options = {}) {
|
|
119
|
+
const after = boundedInteger(
|
|
120
|
+
options.after == null ? 0 : options.after,
|
|
121
|
+
0,
|
|
122
|
+
Number.MAX_SAFE_INTEGER,
|
|
123
|
+
'after',
|
|
124
|
+
)
|
|
125
|
+
const limit = boundedInteger(
|
|
126
|
+
options.limit == null ? DEFAULT_LIMIT : options.limit,
|
|
127
|
+
1,
|
|
128
|
+
MAX_CAPACITY,
|
|
129
|
+
'limit',
|
|
130
|
+
)
|
|
131
|
+
return {
|
|
132
|
+
available: true,
|
|
133
|
+
sessionId,
|
|
134
|
+
latestGeneration,
|
|
135
|
+
events: events
|
|
136
|
+
.filter((event) => event.generation > after)
|
|
137
|
+
.slice(0, limit),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return Object.freeze({ capture, read })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isCapturableDetail(detail) {
|
|
145
|
+
return Boolean(
|
|
146
|
+
detail &&
|
|
147
|
+
typeof detail === 'object' &&
|
|
148
|
+
!Array.isArray(detail) &&
|
|
149
|
+
typeof detail.path === 'string' &&
|
|
150
|
+
(
|
|
151
|
+
EXACT_PATHS.has(detail.path) ||
|
|
152
|
+
ALLOWED_PREFIXES.some((prefix) => detail.path.startsWith(prefix))
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readApiResult(detail) {
|
|
158
|
+
const candidates = [
|
|
159
|
+
detail.apiResult,
|
|
160
|
+
detail.api_result,
|
|
161
|
+
detail.result,
|
|
162
|
+
detail.body && detail.body.api_result,
|
|
163
|
+
]
|
|
164
|
+
for (const candidate of candidates) {
|
|
165
|
+
if (candidate == null || candidate === '') continue
|
|
166
|
+
const parsed = Number(candidate)
|
|
167
|
+
return Number.isInteger(parsed) ? parsed : null
|
|
168
|
+
}
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function hasUnwrappedResponseBody(detail) {
|
|
173
|
+
if (!Object.prototype.hasOwnProperty.call(detail, 'body')) return false
|
|
174
|
+
const body = detail.body
|
|
175
|
+
if (!isPlainObject(body) && !Array.isArray(body)) return false
|
|
176
|
+
return !(
|
|
177
|
+
isPlainObject(body) &&
|
|
178
|
+
(
|
|
179
|
+
Object.prototype.hasOwnProperty.call(body, 'api_result') ||
|
|
180
|
+
Object.prototype.hasOwnProperty.call(body, 'api_data')
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sanitizePostBody(value) {
|
|
186
|
+
if (!isPlainObject(value)) return {}
|
|
187
|
+
const output = {}
|
|
188
|
+
for (const [key, item] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
|
|
189
|
+
if (!safeKey(key)) continue
|
|
190
|
+
const scalar = sanitizeScalar(item)
|
|
191
|
+
if (scalar !== undefined) {
|
|
192
|
+
output[key] = scalar
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
Array.isArray(item) &&
|
|
197
|
+
item.length <= MAX_ARRAY_ITEMS
|
|
198
|
+
) {
|
|
199
|
+
const array = item.map(sanitizeScalar)
|
|
200
|
+
if (array.every((entry) => entry !== undefined)) output[key] = array
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return output
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sanitizeResponse(value) {
|
|
207
|
+
const sanitized = sanitizeNested(value, 0)
|
|
208
|
+
return isPlainObject(sanitized) ? sanitized : {}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sanitizeNested(value, depth) {
|
|
212
|
+
const scalar = sanitizeScalar(value)
|
|
213
|
+
if (scalar !== undefined) return scalar
|
|
214
|
+
if (depth >= MAX_RESPONSE_DEPTH) return undefined
|
|
215
|
+
if (Array.isArray(value)) {
|
|
216
|
+
return value
|
|
217
|
+
.slice(0, MAX_ARRAY_ITEMS)
|
|
218
|
+
.map((item) => sanitizeNested(item, depth + 1))
|
|
219
|
+
.filter((item) => item !== undefined)
|
|
220
|
+
}
|
|
221
|
+
if (!isPlainObject(value)) return undefined
|
|
222
|
+
const output = {}
|
|
223
|
+
for (const [key, item] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
|
|
224
|
+
if (!safeKey(key)) continue
|
|
225
|
+
const sanitized = sanitizeNested(item, depth + 1)
|
|
226
|
+
if (sanitized !== undefined) output[key] = sanitized
|
|
227
|
+
}
|
|
228
|
+
return output
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function sanitizeScalar(value) {
|
|
232
|
+
if (
|
|
233
|
+
value === null ||
|
|
234
|
+
typeof value === 'boolean' ||
|
|
235
|
+
(typeof value === 'number' && Number.isFinite(value))
|
|
236
|
+
) {
|
|
237
|
+
return value
|
|
238
|
+
}
|
|
239
|
+
if (typeof value === 'string') return value.slice(0, MAX_STRING_LENGTH)
|
|
240
|
+
return undefined
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function safeKey(key) {
|
|
244
|
+
return (
|
|
245
|
+
typeof key === 'string' &&
|
|
246
|
+
key.length > 0 &&
|
|
247
|
+
key.length <= 64 &&
|
|
248
|
+
!/(authorization|cookie|header|password|secret|token)/i.test(key)
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function timestamp(value) {
|
|
253
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
254
|
+
if (!Number.isFinite(date.getTime())) throw new Error('now must return a valid date')
|
|
255
|
+
return date.toISOString()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function boundedId(value, name) {
|
|
259
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
|
|
260
|
+
throw new Error(`${name} must be a non-empty bounded string`)
|
|
261
|
+
}
|
|
262
|
+
return value
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function boundedInteger(value, minimum, maximum, name) {
|
|
266
|
+
if (
|
|
267
|
+
!Number.isSafeInteger(value) ||
|
|
268
|
+
value < minimum ||
|
|
269
|
+
value > maximum
|
|
270
|
+
) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`${name} must be an integer from ${minimum} through ${maximum}`,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
return value
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function isPlainObject(value) {
|
|
279
|
+
return Boolean(
|
|
280
|
+
value &&
|
|
281
|
+
typeof value === 'object' &&
|
|
282
|
+
!Array.isArray(value),
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = {
|
|
287
|
+
createPoiActionEvents,
|
|
288
|
+
}
|
package/lib/poi-http-bridge.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('path')
|
|
|
6
6
|
const packageJson = require('../package.json')
|
|
7
7
|
const { loadOrCreateInputToken } = require('./input-token')
|
|
8
8
|
const { createPoiInputProvider } = require('./poi-input')
|
|
9
|
+
const { createPoiInputLease } = require('./poi-input-lease')
|
|
9
10
|
const { createPoiScreenshotProvider } = require('./poi-screenshot')
|
|
10
11
|
|
|
11
12
|
const DEFAULT_PORT = 17777
|
|
@@ -25,6 +26,8 @@ const JSONRPC_VERSION = '2.0'
|
|
|
25
26
|
const MCP_PROTOCOL_VERSION = '2024-11-05'
|
|
26
27
|
const INPUT_BODY_LIMIT = 64 * 1024
|
|
27
28
|
const MASTER_FILE_LIMIT = 16 * 1024 * 1024
|
|
29
|
+
const DEFAULT_ACTION_EVENT_LIMIT = 64
|
|
30
|
+
const MAX_ACTION_EVENT_LIMIT = 256
|
|
28
31
|
|
|
29
32
|
function createPoiDataBridge(options = {}) {
|
|
30
33
|
const getStore = options.getStore || defaultGetStore
|
|
@@ -37,19 +40,28 @@ function createPoiDataBridge(options = {}) {
|
|
|
37
40
|
const getQuestAction = options.getQuestAction || (() => ({ available: false, generation: 0 }))
|
|
38
41
|
const getEquipmentAction = options.getEquipmentAction ||
|
|
39
42
|
(() => ({ available: false, generation: 0 }))
|
|
43
|
+
const getFleetAction = options.getFleetAction ||
|
|
44
|
+
(() => ({ available: false, generation: 0 }))
|
|
45
|
+
const getActionEvents = options.getActionEvents ||
|
|
46
|
+
(() => ({
|
|
47
|
+
available: false,
|
|
48
|
+
sessionId: null,
|
|
49
|
+
latestGeneration: 0,
|
|
50
|
+
events: [],
|
|
51
|
+
}))
|
|
40
52
|
const getBattleTelemetry = options.getBattleTelemetry ||
|
|
41
53
|
(() => ({ available: false, generation: 0 }))
|
|
42
54
|
const inputEnabled = options.inputEnabled === true
|
|
43
55
|
const inputToken = options.inputToken || (
|
|
44
56
|
inputEnabled ? loadOrCreateInputToken(options.inputTokenFile) : null
|
|
45
57
|
)
|
|
58
|
+
const inputLease = options.inputLease || createPoiInputLease(options.inputLeaseOptions)
|
|
46
59
|
let captureScreenshot = options.captureScreenshot || null
|
|
47
60
|
let performInput = options.performInput || null
|
|
48
61
|
|
|
49
62
|
let server = null
|
|
50
63
|
let actualPort = 0
|
|
51
64
|
let inputPending = Promise.resolve()
|
|
52
|
-
let inputSequence = 0
|
|
53
65
|
|
|
54
66
|
function readStore() {
|
|
55
67
|
const store = getStore()
|
|
@@ -82,17 +94,22 @@ function createPoiDataBridge(options = {}) {
|
|
|
82
94
|
})
|
|
83
95
|
}
|
|
84
96
|
|
|
85
|
-
function enqueueInput(operation) {
|
|
97
|
+
function enqueueInput(operation, claim) {
|
|
86
98
|
const execute = async () => {
|
|
99
|
+
inputLease.assertClaimActive(claim)
|
|
87
100
|
if (!performInput) {
|
|
88
101
|
performInput = createPoiInputProvider({ getStore })
|
|
89
102
|
}
|
|
90
103
|
const operationName = await performInput(operation)
|
|
91
|
-
inputSequence += 1
|
|
92
104
|
return {
|
|
93
105
|
ok: true,
|
|
94
106
|
operation: operationName,
|
|
95
|
-
sequence:
|
|
107
|
+
sequence: claim.sequence,
|
|
108
|
+
leaseId: claim.leaseId,
|
|
109
|
+
ownerSessionId: claim.ownerSessionId,
|
|
110
|
+
runId: claim.runId,
|
|
111
|
+
action: claim.action,
|
|
112
|
+
acceptedAt: claim.acceptedAt,
|
|
96
113
|
}
|
|
97
114
|
}
|
|
98
115
|
const result = inputPending.then(execute, execute)
|
|
@@ -134,14 +151,92 @@ function createPoiDataBridge(options = {}) {
|
|
|
134
151
|
'Input request body exceeds 64KB.',
|
|
135
152
|
)
|
|
136
153
|
const operation = JSON.parse(body || '{}')
|
|
137
|
-
|
|
154
|
+
const { input, claim } = claimInputOperation(inputLease, operation)
|
|
155
|
+
sendInputJson(res, 200, await enqueueInput(input, claim))
|
|
138
156
|
} catch (error) {
|
|
139
|
-
const statusCode = error.code === 'BODY_TOO_LARGE'
|
|
157
|
+
const statusCode = error.statusCode || (error.code === 'BODY_TOO_LARGE'
|
|
140
158
|
? 413
|
|
141
159
|
: /WebView|dimensions/.test(error.message)
|
|
142
160
|
? 503
|
|
143
|
-
: 400
|
|
144
|
-
sendInputJson(res, statusCode, {
|
|
161
|
+
: 400)
|
|
162
|
+
sendInputJson(res, statusCode, {
|
|
163
|
+
...(error.code ? { code: error.code } : {}),
|
|
164
|
+
error: error.message,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function handleInputLeaseRequest(req, res, endpoint) {
|
|
170
|
+
if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
|
|
171
|
+
drainRequest(req)
|
|
172
|
+
sendInputJson(
|
|
173
|
+
res,
|
|
174
|
+
401,
|
|
175
|
+
{ error: 'A valid Bearer token is required.' },
|
|
176
|
+
{ headers: { 'WWW-Authenticate': 'Bearer' } },
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (endpoint === '/input/lease') {
|
|
181
|
+
if (req.method !== 'GET') {
|
|
182
|
+
drainRequest(req)
|
|
183
|
+
sendInputJson(res, 405, { error: 'Input lease status only accepts GET requests.' })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
const lease = inputLease.status()
|
|
187
|
+
sendInputJson(res, 200, { active: lease !== null, lease })
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
if (req.method !== 'POST') {
|
|
191
|
+
drainRequest(req)
|
|
192
|
+
sendInputJson(res, 405, { error: 'Input lease changes only accept POST requests.' })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
if (!inputEnabled) {
|
|
196
|
+
drainRequest(req)
|
|
197
|
+
sendInputJson(res, 403, { error: 'WebView input is disabled.' })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if (requestContentLength(req) > INPUT_BODY_LIMIT) {
|
|
201
|
+
drainRequest(req)
|
|
202
|
+
sendInputJson(res, 413, { error: 'Input request body exceeds 64KB.' })
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const body = await readRequestBody(
|
|
208
|
+
req,
|
|
209
|
+
INPUT_BODY_LIMIT,
|
|
210
|
+
'Input request body exceeds 64KB.',
|
|
211
|
+
)
|
|
212
|
+
const request = JSON.parse(body || '{}')
|
|
213
|
+
if (endpoint === '/input/lease/acquire') {
|
|
214
|
+
const lease = inputLease.acquire(request)
|
|
215
|
+
sendInputJson(res, 200, { active: true, lease })
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (endpoint === '/input/lease/renew') {
|
|
219
|
+
const lease = inputLease.renew(request)
|
|
220
|
+
sendInputJson(res, 200, { active: true, lease })
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
if (endpoint === '/input/lease/release') {
|
|
224
|
+
inputLease.release(request)
|
|
225
|
+
sendInputJson(res, 200, { active: false, released: true })
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
if (endpoint === '/input/lease/revoke') {
|
|
229
|
+
inputLease.revoke(request)
|
|
230
|
+
sendInputJson(res, 200, { active: false, revoked: true })
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
sendInputJson(res, 404, { error: `Unknown input lease endpoint: ${endpoint}` })
|
|
234
|
+
} catch (error) {
|
|
235
|
+
const statusCode = error.statusCode || (error.code === 'BODY_TOO_LARGE' ? 413 : 400)
|
|
236
|
+
sendInputJson(res, statusCode, {
|
|
237
|
+
...(error.code ? { code: error.code } : {}),
|
|
238
|
+
error: error.message,
|
|
239
|
+
})
|
|
145
240
|
}
|
|
146
241
|
}
|
|
147
242
|
|
|
@@ -180,7 +275,11 @@ function createPoiDataBridge(options = {}) {
|
|
|
180
275
|
return
|
|
181
276
|
}
|
|
182
277
|
|
|
183
|
-
const
|
|
278
|
+
const requestUrl = new URL(
|
|
279
|
+
req.url,
|
|
280
|
+
`http://127.0.0.1:${actualPort || configuredPort}`,
|
|
281
|
+
)
|
|
282
|
+
const endpoint = requestUrl.pathname
|
|
184
283
|
|
|
185
284
|
if (endpoint === '/health') {
|
|
186
285
|
sendJson(res, 200, { status: 'ok' })
|
|
@@ -237,6 +336,17 @@ function createPoiDataBridge(options = {}) {
|
|
|
237
336
|
return
|
|
238
337
|
}
|
|
239
338
|
|
|
339
|
+
if (
|
|
340
|
+
endpoint === '/input/lease' ||
|
|
341
|
+
endpoint === '/input/lease/acquire' ||
|
|
342
|
+
endpoint === '/input/lease/renew' ||
|
|
343
|
+
endpoint === '/input/lease/release' ||
|
|
344
|
+
endpoint === '/input/lease/revoke'
|
|
345
|
+
) {
|
|
346
|
+
await handleInputLeaseRequest(req, res, endpoint)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
240
350
|
if (endpoint === '/input') {
|
|
241
351
|
await handleInputRequest(req, res)
|
|
242
352
|
return
|
|
@@ -257,6 +367,36 @@ function createPoiDataBridge(options = {}) {
|
|
|
257
367
|
return
|
|
258
368
|
}
|
|
259
369
|
|
|
370
|
+
if (endpoint === '/fleet-action') {
|
|
371
|
+
sendJson(res, 200, getFleetAction())
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (endpoint === '/action-events') {
|
|
376
|
+
if (req.method !== 'GET') {
|
|
377
|
+
drainRequest(req)
|
|
378
|
+
sendJson(res, 405, {
|
|
379
|
+
error: 'Action events endpoint only accepts GET requests.',
|
|
380
|
+
})
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
sendJson(res, 200, getActionEvents({
|
|
384
|
+
after: clampedQueryInteger(
|
|
385
|
+
requestUrl.searchParams.get('after'),
|
|
386
|
+
0,
|
|
387
|
+
0,
|
|
388
|
+
Number.MAX_SAFE_INTEGER,
|
|
389
|
+
),
|
|
390
|
+
limit: clampedQueryInteger(
|
|
391
|
+
requestUrl.searchParams.get('limit'),
|
|
392
|
+
DEFAULT_ACTION_EVENT_LIMIT,
|
|
393
|
+
1,
|
|
394
|
+
MAX_ACTION_EVENT_LIMIT,
|
|
395
|
+
),
|
|
396
|
+
}))
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
260
400
|
const store = readStore()
|
|
261
401
|
const info = store.info
|
|
262
402
|
|
|
@@ -315,6 +455,11 @@ function createPoiDataBridge(options = {}) {
|
|
|
315
455
|
records: (info.quests && info.quests.records) || {},
|
|
316
456
|
},
|
|
317
457
|
airbase: info.airbase || [],
|
|
458
|
+
repairs: info.repairs || [],
|
|
459
|
+
constructions: info.constructions || [],
|
|
460
|
+
maps: info.maps || {},
|
|
461
|
+
useitems: info.useitems || {},
|
|
462
|
+
sortie: store.sortie || {},
|
|
318
463
|
names: extractNames(store),
|
|
319
464
|
})
|
|
320
465
|
break
|
|
@@ -378,6 +523,53 @@ function createPoiDataBridge(options = {}) {
|
|
|
378
523
|
}
|
|
379
524
|
}
|
|
380
525
|
|
|
526
|
+
function clampedQueryInteger(value, fallback, minimum, maximum) {
|
|
527
|
+
if (value == null || value === '') return fallback
|
|
528
|
+
const parsed = Number(value)
|
|
529
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
530
|
+
return Math.min(maximum, Math.max(minimum, Math.trunc(parsed)))
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function claimInputOperation(inputLease, request) {
|
|
534
|
+
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
|
535
|
+
throw inputBridgeError('INPUT_LEASE_REQUIRED', 'Input lease fields are required.', 409)
|
|
536
|
+
}
|
|
537
|
+
const {
|
|
538
|
+
leaseId,
|
|
539
|
+
ownerSessionId,
|
|
540
|
+
runId,
|
|
541
|
+
action,
|
|
542
|
+
sequence,
|
|
543
|
+
...input
|
|
544
|
+
} = request
|
|
545
|
+
if (
|
|
546
|
+
typeof leaseId !== 'string' ||
|
|
547
|
+
typeof ownerSessionId !== 'string' ||
|
|
548
|
+
typeof runId !== 'string' ||
|
|
549
|
+
typeof action !== 'string' ||
|
|
550
|
+
!Number.isSafeInteger(sequence)
|
|
551
|
+
) {
|
|
552
|
+
throw inputBridgeError('INPUT_LEASE_REQUIRED', 'Input lease fields are required.', 409)
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
input,
|
|
556
|
+
claim: inputLease.consumeInput({
|
|
557
|
+
leaseId,
|
|
558
|
+
ownerSessionId,
|
|
559
|
+
runId,
|
|
560
|
+
action,
|
|
561
|
+
sequence,
|
|
562
|
+
}),
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function inputBridgeError(code, message, statusCode) {
|
|
567
|
+
const error = new Error(message)
|
|
568
|
+
error.code = code
|
|
569
|
+
error.statusCode = statusCode
|
|
570
|
+
return error
|
|
571
|
+
}
|
|
572
|
+
|
|
381
573
|
function readRequestBody(
|
|
382
574
|
req,
|
|
383
575
|
maxBytes = 1024 * 1024,
|
|
@@ -624,6 +816,11 @@ function readBridgeData(endpoint, readStore, plannerFile) {
|
|
|
624
816
|
records: (info.quests && info.quests.records) || {},
|
|
625
817
|
},
|
|
626
818
|
airbase: info.airbase || [],
|
|
819
|
+
repairs: info.repairs || [],
|
|
820
|
+
constructions: info.constructions || [],
|
|
821
|
+
maps: info.maps || {},
|
|
822
|
+
useitems: info.useitems || {},
|
|
823
|
+
sortie: store.sortie || {},
|
|
627
824
|
names: extractNames(store),
|
|
628
825
|
}
|
|
629
826
|
default:
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const crypto = require('node:crypto')
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TTL_MS = 10_000
|
|
4
|
+
const MAX_TTL_MS = 30_000
|
|
5
|
+
const MAX_ID_LENGTH = 256
|
|
6
|
+
|
|
7
|
+
function createPoiInputLease(options = {}) {
|
|
8
|
+
const now = options.now || Date.now
|
|
9
|
+
const createId = options.createId || crypto.randomUUID
|
|
10
|
+
const defaultTtlMs = positiveInteger(
|
|
11
|
+
options.defaultTtlMs == null ? DEFAULT_TTL_MS : options.defaultTtlMs,
|
|
12
|
+
'defaultTtlMs',
|
|
13
|
+
)
|
|
14
|
+
const maxTtlMs = positiveInteger(
|
|
15
|
+
options.maxTtlMs == null ? MAX_TTL_MS : options.maxTtlMs,
|
|
16
|
+
'maxTtlMs',
|
|
17
|
+
)
|
|
18
|
+
if (defaultTtlMs > maxTtlMs) {
|
|
19
|
+
throw new Error('defaultTtlMs must not exceed maxTtlMs')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let active = null
|
|
23
|
+
|
|
24
|
+
function current() {
|
|
25
|
+
if (active && active.expiresAtMs <= now()) {
|
|
26
|
+
active = null
|
|
27
|
+
}
|
|
28
|
+
return active
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function status() {
|
|
32
|
+
const lease = current()
|
|
33
|
+
return lease ? publicLease(lease) : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function acquire(request) {
|
|
37
|
+
if (current()) {
|
|
38
|
+
throw leaseError('INPUT_LEASE_BUSY', 'Another input lease is active.')
|
|
39
|
+
}
|
|
40
|
+
const normalized = normalizeAcquireRequest(request, defaultTtlMs, maxTtlMs)
|
|
41
|
+
const leaseId = boundedId(createId(), 'leaseId')
|
|
42
|
+
active = {
|
|
43
|
+
leaseId,
|
|
44
|
+
ownerSessionId: normalized.ownerSessionId,
|
|
45
|
+
runId: normalized.runId,
|
|
46
|
+
mode: normalized.mode,
|
|
47
|
+
action: normalized.action,
|
|
48
|
+
expiresAtMs: now() + normalized.ttlMs,
|
|
49
|
+
nextSequence: 1,
|
|
50
|
+
}
|
|
51
|
+
return publicLease(active)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renew(request) {
|
|
55
|
+
const lease = requireMatchingLease(request)
|
|
56
|
+
const ttlMs = normalizeTtl(request && request.ttlMs, defaultTtlMs, maxTtlMs)
|
|
57
|
+
lease.expiresAtMs = now() + ttlMs
|
|
58
|
+
return publicLease(lease)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function release(request) {
|
|
62
|
+
requireMatchingLease(request)
|
|
63
|
+
active = null
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function revoke(request) {
|
|
68
|
+
const lease = current()
|
|
69
|
+
if (!lease) {
|
|
70
|
+
throw leaseError('INPUT_LEASE_REQUIRED', 'No input lease is active.')
|
|
71
|
+
}
|
|
72
|
+
if (boundedId(request && request.leaseId, 'leaseId') !== lease.leaseId) {
|
|
73
|
+
throw leaseError('INPUT_LEASE_MISMATCH', 'Input lease identity does not match.')
|
|
74
|
+
}
|
|
75
|
+
active = null
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function consumeInput(request) {
|
|
80
|
+
const lease = requireMatchingLease(request)
|
|
81
|
+
const action = boundedId(request && request.action, 'action')
|
|
82
|
+
if (lease.mode === 'action' && action !== lease.action) {
|
|
83
|
+
throw leaseError(
|
|
84
|
+
'INPUT_LEASE_ACTION_MISMATCH',
|
|
85
|
+
`Input action ${action} does not match lease action ${lease.action}.`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
const sequence = request && request.sequence
|
|
89
|
+
if (!Number.isSafeInteger(sequence) || sequence !== lease.nextSequence) {
|
|
90
|
+
throw leaseError(
|
|
91
|
+
'INPUT_SEQUENCE_MISMATCH',
|
|
92
|
+
`Input sequence must be ${lease.nextSequence}.`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
lease.nextSequence += 1
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
leaseId: lease.leaseId,
|
|
98
|
+
ownerSessionId: lease.ownerSessionId,
|
|
99
|
+
runId: lease.runId,
|
|
100
|
+
action,
|
|
101
|
+
sequence,
|
|
102
|
+
acceptedAt: new Date(now()).toISOString(),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertClaimActive(claim) {
|
|
107
|
+
const lease = requireMatchingLease(claim)
|
|
108
|
+
const action = boundedId(claim && claim.action, 'action')
|
|
109
|
+
const sequence = claim && claim.sequence
|
|
110
|
+
if (
|
|
111
|
+
!Number.isSafeInteger(sequence) ||
|
|
112
|
+
sequence < 1 ||
|
|
113
|
+
sequence >= lease.nextSequence ||
|
|
114
|
+
(lease.mode === 'action' && action !== lease.action)
|
|
115
|
+
) {
|
|
116
|
+
throw leaseError('INPUT_LEASE_MISMATCH', 'Input claim does not match the lease.')
|
|
117
|
+
}
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function requireMatchingLease(request) {
|
|
122
|
+
const lease = current()
|
|
123
|
+
if (!lease) {
|
|
124
|
+
throw leaseError('INPUT_LEASE_REQUIRED', 'No input lease is active.')
|
|
125
|
+
}
|
|
126
|
+
const leaseId = boundedId(request && request.leaseId, 'leaseId')
|
|
127
|
+
const ownerSessionId = boundedId(
|
|
128
|
+
request && request.ownerSessionId,
|
|
129
|
+
'ownerSessionId',
|
|
130
|
+
)
|
|
131
|
+
const runId = boundedId(request && request.runId, 'runId')
|
|
132
|
+
if (
|
|
133
|
+
lease.leaseId !== leaseId ||
|
|
134
|
+
lease.ownerSessionId !== ownerSessionId ||
|
|
135
|
+
lease.runId !== runId
|
|
136
|
+
) {
|
|
137
|
+
throw leaseError('INPUT_LEASE_MISMATCH', 'Input lease identity does not match.')
|
|
138
|
+
}
|
|
139
|
+
return lease
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
status,
|
|
144
|
+
acquire,
|
|
145
|
+
renew,
|
|
146
|
+
release,
|
|
147
|
+
revoke,
|
|
148
|
+
consumeInput,
|
|
149
|
+
assertClaimActive,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeAcquireRequest(request, defaultTtlMs, maxTtlMs) {
|
|
154
|
+
const ownerSessionId = boundedId(request && request.ownerSessionId, 'ownerSessionId')
|
|
155
|
+
const runId = boundedId(request && request.runId, 'runId')
|
|
156
|
+
const mode = request && request.mode
|
|
157
|
+
if (mode !== 'action' && mode !== 'workflow') {
|
|
158
|
+
throw leaseError('INVALID_INPUT_LEASE', 'Input lease mode must be action or workflow.')
|
|
159
|
+
}
|
|
160
|
+
const action = request && request.action
|
|
161
|
+
if (
|
|
162
|
+
(mode === 'action' && (typeof action !== 'string' || action.length === 0)) ||
|
|
163
|
+
(mode === 'workflow' && action !== null)
|
|
164
|
+
) {
|
|
165
|
+
throw leaseError(
|
|
166
|
+
'INVALID_INPUT_LEASE',
|
|
167
|
+
'Action leases require an action and workflow leases require action null.',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
ownerSessionId,
|
|
172
|
+
runId,
|
|
173
|
+
mode,
|
|
174
|
+
action: mode === 'action' ? boundedId(action, 'action') : null,
|
|
175
|
+
ttlMs: normalizeTtl(request.ttlMs, defaultTtlMs, maxTtlMs),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeTtl(value, defaultTtlMs, maxTtlMs) {
|
|
180
|
+
const ttlMs = value == null ? defaultTtlMs : value
|
|
181
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
|
|
182
|
+
throw leaseError('INVALID_INPUT_LEASE', 'Input lease ttlMs must be a positive integer.')
|
|
183
|
+
}
|
|
184
|
+
return Math.min(ttlMs, maxTtlMs)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function publicLease(lease) {
|
|
188
|
+
return Object.freeze({
|
|
189
|
+
leaseId: lease.leaseId,
|
|
190
|
+
ownerSessionId: lease.ownerSessionId,
|
|
191
|
+
runId: lease.runId,
|
|
192
|
+
mode: lease.mode,
|
|
193
|
+
action: lease.action,
|
|
194
|
+
expiresAt: new Date(lease.expiresAtMs).toISOString(),
|
|
195
|
+
nextSequence: lease.nextSequence,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function boundedId(value, name) {
|
|
200
|
+
if (
|
|
201
|
+
typeof value !== 'string' ||
|
|
202
|
+
value.length === 0 ||
|
|
203
|
+
value.length > MAX_ID_LENGTH
|
|
204
|
+
) {
|
|
205
|
+
throw leaseError('INVALID_INPUT_LEASE', `${name} must be a non-empty string.`)
|
|
206
|
+
}
|
|
207
|
+
return value
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function positiveInteger(value, name) {
|
|
211
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
212
|
+
throw new Error(`${name} must be a positive integer`)
|
|
213
|
+
}
|
|
214
|
+
return value
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function leaseError(code, message) {
|
|
218
|
+
const error = new Error(message)
|
|
219
|
+
error.code = code
|
|
220
|
+
error.statusCode = code === 'INVALID_INPUT_LEASE' ? 400 : 409
|
|
221
|
+
return error
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = {
|
|
225
|
+
createPoiInputLease,
|
|
226
|
+
}
|
package/lib/poi-input.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const CANONICAL_WIDTH = 1200
|
|
2
2
|
const CANONICAL_HEIGHT = 720
|
|
3
3
|
const DEFAULT_CLICK_DELAY_MS = 10
|
|
4
|
+
const DRAG_MOVE_STEPS = 6
|
|
4
5
|
const MAX_TEXT_LENGTH = 256
|
|
5
6
|
|
|
6
7
|
const SUPPORTED_BUTTONS = new Set(['left', 'middle', 'right'])
|
|
@@ -48,6 +49,10 @@ function createPoiInputProvider(options = {}) {
|
|
|
48
49
|
validateClick(operation)
|
|
49
50
|
await sendClick(layout, operation, delay, clickDelayMs)
|
|
50
51
|
return 'click'
|
|
52
|
+
case 'drag':
|
|
53
|
+
validateDrag(operation)
|
|
54
|
+
await sendDrag(layout, operation, delay)
|
|
55
|
+
return 'drag'
|
|
51
56
|
case 'key':
|
|
52
57
|
validateKey(operation)
|
|
53
58
|
await layout.webContents.sendInputEvent({
|
|
@@ -129,6 +134,48 @@ function validateClick(operation) {
|
|
|
129
134
|
}
|
|
130
135
|
}
|
|
131
136
|
|
|
137
|
+
function validateDrag(operation) {
|
|
138
|
+
assertExactFields(operation, [
|
|
139
|
+
'operation',
|
|
140
|
+
'fromX',
|
|
141
|
+
'fromY',
|
|
142
|
+
'toX',
|
|
143
|
+
'toY',
|
|
144
|
+
'durationMs',
|
|
145
|
+
'button',
|
|
146
|
+
])
|
|
147
|
+
const coordinates = [
|
|
148
|
+
operation.fromX,
|
|
149
|
+
operation.fromY,
|
|
150
|
+
operation.toX,
|
|
151
|
+
operation.toY,
|
|
152
|
+
]
|
|
153
|
+
if (coordinates.some((coordinate) => !Number.isFinite(coordinate))) {
|
|
154
|
+
throw new Error('Drag coordinates must be finite numbers')
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
operation.fromX < 0 ||
|
|
158
|
+
operation.fromX >= CANONICAL_WIDTH ||
|
|
159
|
+
operation.toX < 0 ||
|
|
160
|
+
operation.toX >= CANONICAL_WIDTH ||
|
|
161
|
+
operation.fromY < 0 ||
|
|
162
|
+
operation.fromY >= CANONICAL_HEIGHT ||
|
|
163
|
+
operation.toY < 0 ||
|
|
164
|
+
operation.toY >= CANONICAL_HEIGHT
|
|
165
|
+
) {
|
|
166
|
+
throw new Error('Drag coordinates must be within canonical bounds')
|
|
167
|
+
}
|
|
168
|
+
if (!Number.isInteger(operation.durationMs)) {
|
|
169
|
+
throw new Error('Drag durationMs must be an integer')
|
|
170
|
+
}
|
|
171
|
+
if (operation.durationMs < 50 || operation.durationMs > 2000) {
|
|
172
|
+
throw new Error('Drag durationMs must be from 50 to 2000')
|
|
173
|
+
}
|
|
174
|
+
if (!SUPPORTED_BUTTONS.has(operation.button)) {
|
|
175
|
+
throw new Error(`Unsupported mouse button: ${String(operation.button)}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
132
179
|
function validateKey(operation) {
|
|
133
180
|
assertExactFields(operation, ['operation', 'event', 'key'])
|
|
134
181
|
if (!SUPPORTED_KEY_EVENTS.has(operation.event)) {
|
|
@@ -181,6 +228,56 @@ async function sendClick(layout, operation, delay, clickDelayMs) {
|
|
|
181
228
|
}
|
|
182
229
|
}
|
|
183
230
|
|
|
231
|
+
async function sendDrag(layout, operation, delay) {
|
|
232
|
+
const start = {
|
|
233
|
+
x: Math.floor((operation.fromX * layout.width) / CANONICAL_WIDTH),
|
|
234
|
+
y: Math.floor((operation.fromY * layout.height) / CANONICAL_HEIGHT),
|
|
235
|
+
}
|
|
236
|
+
const destination = {
|
|
237
|
+
x: Math.floor((operation.toX * layout.width) / CANONICAL_WIDTH),
|
|
238
|
+
y: Math.floor((operation.toY * layout.height) / CANONICAL_HEIGHT),
|
|
239
|
+
}
|
|
240
|
+
const mouseButton = { button: operation.button }
|
|
241
|
+
|
|
242
|
+
await layout.webContents.sendInputEvent({
|
|
243
|
+
type: 'mouseMove',
|
|
244
|
+
...start,
|
|
245
|
+
...mouseButton,
|
|
246
|
+
})
|
|
247
|
+
try {
|
|
248
|
+
await layout.webContents.sendInputEvent({
|
|
249
|
+
type: 'mouseDown',
|
|
250
|
+
...start,
|
|
251
|
+
...mouseButton,
|
|
252
|
+
clickCount: 1,
|
|
253
|
+
})
|
|
254
|
+
for (let step = 1; step <= DRAG_MOVE_STEPS; step += 1) {
|
|
255
|
+
const elapsed = Math.round((operation.durationMs * step) / DRAG_MOVE_STEPS)
|
|
256
|
+
const previousElapsed = Math.round(
|
|
257
|
+
(operation.durationMs * (step - 1)) / DRAG_MOVE_STEPS,
|
|
258
|
+
)
|
|
259
|
+
await delay(elapsed - previousElapsed)
|
|
260
|
+
await layout.webContents.sendInputEvent({
|
|
261
|
+
type: 'mouseMove',
|
|
262
|
+
x: Math.round(
|
|
263
|
+
start.x + ((destination.x - start.x) * step) / DRAG_MOVE_STEPS,
|
|
264
|
+
),
|
|
265
|
+
y: Math.round(
|
|
266
|
+
start.y + ((destination.y - start.y) * step) / DRAG_MOVE_STEPS,
|
|
267
|
+
),
|
|
268
|
+
...mouseButton,
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
await layout.webContents.sendInputEvent({
|
|
273
|
+
type: 'mouseUp',
|
|
274
|
+
...destination,
|
|
275
|
+
...mouseButton,
|
|
276
|
+
clickCount: 1,
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
184
281
|
function defaultDelay(milliseconds) {
|
|
185
282
|
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
186
283
|
}
|
package/lib/poi-telemetry.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { createPoiActionEvents } = require('./poi-action-events')
|
|
2
|
+
|
|
1
3
|
const QUEST_LIST_PATH = '/kcsapi/api_get_member/questlist'
|
|
2
4
|
const QUEST_ACTION_PATHS = new Set([
|
|
3
5
|
'/kcsapi/api_req_quest/start',
|
|
@@ -9,6 +11,7 @@ const EQUIPMENT_ACTION_PATHS = new Set([
|
|
|
9
11
|
'/kcsapi/api_req_kaisou/slot_deprive',
|
|
10
12
|
'/kcsapi/api_req_kaisou/unsetslot_all',
|
|
11
13
|
])
|
|
14
|
+
const FLEET_ACTION_PATH = '/kcsapi/api_req_hensei/change'
|
|
12
15
|
const BATTLE_RESULT_PATHS = new Set([
|
|
13
16
|
'/kcsapi/api_req_practice/battle_result',
|
|
14
17
|
'/kcsapi/api_req_sortie/battleresult',
|
|
@@ -39,29 +42,37 @@ const BATTLE_PATHS = new Set([
|
|
|
39
42
|
|
|
40
43
|
function createPoiTelemetry(options = {}) {
|
|
41
44
|
const now = options.now || (() => new Date())
|
|
45
|
+
const actionEvents = createPoiActionEvents({ now })
|
|
42
46
|
let questGeneration = 0
|
|
43
47
|
let questList = null
|
|
44
48
|
let questActionGeneration = 0
|
|
45
49
|
let questAction = null
|
|
46
50
|
let equipmentActionGeneration = 0
|
|
47
51
|
let equipmentAction = null
|
|
52
|
+
let fleetActionGeneration = 0
|
|
53
|
+
let fleetAction = null
|
|
48
54
|
let battleGeneration = 0
|
|
49
55
|
let battleTelemetry = null
|
|
50
56
|
|
|
51
57
|
function handleGameResponse(event) {
|
|
52
58
|
const detail = event && event.detail
|
|
53
59
|
if (!detail || typeof detail.path !== 'string') return
|
|
60
|
+
const actionEvent = actionEvents.capture(detail)
|
|
54
61
|
|
|
55
62
|
if (detail.path === QUEST_LIST_PATH) {
|
|
56
|
-
captureQuestList(detail)
|
|
63
|
+
captureQuestList(detail, actionEvent)
|
|
57
64
|
return
|
|
58
65
|
}
|
|
59
66
|
if (QUEST_ACTION_PATHS.has(detail.path)) {
|
|
60
|
-
captureQuestAction(detail)
|
|
67
|
+
captureQuestAction(detail, actionEvent)
|
|
61
68
|
return
|
|
62
69
|
}
|
|
63
70
|
if (EQUIPMENT_ACTION_PATHS.has(detail.path)) {
|
|
64
|
-
captureEquipmentAction(detail)
|
|
71
|
+
captureEquipmentAction(detail, actionEvent)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (detail.path === FLEET_ACTION_PATH) {
|
|
75
|
+
captureFleetAction(detail, actionEvent)
|
|
65
76
|
return
|
|
66
77
|
}
|
|
67
78
|
if (BATTLE_RESULT_PATHS.has(detail.path)) {
|
|
@@ -73,7 +84,7 @@ function createPoiTelemetry(options = {}) {
|
|
|
73
84
|
}
|
|
74
85
|
}
|
|
75
86
|
|
|
76
|
-
function captureQuestList(detail) {
|
|
87
|
+
function captureQuestList(detail, actionEvent) {
|
|
77
88
|
const body = detail.body
|
|
78
89
|
const postBody = detail.postBody
|
|
79
90
|
const tabId = toInteger(postBody && postBody.api_tab_id)
|
|
@@ -98,7 +109,9 @@ function createPoiTelemetry(options = {}) {
|
|
|
98
109
|
questList = {
|
|
99
110
|
available: true,
|
|
100
111
|
generation: questGeneration,
|
|
101
|
-
capturedAt:
|
|
112
|
+
capturedAt: actionEvent
|
|
113
|
+
? actionEvent.capturedAt
|
|
114
|
+
: now().toISOString(),
|
|
102
115
|
tabId,
|
|
103
116
|
pageNo: nonNegativeInteger(body.api_disp_page),
|
|
104
117
|
pageCount: nonNegativeInteger(body.api_page_count),
|
|
@@ -109,7 +122,11 @@ function createPoiTelemetry(options = {}) {
|
|
|
109
122
|
}
|
|
110
123
|
}
|
|
111
124
|
|
|
112
|
-
function captureQuestAction(detail) {
|
|
125
|
+
function captureQuestAction(detail, actionEvent) {
|
|
126
|
+
const apiResult = actionEvent
|
|
127
|
+
? actionEvent.apiResult
|
|
128
|
+
: equipmentApiResult(detail)
|
|
129
|
+
if (apiResult !== 1) return
|
|
113
130
|
const postBody = detail.postBody
|
|
114
131
|
const questId = toInteger(postBody && postBody.api_quest_id)
|
|
115
132
|
if (questId == null || questId <= 0) return
|
|
@@ -118,15 +135,20 @@ function createPoiTelemetry(options = {}) {
|
|
|
118
135
|
questAction = {
|
|
119
136
|
available: true,
|
|
120
137
|
generation: questActionGeneration,
|
|
121
|
-
capturedAt:
|
|
122
|
-
|
|
138
|
+
capturedAt: actionEvent
|
|
139
|
+
? actionEvent.capturedAt
|
|
140
|
+
: now().toISOString(),
|
|
141
|
+
path: actionEvent ? actionEvent.path : detail.path,
|
|
123
142
|
questId,
|
|
124
143
|
flag: toInteger(postBody && postBody.api_quest_flag),
|
|
144
|
+
apiResult,
|
|
125
145
|
}
|
|
126
146
|
}
|
|
127
147
|
|
|
128
|
-
function captureEquipmentAction(detail) {
|
|
129
|
-
const apiResult =
|
|
148
|
+
function captureEquipmentAction(detail, actionEvent) {
|
|
149
|
+
const apiResult = actionEvent
|
|
150
|
+
? actionEvent.apiResult
|
|
151
|
+
: equipmentApiResult(detail)
|
|
130
152
|
if (apiResult === 0) return
|
|
131
153
|
const postBody = normalizeEquipmentPostBody(detail.path, detail.postBody)
|
|
132
154
|
if (!postBody) return
|
|
@@ -135,8 +157,31 @@ function createPoiTelemetry(options = {}) {
|
|
|
135
157
|
equipmentAction = {
|
|
136
158
|
available: true,
|
|
137
159
|
generation: equipmentActionGeneration,
|
|
138
|
-
capturedAt:
|
|
139
|
-
|
|
160
|
+
capturedAt: actionEvent
|
|
161
|
+
? actionEvent.capturedAt
|
|
162
|
+
: now().toISOString(),
|
|
163
|
+
path: actionEvent ? actionEvent.path : detail.path,
|
|
164
|
+
apiResult,
|
|
165
|
+
postBody,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function captureFleetAction(detail, actionEvent) {
|
|
170
|
+
const apiResult = actionEvent
|
|
171
|
+
? actionEvent.apiResult
|
|
172
|
+
: equipmentApiResult(detail)
|
|
173
|
+
if (apiResult === 0) return
|
|
174
|
+
const postBody = normalizeFleetPostBody(detail.postBody)
|
|
175
|
+
if (!postBody) return
|
|
176
|
+
|
|
177
|
+
fleetActionGeneration += 1
|
|
178
|
+
fleetAction = {
|
|
179
|
+
available: true,
|
|
180
|
+
generation: fleetActionGeneration,
|
|
181
|
+
capturedAt: actionEvent
|
|
182
|
+
? actionEvent.capturedAt
|
|
183
|
+
: now().toISOString(),
|
|
184
|
+
path: actionEvent ? actionEvent.path : detail.path,
|
|
140
185
|
apiResult,
|
|
141
186
|
postBody,
|
|
142
187
|
}
|
|
@@ -207,12 +252,31 @@ function createPoiTelemetry(options = {}) {
|
|
|
207
252
|
getEquipmentAction() {
|
|
208
253
|
return equipmentAction || { available: false, generation: 0 }
|
|
209
254
|
},
|
|
255
|
+
getFleetAction() {
|
|
256
|
+
return fleetAction || { available: false, generation: 0 }
|
|
257
|
+
},
|
|
258
|
+
getActionEvents(options) {
|
|
259
|
+
return actionEvents.read(options)
|
|
260
|
+
},
|
|
210
261
|
getBattleTelemetry() {
|
|
211
262
|
return battleTelemetry || { available: false, generation: 0 }
|
|
212
263
|
},
|
|
213
264
|
}
|
|
214
265
|
}
|
|
215
266
|
|
|
267
|
+
function normalizeFleetPostBody(postBody) {
|
|
268
|
+
if (!postBody || typeof postBody !== 'object') return null
|
|
269
|
+
const fleetId = boundedInteger(postBody.api_id, 1, 4)
|
|
270
|
+
const shipIndex = boundedInteger(postBody.api_ship_idx, 0, 5)
|
|
271
|
+
const shipId = boundedInteger(postBody.api_ship_id, -1)
|
|
272
|
+
if (fleetId == null || shipIndex == null || shipId == null) return null
|
|
273
|
+
return {
|
|
274
|
+
api_id: fleetId,
|
|
275
|
+
api_ship_idx: shipIndex,
|
|
276
|
+
api_ship_id: shipId,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
216
280
|
function normalizeEquipmentPostBody(path, postBody) {
|
|
217
281
|
if (!postBody || typeof postBody !== 'object') return null
|
|
218
282
|
if (path === '/kcsapi/api_req_kaisou/slotset') {
|