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 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
@@ -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)
@@ -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
+ }
@@ -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: 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,
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
- sendInputJson(res, 200, await enqueueInput(operation))
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, { 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
+ })
147
240
  }
148
241
  }
149
242
 
@@ -182,7 +275,11 @@ function createPoiDataBridge(options = {}) {
182
275
  return
183
276
  }
184
277
 
185
- 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
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
  }
@@ -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: now().toISOString(),
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: now().toISOString(),
129
- path: detail.path,
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 = equipmentApiResult(detail)
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: now().toISOString(),
146
- path: detail.path,
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 = equipmentApiResult(detail)
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: now().toISOString(),
163
- path: detail.path,
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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poi-plugin-mcp",
3
- "version": "0.2.12",
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": [