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 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, install from the repo root:
26
+ When working from this repository, verify the package from the repo root:
27
27
 
28
28
  ```powershell
29
29
  npm install
30
- npm run install:poi
30
+ npm test
31
+ npm pack --dry-run --ignore-scripts
31
32
  ```
32
33
 
33
- Then restart Poi and enable `MCP Data Bridge` if needed.
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}`. `POST /input`
87
- requires `Authorization: Bearer <token>`, accepts at most 64 KiB of JSON, and
88
- works only while WebView input is enabled in the plugin settings. Both routes
89
- disable CORS and return `Cache-Control: no-store`.
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)
@@ -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
+ }
@@ -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: inputSequence,
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
- sendInputJson(res, 200, await enqueueInput(operation))
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, { error: error.message })
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 endpoint = new URL(req.url, `http://127.0.0.1:${actualPort || configuredPort}`).pathname
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
  }
@@ -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: now().toISOString(),
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: now().toISOString(),
122
- path: detail.path,
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 = equipmentApiResult(detail)
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: now().toISOString(),
139
- path: detail.path,
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') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poi-plugin-mcp",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Poi data, WebView capture, and opt-in authenticated input bridge for local KanColle tools.",
5
5
  "main": "index.js",
6
6
  "keywords": [