poi-plugin-mcp 0.2.12 → 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 +1 -0
- package/lib/bridge-controller.js +1 -0
- package/lib/poi-action-events.js +288 -0
- package/lib/poi-http-bridge.js +199 -9
- package/lib/poi-input-lease.js +226 -0
- package/lib/poi-input.js +97 -0
- package/lib/poi-telemetry.js +41 -17
- 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
|
@@ -8,6 +8,7 @@ const controller = createBridgeController({
|
|
|
8
8
|
getQuestAction: telemetry.getQuestAction,
|
|
9
9
|
getEquipmentAction: telemetry.getEquipmentAction,
|
|
10
10
|
getFleetAction: telemetry.getFleetAction,
|
|
11
|
+
getActionEvents: telemetry.getActionEvents,
|
|
11
12
|
getBattleTelemetry: telemetry.getBattleTelemetry,
|
|
12
13
|
})
|
|
13
14
|
const settingsClass = createSettingsClass(controller)
|
package/lib/bridge-controller.js
CHANGED
|
@@ -34,6 +34,7 @@ function createBridgeController(options = {}) {
|
|
|
34
34
|
getQuestAction: options.getQuestAction,
|
|
35
35
|
getEquipmentAction: options.getEquipmentAction,
|
|
36
36
|
getFleetAction: options.getFleetAction,
|
|
37
|
+
getActionEvents: options.getActionEvents,
|
|
37
38
|
getBattleTelemetry: options.getBattleTelemetry,
|
|
38
39
|
inputEnabled: settings.inputEnabled,
|
|
39
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
|
|
@@ -39,19 +42,26 @@ function createPoiDataBridge(options = {}) {
|
|
|
39
42
|
(() => ({ available: false, generation: 0 }))
|
|
40
43
|
const getFleetAction = options.getFleetAction ||
|
|
41
44
|
(() => ({ available: false, generation: 0 }))
|
|
45
|
+
const getActionEvents = options.getActionEvents ||
|
|
46
|
+
(() => ({
|
|
47
|
+
available: false,
|
|
48
|
+
sessionId: null,
|
|
49
|
+
latestGeneration: 0,
|
|
50
|
+
events: [],
|
|
51
|
+
}))
|
|
42
52
|
const getBattleTelemetry = options.getBattleTelemetry ||
|
|
43
53
|
(() => ({ available: false, generation: 0 }))
|
|
44
54
|
const inputEnabled = options.inputEnabled === true
|
|
45
55
|
const inputToken = options.inputToken || (
|
|
46
56
|
inputEnabled ? loadOrCreateInputToken(options.inputTokenFile) : null
|
|
47
57
|
)
|
|
58
|
+
const inputLease = options.inputLease || createPoiInputLease(options.inputLeaseOptions)
|
|
48
59
|
let captureScreenshot = options.captureScreenshot || null
|
|
49
60
|
let performInput = options.performInput || null
|
|
50
61
|
|
|
51
62
|
let server = null
|
|
52
63
|
let actualPort = 0
|
|
53
64
|
let inputPending = Promise.resolve()
|
|
54
|
-
let inputSequence = 0
|
|
55
65
|
|
|
56
66
|
function readStore() {
|
|
57
67
|
const store = getStore()
|
|
@@ -84,17 +94,22 @@ function createPoiDataBridge(options = {}) {
|
|
|
84
94
|
})
|
|
85
95
|
}
|
|
86
96
|
|
|
87
|
-
function enqueueInput(operation) {
|
|
97
|
+
function enqueueInput(operation, claim) {
|
|
88
98
|
const execute = async () => {
|
|
99
|
+
inputLease.assertClaimActive(claim)
|
|
89
100
|
if (!performInput) {
|
|
90
101
|
performInput = createPoiInputProvider({ getStore })
|
|
91
102
|
}
|
|
92
103
|
const operationName = await performInput(operation)
|
|
93
|
-
inputSequence += 1
|
|
94
104
|
return {
|
|
95
105
|
ok: true,
|
|
96
106
|
operation: operationName,
|
|
97
|
-
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,
|
|
98
113
|
}
|
|
99
114
|
}
|
|
100
115
|
const result = inputPending.then(execute, execute)
|
|
@@ -136,14 +151,92 @@ function createPoiDataBridge(options = {}) {
|
|
|
136
151
|
'Input request body exceeds 64KB.',
|
|
137
152
|
)
|
|
138
153
|
const operation = JSON.parse(body || '{}')
|
|
139
|
-
|
|
154
|
+
const { input, claim } = claimInputOperation(inputLease, operation)
|
|
155
|
+
sendInputJson(res, 200, await enqueueInput(input, claim))
|
|
140
156
|
} catch (error) {
|
|
141
|
-
const statusCode = error.code === 'BODY_TOO_LARGE'
|
|
157
|
+
const statusCode = error.statusCode || (error.code === 'BODY_TOO_LARGE'
|
|
142
158
|
? 413
|
|
143
159
|
: /WebView|dimensions/.test(error.message)
|
|
144
160
|
? 503
|
|
145
|
-
: 400
|
|
146
|
-
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
|
+
})
|
|
147
240
|
}
|
|
148
241
|
}
|
|
149
242
|
|
|
@@ -182,7 +275,11 @@ function createPoiDataBridge(options = {}) {
|
|
|
182
275
|
return
|
|
183
276
|
}
|
|
184
277
|
|
|
185
|
-
const
|
|
278
|
+
const requestUrl = new URL(
|
|
279
|
+
req.url,
|
|
280
|
+
`http://127.0.0.1:${actualPort || configuredPort}`,
|
|
281
|
+
)
|
|
282
|
+
const endpoint = requestUrl.pathname
|
|
186
283
|
|
|
187
284
|
if (endpoint === '/health') {
|
|
188
285
|
sendJson(res, 200, { status: 'ok' })
|
|
@@ -239,6 +336,17 @@ function createPoiDataBridge(options = {}) {
|
|
|
239
336
|
return
|
|
240
337
|
}
|
|
241
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
|
+
|
|
242
350
|
if (endpoint === '/input') {
|
|
243
351
|
await handleInputRequest(req, res)
|
|
244
352
|
return
|
|
@@ -264,6 +372,31 @@ function createPoiDataBridge(options = {}) {
|
|
|
264
372
|
return
|
|
265
373
|
}
|
|
266
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
|
+
|
|
267
400
|
const store = readStore()
|
|
268
401
|
const info = store.info
|
|
269
402
|
|
|
@@ -322,6 +455,11 @@ function createPoiDataBridge(options = {}) {
|
|
|
322
455
|
records: (info.quests && info.quests.records) || {},
|
|
323
456
|
},
|
|
324
457
|
airbase: info.airbase || [],
|
|
458
|
+
repairs: info.repairs || [],
|
|
459
|
+
constructions: info.constructions || [],
|
|
460
|
+
maps: info.maps || {},
|
|
461
|
+
useitems: info.useitems || {},
|
|
462
|
+
sortie: store.sortie || {},
|
|
325
463
|
names: extractNames(store),
|
|
326
464
|
})
|
|
327
465
|
break
|
|
@@ -385,6 +523,53 @@ function createPoiDataBridge(options = {}) {
|
|
|
385
523
|
}
|
|
386
524
|
}
|
|
387
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
|
+
|
|
388
573
|
function readRequestBody(
|
|
389
574
|
req,
|
|
390
575
|
maxBytes = 1024 * 1024,
|
|
@@ -631,6 +816,11 @@ function readBridgeData(endpoint, readStore, plannerFile) {
|
|
|
631
816
|
records: (info.quests && info.quests.records) || {},
|
|
632
817
|
},
|
|
633
818
|
airbase: info.airbase || [],
|
|
819
|
+
repairs: info.repairs || [],
|
|
820
|
+
constructions: info.constructions || [],
|
|
821
|
+
maps: info.maps || {},
|
|
822
|
+
useitems: info.useitems || {},
|
|
823
|
+
sortie: store.sortie || {},
|
|
634
824
|
names: extractNames(store),
|
|
635
825
|
}
|
|
636
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',
|
|
@@ -40,6 +42,7 @@ const BATTLE_PATHS = new Set([
|
|
|
40
42
|
|
|
41
43
|
function createPoiTelemetry(options = {}) {
|
|
42
44
|
const now = options.now || (() => new Date())
|
|
45
|
+
const actionEvents = createPoiActionEvents({ now })
|
|
43
46
|
let questGeneration = 0
|
|
44
47
|
let questList = null
|
|
45
48
|
let questActionGeneration = 0
|
|
@@ -54,21 +57,22 @@ function createPoiTelemetry(options = {}) {
|
|
|
54
57
|
function handleGameResponse(event) {
|
|
55
58
|
const detail = event && event.detail
|
|
56
59
|
if (!detail || typeof detail.path !== 'string') return
|
|
60
|
+
const actionEvent = actionEvents.capture(detail)
|
|
57
61
|
|
|
58
62
|
if (detail.path === QUEST_LIST_PATH) {
|
|
59
|
-
captureQuestList(detail)
|
|
63
|
+
captureQuestList(detail, actionEvent)
|
|
60
64
|
return
|
|
61
65
|
}
|
|
62
66
|
if (QUEST_ACTION_PATHS.has(detail.path)) {
|
|
63
|
-
captureQuestAction(detail)
|
|
67
|
+
captureQuestAction(detail, actionEvent)
|
|
64
68
|
return
|
|
65
69
|
}
|
|
66
70
|
if (EQUIPMENT_ACTION_PATHS.has(detail.path)) {
|
|
67
|
-
captureEquipmentAction(detail)
|
|
71
|
+
captureEquipmentAction(detail, actionEvent)
|
|
68
72
|
return
|
|
69
73
|
}
|
|
70
74
|
if (detail.path === FLEET_ACTION_PATH) {
|
|
71
|
-
captureFleetAction(detail)
|
|
75
|
+
captureFleetAction(detail, actionEvent)
|
|
72
76
|
return
|
|
73
77
|
}
|
|
74
78
|
if (BATTLE_RESULT_PATHS.has(detail.path)) {
|
|
@@ -80,7 +84,7 @@ function createPoiTelemetry(options = {}) {
|
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
function captureQuestList(detail) {
|
|
87
|
+
function captureQuestList(detail, actionEvent) {
|
|
84
88
|
const body = detail.body
|
|
85
89
|
const postBody = detail.postBody
|
|
86
90
|
const tabId = toInteger(postBody && postBody.api_tab_id)
|
|
@@ -105,7 +109,9 @@ function createPoiTelemetry(options = {}) {
|
|
|
105
109
|
questList = {
|
|
106
110
|
available: true,
|
|
107
111
|
generation: questGeneration,
|
|
108
|
-
capturedAt:
|
|
112
|
+
capturedAt: actionEvent
|
|
113
|
+
? actionEvent.capturedAt
|
|
114
|
+
: now().toISOString(),
|
|
109
115
|
tabId,
|
|
110
116
|
pageNo: nonNegativeInteger(body.api_disp_page),
|
|
111
117
|
pageCount: nonNegativeInteger(body.api_page_count),
|
|
@@ -116,7 +122,11 @@ function createPoiTelemetry(options = {}) {
|
|
|
116
122
|
}
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
function captureQuestAction(detail) {
|
|
125
|
+
function captureQuestAction(detail, actionEvent) {
|
|
126
|
+
const apiResult = actionEvent
|
|
127
|
+
? actionEvent.apiResult
|
|
128
|
+
: equipmentApiResult(detail)
|
|
129
|
+
if (apiResult !== 1) return
|
|
120
130
|
const postBody = detail.postBody
|
|
121
131
|
const questId = toInteger(postBody && postBody.api_quest_id)
|
|
122
132
|
if (questId == null || questId <= 0) return
|
|
@@ -125,15 +135,20 @@ function createPoiTelemetry(options = {}) {
|
|
|
125
135
|
questAction = {
|
|
126
136
|
available: true,
|
|
127
137
|
generation: questActionGeneration,
|
|
128
|
-
capturedAt:
|
|
129
|
-
|
|
138
|
+
capturedAt: actionEvent
|
|
139
|
+
? actionEvent.capturedAt
|
|
140
|
+
: now().toISOString(),
|
|
141
|
+
path: actionEvent ? actionEvent.path : detail.path,
|
|
130
142
|
questId,
|
|
131
143
|
flag: toInteger(postBody && postBody.api_quest_flag),
|
|
144
|
+
apiResult,
|
|
132
145
|
}
|
|
133
146
|
}
|
|
134
147
|
|
|
135
|
-
function captureEquipmentAction(detail) {
|
|
136
|
-
const apiResult =
|
|
148
|
+
function captureEquipmentAction(detail, actionEvent) {
|
|
149
|
+
const apiResult = actionEvent
|
|
150
|
+
? actionEvent.apiResult
|
|
151
|
+
: equipmentApiResult(detail)
|
|
137
152
|
if (apiResult === 0) return
|
|
138
153
|
const postBody = normalizeEquipmentPostBody(detail.path, detail.postBody)
|
|
139
154
|
if (!postBody) return
|
|
@@ -142,15 +157,19 @@ function createPoiTelemetry(options = {}) {
|
|
|
142
157
|
equipmentAction = {
|
|
143
158
|
available: true,
|
|
144
159
|
generation: equipmentActionGeneration,
|
|
145
|
-
capturedAt:
|
|
146
|
-
|
|
160
|
+
capturedAt: actionEvent
|
|
161
|
+
? actionEvent.capturedAt
|
|
162
|
+
: now().toISOString(),
|
|
163
|
+
path: actionEvent ? actionEvent.path : detail.path,
|
|
147
164
|
apiResult,
|
|
148
165
|
postBody,
|
|
149
166
|
}
|
|
150
167
|
}
|
|
151
168
|
|
|
152
|
-
function captureFleetAction(detail) {
|
|
153
|
-
const apiResult =
|
|
169
|
+
function captureFleetAction(detail, actionEvent) {
|
|
170
|
+
const apiResult = actionEvent
|
|
171
|
+
? actionEvent.apiResult
|
|
172
|
+
: equipmentApiResult(detail)
|
|
154
173
|
if (apiResult === 0) return
|
|
155
174
|
const postBody = normalizeFleetPostBody(detail.postBody)
|
|
156
175
|
if (!postBody) return
|
|
@@ -159,8 +178,10 @@ function createPoiTelemetry(options = {}) {
|
|
|
159
178
|
fleetAction = {
|
|
160
179
|
available: true,
|
|
161
180
|
generation: fleetActionGeneration,
|
|
162
|
-
capturedAt:
|
|
163
|
-
|
|
181
|
+
capturedAt: actionEvent
|
|
182
|
+
? actionEvent.capturedAt
|
|
183
|
+
: now().toISOString(),
|
|
184
|
+
path: actionEvent ? actionEvent.path : detail.path,
|
|
164
185
|
apiResult,
|
|
165
186
|
postBody,
|
|
166
187
|
}
|
|
@@ -234,6 +255,9 @@ function createPoiTelemetry(options = {}) {
|
|
|
234
255
|
getFleetAction() {
|
|
235
256
|
return fleetAction || { available: false, generation: 0 }
|
|
236
257
|
},
|
|
258
|
+
getActionEvents(options) {
|
|
259
|
+
return actionEvents.read(options)
|
|
260
|
+
},
|
|
237
261
|
getBattleTelemetry() {
|
|
238
262
|
return battleTelemetry || { available: false, generation: 0 }
|
|
239
263
|
},
|