poi-plugin-mcp 0.2.16 → 0.2.21

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.
@@ -1,1320 +1,1673 @@
1
- const fs = require('fs')
2
- const http = require('http')
3
- const crypto = require('crypto')
4
- const os = require('os')
5
- const path = require('path')
6
- const packageJson = require('../package.json')
7
- const { loadOrCreateInputToken } = require('./input-token')
8
- const { createPoiInputProvider } = require('./poi-input')
9
- const { createPoiInputLease } = require('./poi-input-lease')
10
- const { createPoiScreenshotProvider } = require('./poi-screenshot')
11
-
12
- const DEFAULT_PORT = 17777
13
- const DEFAULT_PORT_FILE = path.join(os.homedir(), '.poi-mcp', 'port')
14
- const DEFAULT_PLANNER_FILE = path.join(
15
- process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
16
- 'poi',
17
- 'poi-plugin-ship-info.json',
18
- )
19
- const DEFAULT_MASTER_FILE = path.join(
20
- process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
21
- 'poi',
22
- 'navy-album',
23
- 'master.json',
24
- )
25
- const JSONRPC_VERSION = '2.0'
26
- const MCP_PROTOCOL_VERSION = '2024-11-05'
27
- const INPUT_BODY_LIMIT = 64 * 1024
28
- const MASTER_FILE_LIMIT = 16 * 1024 * 1024
29
- const DEFAULT_ACTION_EVENT_LIMIT = 64
30
- const MAX_ACTION_EVENT_LIMIT = 256
31
-
32
- function createPoiDataBridge(options = {}) {
33
- const getStore = options.getStore || defaultGetStore
34
- const configuredPort = options.port == null ? DEFAULT_PORT : options.port
35
- const portFile = options.portFile || DEFAULT_PORT_FILE
36
- const plannerFile = options.plannerFile || DEFAULT_PLANNER_FILE
37
- const masterFile = options.masterFile || DEFAULT_MASTER_FILE
38
- const logger = options.logger || console
39
- const getQuestList = options.getQuestList || (() => ({ available: false, generation: 0 }))
40
- const getQuestAction = options.getQuestAction || (() => ({ available: false, generation: 0 }))
41
- const getEquipmentAction = options.getEquipmentAction ||
42
- (() => ({ available: false, generation: 0 }))
43
- const getUnsetSlot = options.getUnsetSlot ||
44
- (() => ({ available: false, generation: 0 }))
45
- const getFleetAction = options.getFleetAction ||
46
- (() => ({ available: false, generation: 0 }))
47
- const getActionEvents = options.getActionEvents ||
48
- (() => ({
49
- available: false,
50
- sessionId: null,
51
- latestGeneration: 0,
52
- events: [],
53
- }))
54
- const getBattleTelemetry = options.getBattleTelemetry ||
55
- (() => ({ available: false, generation: 0 }))
56
- const inputEnabled = options.inputEnabled === true
57
- const inputToken = options.inputToken || (
58
- inputEnabled ? loadOrCreateInputToken(options.inputTokenFile) : null
59
- )
60
- const inputLease = options.inputLease || createPoiInputLease(options.inputLeaseOptions)
61
- let captureScreenshot = options.captureScreenshot || null
62
- let performInput = options.performInput || null
63
-
64
- let server = null
65
- let actualPort = 0
66
- let inputPending = Promise.resolve()
67
-
68
- function readStore() {
69
- const store = getStore()
70
- if (!store || !store.info) {
71
- throw new Error('POI store not ready. Enter the game first.')
72
- }
73
- return store
74
- }
75
-
76
- function sendJson(res, statusCode, data, options = {}) {
77
- const headers = {
78
- 'Content-Type': 'application/json',
79
- }
80
- if (options.allowCors !== false) {
81
- headers['Access-Control-Allow-Origin'] = '*'
82
- }
83
- if (options.noStore) {
84
- headers['Cache-Control'] = 'no-store'
85
- }
86
- Object.assign(headers, options.headers)
87
- res.writeHead(statusCode, headers)
88
- res.end(JSON.stringify(data))
89
- }
90
-
91
- function sendInputJson(res, statusCode, data, options = {}) {
92
- sendJson(res, statusCode, data, {
93
- allowCors: false,
94
- noStore: true,
95
- ...options,
96
- })
97
- }
98
-
99
- function enqueueInput(operation, claim) {
100
- const execute = async () => {
101
- inputLease.assertClaimActive(claim)
102
- if (!performInput) {
103
- performInput = createPoiInputProvider({ getStore })
104
- }
105
- const operationName = await performInput(operation)
106
- return {
107
- ok: true,
108
- operation: operationName,
109
- sequence: claim.sequence,
110
- leaseId: claim.leaseId,
111
- ownerSessionId: claim.ownerSessionId,
112
- runId: claim.runId,
113
- action: claim.action,
114
- acceptedAt: claim.acceptedAt,
115
- }
116
- }
117
- const result = inputPending.then(execute, execute)
118
- inputPending = result.catch(() => {})
119
- return result
120
- }
121
-
122
- async function handleInputRequest(req, res) {
123
- if (req.method !== 'POST') {
124
- drainRequest(req)
125
- sendInputJson(res, 405, { error: 'Input endpoint only accepts POST requests.' })
126
- return
127
- }
128
- if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
129
- drainRequest(req)
130
- sendInputJson(
131
- res,
132
- 401,
133
- { error: 'A valid Bearer token is required.' },
134
- { headers: { 'WWW-Authenticate': 'Bearer' } },
135
- )
136
- return
137
- }
138
- if (requestContentLength(req) > INPUT_BODY_LIMIT) {
139
- drainRequest(req)
140
- sendInputJson(res, 413, { error: 'Input request body exceeds 64KB.' })
141
- return
142
- }
143
- if (!inputEnabled) {
144
- drainRequest(req)
145
- sendInputJson(res, 403, { error: 'WebView input is disabled.' })
146
- return
147
- }
148
-
149
- try {
150
- const body = await readRequestBody(
151
- req,
152
- INPUT_BODY_LIMIT,
153
- 'Input request body exceeds 64KB.',
154
- )
155
- const operation = JSON.parse(body || '{}')
156
- const { input, claim } = claimInputOperation(inputLease, operation)
157
- sendInputJson(res, 200, await enqueueInput(input, claim))
158
- } catch (error) {
159
- const statusCode = error.statusCode || (error.code === 'BODY_TOO_LARGE'
160
- ? 413
161
- : /WebView|dimensions/.test(error.message)
162
- ? 503
163
- : 400)
164
- sendInputJson(res, statusCode, {
165
- ...(error.code ? { code: error.code } : {}),
166
- error: error.message,
167
- })
168
- }
169
- }
170
-
171
- async function handleInputLeaseRequest(req, res, endpoint) {
172
- if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
173
- drainRequest(req)
174
- sendInputJson(
175
- res,
176
- 401,
177
- { error: 'A valid Bearer token is required.' },
178
- { headers: { 'WWW-Authenticate': 'Bearer' } },
179
- )
180
- return
181
- }
182
- if (endpoint === '/input/lease') {
183
- if (req.method !== 'GET') {
184
- drainRequest(req)
185
- sendInputJson(res, 405, { error: 'Input lease status only accepts GET requests.' })
186
- return
187
- }
188
- const lease = inputLease.status()
189
- sendInputJson(res, 200, { active: lease !== null, lease })
190
- return
191
- }
192
- if (req.method !== 'POST') {
193
- drainRequest(req)
194
- sendInputJson(res, 405, { error: 'Input lease changes only accept POST requests.' })
195
- return
196
- }
197
- if (!inputEnabled) {
198
- drainRequest(req)
199
- sendInputJson(res, 403, { error: 'WebView input is disabled.' })
200
- return
201
- }
202
- if (requestContentLength(req) > INPUT_BODY_LIMIT) {
203
- drainRequest(req)
204
- sendInputJson(res, 413, { error: 'Input request body exceeds 64KB.' })
205
- return
206
- }
207
-
208
- try {
209
- const body = await readRequestBody(
210
- req,
211
- INPUT_BODY_LIMIT,
212
- 'Input request body exceeds 64KB.',
213
- )
214
- const request = JSON.parse(body || '{}')
215
- if (endpoint === '/input/lease/acquire') {
216
- const lease = inputLease.acquire(request)
217
- sendInputJson(res, 200, { active: true, lease })
218
- return
219
- }
220
- if (endpoint === '/input/lease/renew') {
221
- const lease = inputLease.renew(request)
222
- sendInputJson(res, 200, { active: true, lease })
223
- return
224
- }
225
- if (endpoint === '/input/lease/release') {
226
- inputLease.release(request)
227
- sendInputJson(res, 200, { active: false, released: true })
228
- return
229
- }
230
- if (endpoint === '/input/lease/revoke') {
231
- inputLease.revoke(request)
232
- sendInputJson(res, 200, { active: false, revoked: true })
233
- return
234
- }
235
- sendInputJson(res, 404, { error: `Unknown input lease endpoint: ${endpoint}` })
236
- } catch (error) {
237
- const statusCode = error.statusCode || (error.code === 'BODY_TOO_LARGE' ? 413 : 400)
238
- sendInputJson(res, statusCode, {
239
- ...(error.code ? { code: error.code } : {}),
240
- error: error.message,
241
- })
242
- }
243
- }
244
-
245
- function handleMcpRequest(req, res) {
246
- if (req.method !== 'POST') {
247
- sendJson(res, 405, { error: 'MCP endpoint only accepts POST requests.' })
248
- return
249
- }
250
-
251
- readRequestBody(req)
252
- .then((body) => {
253
- const message = JSON.parse(body || '{}')
254
- const response = handleMcpMessage(message, readStore, plannerFile)
255
-
256
- if (response == null) {
257
- res.writeHead(202, {
258
- 'Access-Control-Allow-Origin': '*',
259
- 'Content-Type': 'application/json',
260
- })
261
- res.end('')
262
- return
263
- }
264
-
265
- sendJson(res, 200, response)
266
- })
267
- .catch((error) => {
268
- sendJson(res, 200, jsonRpcError(null, -32700, error.message))
269
- })
270
- }
271
-
272
- async function handleRequest(req, res) {
273
- try {
274
- if (req.url === '/shutdown') {
275
- sendJson(res, 200, { status: 'shutting down' })
276
- stop()
277
- return
278
- }
279
-
280
- const requestUrl = new URL(
281
- req.url,
282
- `http://127.0.0.1:${actualPort || configuredPort}`,
283
- )
284
- const endpoint = requestUrl.pathname
285
-
286
- if (endpoint === '/health') {
287
- sendJson(res, 200, { status: 'ok' })
288
- return
289
- }
290
-
291
- if (endpoint === '/mcp') {
292
- handleMcpRequest(req, res)
293
- return
294
- }
295
-
296
- if (endpoint === '/screenshot') {
297
- if (req.method !== 'GET') {
298
- sendJson(
299
- res,
300
- 405,
301
- { error: 'Screenshot endpoint only accepts GET requests.' },
302
- { allowCors: false, noStore: true },
303
- )
304
- return
305
- }
306
- try {
307
- if (!captureScreenshot) {
308
- captureScreenshot = createPoiScreenshotProvider()
309
- }
310
- sendJson(
311
- res,
312
- 200,
313
- await captureScreenshot(),
314
- { allowCors: false, noStore: true },
315
- )
316
- } catch (error) {
317
- sendJson(
318
- res,
319
- 503,
320
- { error: error.message },
321
- { allowCors: false, noStore: true },
322
- )
323
- }
324
- return
325
- }
326
-
327
- if (endpoint === '/input/status') {
328
- if (req.method !== 'GET') {
329
- drainRequest(req)
330
- sendInputJson(
331
- res,
332
- 405,
333
- { error: 'Input status endpoint only accepts GET requests.' },
334
- )
335
- return
336
- }
337
- sendInputJson(res, 200, { enabled: inputEnabled })
338
- return
339
- }
340
-
341
- if (
342
- endpoint === '/input/lease' ||
343
- endpoint === '/input/lease/acquire' ||
344
- endpoint === '/input/lease/renew' ||
345
- endpoint === '/input/lease/release' ||
346
- endpoint === '/input/lease/revoke'
347
- ) {
348
- await handleInputLeaseRequest(req, res, endpoint)
349
- return
350
- }
351
-
352
- if (endpoint === '/input') {
353
- await handleInputRequest(req, res)
354
- return
355
- }
356
-
357
- if (endpoint === '/quest-list') {
358
- sendJson(res, 200, getQuestList())
359
- return
360
- }
361
-
362
- if (endpoint === '/quest-action') {
363
- sendJson(res, 200, getQuestAction())
364
- return
365
- }
366
-
367
- if (endpoint === '/equipment-action') {
368
- sendJson(res, 200, getEquipmentAction())
369
- return
370
- }
371
-
372
- if (endpoint === '/unsetslot') {
373
- sendJson(res, 200, getUnsetSlot())
374
- return
375
- }
376
-
377
- if (endpoint === '/fleet-action') {
378
- sendJson(res, 200, getFleetAction())
379
- return
380
- }
381
-
382
- if (endpoint === '/action-events') {
383
- if (req.method !== 'GET') {
384
- drainRequest(req)
385
- sendJson(res, 405, {
386
- error: 'Action events endpoint only accepts GET requests.',
387
- })
388
- return
389
- }
390
- sendJson(res, 200, getActionEvents({
391
- after: clampedQueryInteger(
392
- requestUrl.searchParams.get('after'),
393
- 0,
394
- 0,
395
- Number.MAX_SAFE_INTEGER,
396
- ),
397
- limit: clampedQueryInteger(
398
- requestUrl.searchParams.get('limit'),
399
- DEFAULT_ACTION_EVENT_LIMIT,
400
- 1,
401
- MAX_ACTION_EVENT_LIMIT,
402
- ),
403
- }))
404
- return
405
- }
406
-
407
- const store = readStore()
408
- const info = store.info
409
-
410
- switch (endpoint) {
411
- case '/basic':
412
- sendJson(res, 200, info.basic || {})
413
- break
414
- case '/fleets':
415
- sendJson(res, 200, info.fleets || [])
416
- break
417
- case '/ships':
418
- sendJson(res, 200, info.ships || {})
419
- break
420
- case '/equipment':
421
- sendJson(res, 200, info.equips || {})
422
- break
423
- case '/resources':
424
- sendJson(res, 200, info.resources || [])
425
- break
426
- case '/quests':
427
- sendJson(res, 200, {
428
- activeQuests: (info.quests && info.quests.activeQuests) || {},
429
- records: (info.quests && info.quests.records) || {},
430
- })
431
- break
432
- case '/airbase':
433
- sendJson(res, 200, info.airbase || [])
434
- break
435
- case '/names':
436
- sendJson(res, 200, extractNames(store))
437
- break
438
- case '/master':
439
- sendJson(res, 200, extractMasterData(store, masterFile))
440
- break
441
- case '/event':
442
- sendJson(res, 200, extractEventData(store))
443
- break
444
- case '/planner':
445
- sendJson(res, 200, extractPlannerData(store, plannerFile))
446
- break
447
- case '/battle':
448
- sendJson(res, 200, combineBattleTelemetry(
449
- getBattleTelemetry(),
450
- extractProphetBattle(store),
451
- ))
452
- break
453
- case '/all':
454
- sendJson(res, 200, {
455
- basic: info.basic || {},
456
- fleets: info.fleets || [],
457
- ships: info.ships || {},
458
- equipment: info.equips || {},
459
- resources: info.resources || [],
460
- quests: {
461
- activeQuests: (info.quests && info.quests.activeQuests) || {},
462
- records: (info.quests && info.quests.records) || {},
463
- },
464
- airbase: info.airbase || [],
465
- repairs: info.repairs || [],
466
- constructions: info.constructions || [],
467
- maps: info.maps || {},
468
- useitems: info.useitems || {},
469
- sortie: store.sortie || {},
470
- names: extractNames(store),
471
- })
472
- break
473
- default:
474
- sendJson(res, 404, { error: `Unknown endpoint: ${endpoint}` })
475
- }
476
- } catch (error) {
477
- sendJson(res, 503, { error: error.message })
478
- }
479
- }
480
-
481
- function start() {
482
- if (server) return Promise.resolve()
483
-
484
- fs.mkdirSync(path.dirname(portFile), { recursive: true })
485
- server = http.createServer(handleRequest)
486
-
487
- return new Promise((resolve, reject) => {
488
- const onError = (error) => {
489
- cleanupPortFile(portFile)
490
- server = null
491
- reject(error)
492
- }
493
-
494
- server.once('error', onError)
495
- server.listen(configuredPort, '127.0.0.1', () => {
496
- server.removeListener('error', onError)
497
- actualPort = server.address().port
498
- fs.writeFileSync(portFile, String(actualPort), 'utf8')
499
- logger.log(`[poi-plugin-mcp] HTTP API started on http://127.0.0.1:${actualPort}`)
500
- resolve()
501
- })
502
- })
503
- }
504
-
505
- function stop() {
506
- if (!server) {
507
- cleanupPortFile(portFile)
508
- actualPort = 0
509
- return Promise.resolve()
510
- }
511
-
512
- const closingServer = server
513
- server = null
514
-
515
- return new Promise((resolve) => {
516
- closingServer.close(() => {
517
- cleanupPortFile(portFile)
518
- actualPort = 0
519
- resolve()
520
- })
521
- })
522
- }
523
-
524
- return {
525
- start,
526
- stop,
527
- getPort() {
528
- return actualPort
529
- },
530
- }
531
- }
532
-
533
- function clampedQueryInteger(value, fallback, minimum, maximum) {
534
- if (value == null || value === '') return fallback
535
- const parsed = Number(value)
536
- if (!Number.isFinite(parsed)) return fallback
537
- return Math.min(maximum, Math.max(minimum, Math.trunc(parsed)))
538
- }
539
-
540
- function claimInputOperation(inputLease, request) {
541
- if (!request || typeof request !== 'object' || Array.isArray(request)) {
542
- throw inputBridgeError('INPUT_LEASE_REQUIRED', 'Input lease fields are required.', 409)
543
- }
544
- const {
545
- leaseId,
546
- ownerSessionId,
547
- runId,
548
- action,
549
- sequence,
550
- ...input
551
- } = request
552
- if (
553
- typeof leaseId !== 'string' ||
554
- typeof ownerSessionId !== 'string' ||
555
- typeof runId !== 'string' ||
556
- typeof action !== 'string' ||
557
- !Number.isSafeInteger(sequence)
558
- ) {
559
- throw inputBridgeError('INPUT_LEASE_REQUIRED', 'Input lease fields are required.', 409)
560
- }
561
- return {
562
- input,
563
- claim: inputLease.consumeInput({
564
- leaseId,
565
- ownerSessionId,
566
- runId,
567
- action,
568
- sequence,
569
- }),
570
- }
571
- }
572
-
573
- function inputBridgeError(code, message, statusCode) {
574
- const error = new Error(message)
575
- error.code = code
576
- error.statusCode = statusCode
577
- return error
578
- }
579
-
580
- function readRequestBody(
581
- req,
582
- maxBytes = 1024 * 1024,
583
- tooLargeMessage = 'MCP request body is too large.',
584
- ) {
585
- return new Promise((resolve, reject) => {
586
- let body = ''
587
- let bodyBytes = 0
588
- let tooLarge = false
589
- req.setEncoding('utf8')
590
- req.on('data', (chunk) => {
591
- if (tooLarge) return
592
- bodyBytes += Buffer.byteLength(chunk)
593
- if (bodyBytes > maxBytes) {
594
- tooLarge = true
595
- body = ''
596
- return
597
- }
598
- body += chunk
599
- })
600
- req.on('end', () => {
601
- if (tooLarge) {
602
- const error = new Error(tooLargeMessage)
603
- error.code = 'BODY_TOO_LARGE'
604
- reject(error)
605
- } else {
606
- resolve(body)
607
- }
608
- })
609
- req.on('error', reject)
610
- })
611
- }
612
-
613
- function requestContentLength(req) {
614
- const value = req.headers['content-length']
615
- if (value == null) return 0
616
- const length = Number(value)
617
- return Number.isSafeInteger(length) && length >= 0 ? length : Infinity
618
- }
619
-
620
- function drainRequest(req) {
621
- req.resume()
622
- }
623
-
624
- function hasValidBearerToken(authorization, expectedToken) {
625
- if (
626
- typeof authorization !== 'string' ||
627
- typeof expectedToken !== 'string' ||
628
- !authorization.startsWith('Bearer ')
629
- ) {
630
- return false
631
- }
632
-
633
- const supplied = Buffer.from(authorization.slice('Bearer '.length), 'utf8')
634
- const expected = Buffer.from(expectedToken, 'utf8')
635
- return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)
636
- }
637
-
638
- function handleMcpMessage(message, readStore, plannerFile) {
639
- const { id, method, params } = message || {}
640
-
641
- switch (method) {
642
- case 'initialize':
643
- return jsonRpcResult(id, {
644
- protocolVersion: MCP_PROTOCOL_VERSION,
645
- capabilities: {
646
- resources: { subscribe: false },
647
- tools: {},
648
- },
649
- serverInfo: { name: 'poi-plugin-mcp', version: packageJson.version },
650
- })
651
-
652
- case 'notifications/initialized':
653
- case 'notifications/cancelled':
654
- return null
655
-
656
- case 'ping':
657
- return jsonRpcResult(id, {})
658
-
659
- case 'resources/list':
660
- return jsonRpcResult(id, {
661
- resources: MCP_RESOURCE_ENDPOINTS.map(({ uri }) => ({
662
- uri,
663
- name: uri.replace('poi://', ''),
664
- mimeType: 'application/json',
665
- })),
666
- })
667
-
668
- case 'resources/read': {
669
- const uri = params && params.uri
670
- const endpoint = MCP_RESOURCE_ENDPOINTS.find((item) => item.uri === uri)
671
- if (!endpoint) return jsonRpcError(id, -32602, `Unknown resource: ${uri}`)
672
- const data = readBridgeData(endpoint.path, readStore, plannerFile)
673
- return jsonRpcResult(id, {
674
- contents: [{
675
- uri,
676
- mimeType: 'application/json',
677
- text: JSON.stringify(data, null, 2),
678
- }],
679
- })
680
- }
681
-
682
- case 'tools/list':
683
- return jsonRpcResult(id, { tools: MCP_TOOLS })
684
-
685
- case 'tools/call': {
686
- const toolName = params && params.name
687
- const toolArgs = (params && params.arguments) || {}
688
- const result = callMcpTool(toolName, toolArgs, readStore, plannerFile)
689
- if (result.error) return jsonRpcError(id, -32602, result.error)
690
- return jsonRpcResult(id, {
691
- content: [{ type: 'text', text: JSON.stringify(result.value, null, 2) }],
692
- })
693
- }
694
-
695
- default:
696
- return jsonRpcError(id, -32601, `Unknown method: ${method}`)
697
- }
698
- }
699
-
700
- const MCP_RESOURCE_ENDPOINTS = [
701
- { uri: 'poi://basic', path: '/basic' },
702
- { uri: 'poi://fleets', path: '/fleets' },
703
- { uri: 'poi://ships', path: '/ships' },
704
- { uri: 'poi://equipment', path: '/equipment' },
705
- { uri: 'poi://resources', path: '/resources' },
706
- { uri: 'poi://quests', path: '/quests' },
707
- { uri: 'poi://airbase', path: '/airbase' },
708
- { uri: 'poi://names', path: '/names' },
709
- { uri: 'poi://master', path: '/master' },
710
- { uri: 'poi://event', path: '/event' },
711
- { uri: 'poi://planner', path: '/planner' },
712
- { uri: 'poi://all', path: '/all' },
713
- ]
714
-
715
- const MCP_TOOLS = [
716
- {
717
- name: 'get_fleet_status',
718
- description: 'Get one owned fleet with ship and equipped item instance details.',
719
- inputSchema: {
720
- type: 'object',
721
- properties: { fleetId: { type: 'number', description: 'Fleet number, 1-4.' } },
722
- required: ['fleetId'],
723
- },
724
- },
725
- {
726
- name: 'search_ships',
727
- description: 'Search owned ship instances by level and morale.',
728
- inputSchema: {
729
- type: 'object',
730
- properties: {
731
- minLevel: { type: 'number' },
732
- maxLevel: { type: 'number' },
733
- minMorale: { type: 'number' },
734
- },
735
- },
736
- },
737
- {
738
- name: 'search_equipment',
739
- description: 'Search owned equipment instances by improvement level.',
740
- inputSchema: {
741
- type: 'object',
742
- properties: {
743
- minLevel: { type: 'number', description: 'Minimum improvement level.' },
744
- },
745
- },
746
- },
747
- {
748
- name: 'get_resources',
749
- description: 'Get current account resource array.',
750
- inputSchema: { type: 'object', properties: {} },
751
- },
752
- {
753
- name: 'get_all',
754
- description: 'Get account basics, fleets, ships, equipment, resources, quests, airbase, and names. Optionally include master, event, and planner data.',
755
- inputSchema: {
756
- type: 'object',
757
- properties: {
758
- include: {
759
- type: 'array',
760
- items: { type: 'string', enum: ['master', 'event', 'planner'] },
761
- },
762
- },
763
- },
764
- },
765
- ]
766
-
767
- function callMcpTool(toolName, args, readStore, plannerFile) {
768
- switch (toolName) {
769
- case 'get_fleet_status':
770
- return { value: buildFleetStatus(args || {}, readStore) }
771
- case 'search_ships':
772
- return { value: searchShips(args || {}, readStore) }
773
- case 'search_equipment':
774
- return { value: searchEquipment(args || {}, readStore) }
775
- case 'get_resources':
776
- return { value: readBridgeData('/resources', readStore, plannerFile) }
777
- case 'get_all':
778
- return { value: buildAllPayload(args || {}, readStore, plannerFile) }
779
- default:
780
- return { error: `Unknown tool: ${toolName}` }
781
- }
782
- }
783
-
784
- function readBridgeData(endpoint, readStore, plannerFile) {
785
- const store = readStore()
786
- const info = store.info
787
-
788
- switch (endpoint) {
789
- case '/basic':
790
- return info.basic || {}
791
- case '/fleets':
792
- return info.fleets || []
793
- case '/ships':
794
- return info.ships || {}
795
- case '/equipment':
796
- return info.equips || {}
797
- case '/resources':
798
- return info.resources || []
799
- case '/quests':
800
- return {
801
- activeQuests: (info.quests && info.quests.activeQuests) || {},
802
- records: (info.quests && info.quests.records) || {},
803
- }
804
- case '/airbase':
805
- return info.airbase || []
806
- case '/names':
807
- return extractNames(store)
808
- case '/master':
809
- return extractMasterData(store)
810
- case '/event':
811
- return extractEventData(store)
812
- case '/planner':
813
- return extractPlannerData(store, plannerFile)
814
- case '/all':
815
- return {
816
- basic: info.basic || {},
817
- fleets: info.fleets || [],
818
- ships: info.ships || {},
819
- equipment: info.equips || {},
820
- resources: info.resources || [],
821
- quests: {
822
- activeQuests: (info.quests && info.quests.activeQuests) || {},
823
- records: (info.quests && info.quests.records) || {},
824
- },
825
- airbase: info.airbase || [],
826
- repairs: info.repairs || [],
827
- constructions: info.constructions || [],
828
- maps: info.maps || {},
829
- useitems: info.useitems || {},
830
- sortie: store.sortie || {},
831
- names: extractNames(store),
832
- }
833
- default:
834
- throw new Error(`Unknown endpoint: ${endpoint}`)
835
- }
836
- }
837
-
838
- function buildFleetStatus(args, readStore) {
839
- const fleetId = Number(args.fleetId)
840
- const store = readStore()
841
- const info = store.info
842
- const fleets = Array.isArray(info.fleets) ? info.fleets : []
843
- const fleet = fleets[fleetId - 1]
844
- if (!fleet) return { error: `Fleet #${args.fleetId} not found` }
845
-
846
- const ships = info.ships || {}
847
- const equips = info.equips || {}
848
-
849
- return {
850
- id: fleet.api_id,
851
- name: fleet.api_name,
852
- mission: fleet.api_mission,
853
- ships: (fleet.api_ship || []).filter((id) => id > 0).map((shipId) => {
854
- const ship = ships[shipId]
855
- if (!ship) return { id: shipId }
856
-
857
- return {
858
- id: ship.api_id,
859
- shipId: ship.api_ship_id,
860
- level: ship.api_lv,
861
- hp: `${ship.api_nowhp}/${ship.api_maxhp}`,
862
- morale: ship.api_cond,
863
- locked: ship.api_locked,
864
- slotItems: (ship.api_slot || []).filter((equipId) => equipId > 0).map((equipId) => {
865
- const equip = equips[equipId]
866
- return equip ? {
867
- id: equip.api_id,
868
- equipId: equip.api_slotitem_id,
869
- level: equip.api_level || 0,
870
- prof: equip.api_alv || 0,
871
- } : null
872
- }).filter(Boolean),
873
- }
874
- }),
875
- }
876
- }
877
-
878
- function searchShips(args, readStore) {
879
- const store = readStore()
880
- const master = extractMasterData(store)
881
- const ships = Object.values((store.info && store.info.ships) || {}).filter((ship) => {
882
- if (!ship) return false
883
- if (args.minLevel != null && ship.api_lv < Number(args.minLevel)) return false
884
- if (args.maxLevel != null && ship.api_lv > Number(args.maxLevel)) return false
885
- if (args.minMorale != null && ship.api_cond < Number(args.minMorale)) return false
886
- return true
887
- }).map((ship) => enrichShip(ship, master))
888
-
889
- return { total: ships.length, ships }
890
- }
891
-
892
- function searchEquipment(args, readStore) {
893
- const store = readStore()
894
- const master = extractMasterData(store)
895
- const equipment = Object.values((store.info && store.info.equips) || {}).filter((equip) => {
896
- if (!equip) return false
897
- if (args.minLevel != null && (equip.api_level || 0) < Number(args.minLevel)) return false
898
- return true
899
- }).map((equip) => enrichEquipment(equip, master))
900
-
901
- return { total: equipment.length, equipment }
902
- }
903
-
904
- function buildAllPayload(args, readStore, plannerFile) {
905
- const payload = readBridgeData('/all', readStore, plannerFile)
906
- const include = Array.isArray(args.include) ? new Set(args.include) : new Set()
907
-
908
- if (include.has('master')) payload.master = readBridgeData('/master', readStore, plannerFile)
909
- if (include.has('event')) payload.event = readBridgeData('/event', readStore, plannerFile)
910
- if (include.has('planner')) payload.planner = readBridgeData('/planner', readStore, plannerFile)
911
-
912
- return payload
913
- }
914
-
915
- function enrichShip(ship, master) {
916
- const masterShip = master.ships && master.ships[ship.api_ship_id]
917
- const shipType = masterShip && master.shipTypes && master.shipTypes[masterShip.api_stype]
918
-
919
- return {
920
- ...ship,
921
- instanceId: ship.api_id,
922
- masterId: ship.api_ship_id,
923
- name: (masterShip && masterShip.api_name) || '',
924
- typeName: (shipType && shipType.api_name) || '',
925
- }
926
- }
927
-
928
- function enrichEquipment(equip, master) {
929
- const masterEquip = master.equipment && master.equipment[equip.api_slotitem_id]
930
- const typeIds = masterEquip && Array.isArray(masterEquip.api_type) ? masterEquip.api_type : []
931
- const typeId = typeIds[2] || typeIds[1] || typeIds[0]
932
- const equipType = typeId && master.equipmentTypes && master.equipmentTypes[typeId]
933
-
934
- return {
935
- ...equip,
936
- instanceId: equip.api_id,
937
- masterId: equip.api_slotitem_id,
938
- name: (masterEquip && masterEquip.api_name) || '',
939
- typeName: (equipType && equipType.api_name) || '',
940
- }
941
- }
942
-
943
- function jsonRpcResult(id, result) {
944
- return { jsonrpc: JSONRPC_VERSION, id, result }
945
- }
946
-
947
- function jsonRpcError(id, code, message) {
948
- return { jsonrpc: JSONRPC_VERSION, id, error: { code, message } }
949
- }
950
-
951
- function defaultGetStore(storePath) {
952
- if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
953
- return window.getStore(storePath)
954
- }
955
- return null
956
- }
957
-
958
- function extractProphetBattle(store) {
959
- const battle = store &&
960
- store.ext &&
961
- store.ext['poi-plugin-prophet'] &&
962
- store.ext['poi-plugin-prophet']._ &&
963
- store.ext['poi-plugin-prophet']._.battle
964
- if (!battle || typeof battle !== 'object') {
965
- return {
966
- available: false,
967
- source: 'poi-plugin-prophet',
968
- engine: 'poi-lib-battle',
969
- engineVersion: '3.0.5',
970
- }
971
- }
972
-
973
- const fleets = {
974
- main: compactBattleFleet(battle.mainFleet),
975
- escort: compactBattleFleet(battle.escortFleet),
976
- enemy: compactBattleFleet(battle.enemyFleet),
977
- enemyEscort: compactBattleFleet(battle.enemyEscort),
978
- }
979
- const result = compactBattleResult(battle.result)
980
- return {
981
- available: true,
982
- source: 'poi-plugin-prophet',
983
- engine: 'poi-lib-battle',
984
- engineVersion: '3.0.5',
985
- sortieState: finiteNumber(battle.sortieState, 0),
986
- sortieStateName: sortieStateName(battle.sortieState),
987
- mapAreaId: finiteNumber(battle.mapAreaId, 0),
988
- eventId: finiteNumber(battle.eventId, 0),
989
- eventKind: finiteNumber(battle.eventKind, 0),
990
- airControl: stringValue(battle.airControl),
991
- battleForm: stringValue(battle.battleForm),
992
- enemyFormation: stringValue(battle.eFormation),
993
- rank: typeof result.rank === 'string' ? result.rank : null,
994
- mvpIndex0Based: compactMvp(result.mvp),
995
- heavilyDamaged: findHeavilyDamaged(fleets),
996
- fleets,
997
- }
998
- }
999
-
1000
- function combineBattleTelemetry(telemetry, predicted) {
1001
- const current = telemetry && typeof telemetry === 'object'
1002
- ? telemetry
1003
- : { available: false, generation: 0 }
1004
- return {
1005
- available: current.available === true || predicted.available === true,
1006
- generation: finiteNumber(current.generation, 0),
1007
- status: typeof current.status === 'string' ? current.status : 'unavailable',
1008
- observed: current.observed || null,
1009
- predicted,
1010
- official: current.official || null,
1011
- }
1012
- }
1013
-
1014
- function compactBattleResult(result) {
1015
- if (!result || typeof result !== 'object') return {}
1016
- return Object.fromEntries(
1017
- ['rank', 'mvp', 'getShip', 'getItem']
1018
- .filter((key) => result[key] !== undefined)
1019
- .map((key) => [key, result[key]]),
1020
- )
1021
- }
1022
-
1023
- function compactMvp(value) {
1024
- const values = Array.isArray(value) ? value : [value, null]
1025
- return {
1026
- main: Number.isInteger(values[0]) && values[0] >= 0 ? values[0] : null,
1027
- escort: Number.isInteger(values[1]) && values[1] >= 0 ? values[1] : null,
1028
- }
1029
- }
1030
-
1031
- function findHeavilyDamaged(fleets) {
1032
- return ['main', 'escort'].flatMap((fleetName) =>
1033
- fleets[fleetName].flatMap((ship) => {
1034
- if (
1035
- !Number.isFinite(ship.currentHp) ||
1036
- !Number.isFinite(ship.maxHp) ||
1037
- ship.maxHp <= 0 ||
1038
- ship.currentHp > ship.maxHp * 0.25
1039
- ) {
1040
- return []
1041
- }
1042
- return [{
1043
- fleet: fleetName,
1044
- position: ship.position,
1045
- instanceId: ship.instanceId,
1046
- currentHp: ship.currentHp,
1047
- maxHp: ship.maxHp,
1048
- }]
1049
- }),
1050
- )
1051
- }
1052
-
1053
- function compactBattleFleet(fleet) {
1054
- if (!Array.isArray(fleet)) return []
1055
- return fleet.flatMap((ship) => {
1056
- if (!ship || typeof ship !== 'object') return []
1057
- const raw = ship.raw && typeof ship.raw === 'object' ? ship.raw : {}
1058
- return [{
1059
- id: nullableNumber(ship.id),
1060
- owner: nullableNumber(ship.owner),
1061
- position: nullableNumber(ship.pos),
1062
- maxHp: nullableNumber(ship.maxHP),
1063
- initialHp: nullableNumber(ship.initHP),
1064
- currentHp: nullableNumber(ship.nowHP),
1065
- lostHp: nullableNumber(ship.lostHP),
1066
- damage: nullableNumber(ship.damage),
1067
- items: Array.isArray(ship.items) ? [...ship.items] : [],
1068
- useItem: ship.useItem == null ? null : ship.useItem,
1069
- instanceId: Number.isInteger(raw.api_id) ? raw.api_id : null,
1070
- masterId: Number.isInteger(raw.api_ship_id) ? raw.api_ship_id : null,
1071
- }]
1072
- })
1073
- }
1074
-
1075
- function sortieStateName(value) {
1076
- return ['in_port', 'navigation', 'battle', 'practice'][value] || 'unknown'
1077
- }
1078
-
1079
- function finiteNumber(value, fallback) {
1080
- return Number.isFinite(value) ? value : fallback
1081
- }
1082
-
1083
- function nullableNumber(value) {
1084
- return Number.isFinite(value) ? value : null
1085
- }
1086
-
1087
- function stringValue(value) {
1088
- return typeof value === 'string' ? value : ''
1089
- }
1090
-
1091
- function cleanupPortFile(portFile) {
1092
- try {
1093
- fs.unlinkSync(portFile)
1094
- } catch (_) {}
1095
- }
1096
-
1097
- function extractNames(store) {
1098
- const result = { ships: {}, equipment: {}, missions: {} }
1099
- const constants = store.const || {}
1100
-
1101
- collectApiNames(constants.$ships, result.ships)
1102
- collectApiNames(constants.$equips, result.equipment)
1103
- collectApiNames(constants.$missions, result.missions)
1104
-
1105
- const wctf = store.wctf || {}
1106
- if (Object.keys(result.ships).length === 0) collectSimpleNames(wctf.ships, result.ships)
1107
- if (Object.keys(result.equipment).length === 0) collectSimpleNames(wctf.items, result.equipment)
1108
-
1109
- return result
1110
- }
1111
-
1112
- function collectApiNames(source, target) {
1113
- if (!source || typeof source !== 'object') return
1114
-
1115
- for (const [id, value] of Object.entries(source)) {
1116
- if (value && value.api_name) target[id] = value.api_name
1117
- }
1118
- }
1119
-
1120
- function collectSimpleNames(source, target) {
1121
- if (!source || typeof source !== 'object') return
1122
-
1123
- for (const [id, value] of Object.entries(source)) {
1124
- if (value && value.name) target[id] = value.name
1125
- }
1126
- }
1127
-
1128
- function extractMasterData(store, masterFile = DEFAULT_MASTER_FILE) {
1129
- const constants = store.const || {}
1130
-
1131
- return {
1132
- ships: constants.$ships || {},
1133
- equipment: constants.$equips || {},
1134
- shipTypes: constants.$shipTypes || {},
1135
- equipmentTypes:
1136
- constants.$equipTypes ||
1137
- constants.$equipmentTypes ||
1138
- constants.$slotitemTypes ||
1139
- constants.$slotItemTypes ||
1140
- {},
1141
- missions: constants.$missions || {},
1142
- equipmentRules: readEquipmentRules(masterFile),
1143
- }
1144
- }
1145
-
1146
- function readEquipmentRules(masterFile) {
1147
- try {
1148
- const stat = fs.statSync(masterFile)
1149
- if (!stat.isFile() || stat.size <= 0 || stat.size > MASTER_FILE_LIMIT) {
1150
- return { available: false, source: 'navy-album-master-cache' }
1151
- }
1152
- const master = JSON.parse(fs.readFileSync(masterFile, 'utf8'))
1153
- const equipmentShip = objectOrEmpty(master.api_mst_equip_ship)
1154
- const equipmentExslotTypes = Array.isArray(master.api_mst_equip_exslot)
1155
- ? master.api_mst_equip_exslot.filter(Number.isInteger)
1156
- : []
1157
- const equipmentExslotShip = objectOrEmpty(
1158
- master.api_mst_equip_exslot_ship,
1159
- )
1160
- const equipmentLimitExslot = objectOrEmpty(
1161
- master.api_mst_equip_limit_exslot,
1162
- )
1163
- if (
1164
- Object.keys(equipmentShip).length === 0 ||
1165
- equipmentExslotTypes.length === 0
1166
- ) {
1167
- return { available: false, source: 'navy-album-master-cache' }
1168
- }
1169
- return {
1170
- available: true,
1171
- source: 'navy-album-master-cache',
1172
- equipmentShip,
1173
- equipmentExslotTypes,
1174
- equipmentExslotShip,
1175
- equipmentLimitExslot,
1176
- }
1177
- } catch (_) {
1178
- return { available: false, source: 'navy-album-master-cache' }
1179
- }
1180
- }
1181
-
1182
- function objectOrEmpty(value) {
1183
- return value && typeof value === 'object' && !Array.isArray(value)
1184
- ? value
1185
- : {}
1186
- }
1187
-
1188
- function extractEventData(store) {
1189
- const tags = extractShipTags(store)
1190
- const ships = {}
1191
-
1192
- for (const ship of Object.values((store.info && store.info.ships) || {})) {
1193
- if (!ship || typeof ship !== 'object') continue
1194
- const area = ship.api_sally_area || 0
1195
- const tag = tags[area - 1] || emptyTag(area)
1196
-
1197
- ships[ship.api_id] = {
1198
- instanceId: ship.api_id,
1199
- masterId: ship.api_ship_id,
1200
- shipId: ship.api_id,
1201
- modelId: ship.api_ship_id,
1202
- area,
1203
- mapName: tag.mapName,
1204
- fleetName: tag.fleetName,
1205
- color: tag.color,
1206
- }
1207
- }
1208
-
1209
- return {
1210
- tags,
1211
- ships,
1212
- }
1213
- }
1214
-
1215
- function extractPlannerData(store, plannerFile = DEFAULT_PLANNER_FILE) {
1216
- const tags = extractShipTags(store)
1217
- const current = readPlannerCurrent(plannerFile)
1218
- const length = Math.max(tags.length, current.length)
1219
- const areas = []
1220
- const shipMap = {}
1221
-
1222
- for (let index = 0; index < length; index += 1) {
1223
- const area = index + 1
1224
- const tag = tags[index] || emptyTag(area)
1225
- const shipIds = Array.isArray(current[index]) ? current[index] : []
1226
-
1227
- areas.push({
1228
- area,
1229
- mapName: tag.mapName,
1230
- fleetName: tag.fleetName,
1231
- color: tag.color,
1232
- shipIds,
1233
- })
1234
-
1235
- for (const shipId of shipIds) {
1236
- shipMap[shipId] = {
1237
- area,
1238
- mapName: tag.mapName,
1239
- fleetName: tag.fleetName,
1240
- color: tag.color,
1241
- }
1242
- }
1243
- }
1244
-
1245
- return {
1246
- areas,
1247
- shipMap,
1248
- }
1249
- }
1250
-
1251
- function readPlannerCurrent(plannerFile) {
1252
- try {
1253
- const data = JSON.parse(fs.readFileSync(plannerFile, 'utf8'))
1254
- if (Array.isArray(data.planner)) return data.planner
1255
- if (data.planner && Array.isArray(data.planner.current)) {
1256
- return data.planner.current
1257
- }
1258
- } catch (_) {}
1259
-
1260
- return []
1261
- }
1262
-
1263
- function extractShipTags(store) {
1264
- const shiptag = (store.fcd && store.fcd.shiptag) || {}
1265
- const mapNames = Array.isArray(shiptag.mapname) ? shiptag.mapname : []
1266
- const fleetNames = selectFleetNames(shiptag.fleetname)
1267
- const colors = Array.isArray(shiptag.color) ? shiptag.color : []
1268
-
1269
- return mapNames.map((mapName, index) => ({
1270
- area: index + 1,
1271
- mapName,
1272
- fleetName: fleetNames[index] || mapName,
1273
- color: colors[index] || '',
1274
- }))
1275
- }
1276
-
1277
- function selectFleetNames(fleetname) {
1278
- if (Array.isArray(fleetname)) return fleetname
1279
- if (!fleetname || typeof fleetname !== 'object') return []
1280
-
1281
- const language = getWindowLanguage()
1282
- return (
1283
- fleetname[language] ||
1284
- fleetname['zh-CN'] ||
1285
- fleetname['zh-TW'] ||
1286
- fleetname.ja ||
1287
- fleetname['ja-JP'] ||
1288
- fleetname['en-US'] ||
1289
- Object.values(fleetname).find(Array.isArray) ||
1290
- []
1291
- )
1292
- }
1293
-
1294
- function getWindowLanguage() {
1295
- if (typeof window !== 'undefined' && window.language) {
1296
- return window.language
1297
- }
1298
- return 'zh-CN'
1299
- }
1300
-
1301
- function emptyTag(area) {
1302
- return {
1303
- area,
1304
- mapName: '',
1305
- fleetName: '',
1306
- color: '',
1307
- }
1308
- }
1309
-
1310
- module.exports = {
1311
- createPoiDataBridge,
1312
- DEFAULT_PORT,
1313
- DEFAULT_PORT_FILE,
1314
- DEFAULT_PLANNER_FILE,
1315
- INPUT_BODY_LIMIT,
1316
- extractEventData,
1317
- extractMasterData,
1318
- extractPlannerData,
1319
- extractNames,
1320
- }
1
+ const fs = require('fs')
2
+ const http = require('http')
3
+ const crypto = require('crypto')
4
+ const os = require('os')
5
+ const path = require('path')
6
+ const packageJson = require('../package.json')
7
+ const { loadOrCreateInputToken } = require('./input-token')
8
+ const { createPoiInputProvider } = require('./poi-input')
9
+ const { createPoiInputLease } = require('./poi-input-lease')
10
+ const { createPoiScreenshotProvider } = require('./poi-screenshot')
11
+ const {
12
+ MAX_QUERY_BODY_BYTES,
13
+ createPoiDataQuery,
14
+ } = require('./poi-data-query')
15
+ const {
16
+ collectFleetMetricShips,
17
+ inspectFleetMetrics,
18
+ moraleMeaning,
19
+ speedFromRaw,
20
+ speedMeaning,
21
+ } = require('./fleet-metrics')
22
+
23
+ const DEFAULT_PORT = 17777
24
+ const DEFAULT_PORT_FILE = path.join(os.homedir(), '.poi-mcp', 'port')
25
+ const DEFAULT_PLANNER_FILE = path.join(
26
+ process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
27
+ 'poi',
28
+ 'poi-plugin-ship-info.json',
29
+ )
30
+ const DEFAULT_MASTER_FILE = path.join(
31
+ process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
32
+ 'poi',
33
+ 'navy-album',
34
+ 'master.json',
35
+ )
36
+ const JSONRPC_VERSION = '2.0'
37
+
38
+ const POI_RESOURCE_FIELDS = Object.freeze([
39
+ ['fuel', '燃料'],
40
+ ['ammo', '弹药'],
41
+ ['steel', '钢材'],
42
+ ['bauxite', '铝土'],
43
+ ['instantConstruction', '高速建造材(喷火)'],
44
+ ['repairBuckets', '高速修复材(桶)'],
45
+ ['developmentMaterials', '开发资材'],
46
+ ['improvementMaterials', '改修资材(螺丝)'],
47
+ ])
48
+
49
+ function decodePoiResources(raw) {
50
+ const values = Array.isArray(raw) ? raw : []
51
+ const named = { raw: values }
52
+ for (let index = 0; index < POI_RESOURCE_FIELDS.length; index += 1) {
53
+ const [key, label] = POI_RESOURCE_FIELDS[index]
54
+ named[key] = { key, label, raw: values[index] ?? null }
55
+ }
56
+ return named
57
+ }
58
+ const MCP_PROTOCOL_VERSION = '2024-11-05'
59
+ const INPUT_BODY_LIMIT = 64 * 1024
60
+ const MASTER_FILE_LIMIT = 16 * 1024 * 1024
61
+ const DEFAULT_ACTION_EVENT_LIMIT = 64
62
+ const MAX_ACTION_EVENT_LIMIT = 256
63
+ const DEFAULT_ACTION_EVENT_WAIT_TIMEOUT_MS = 30_000
64
+ const MAX_ACTION_EVENT_WAIT_TIMEOUT_MS = 60_000
65
+
66
+ function createPoiDataBridge(options = {}) {
67
+ const getStore = options.getStore || defaultGetStore
68
+ const configuredPort = options.port == null ? DEFAULT_PORT : options.port
69
+ const portFile = options.portFile || DEFAULT_PORT_FILE
70
+ const plannerFile = options.plannerFile || DEFAULT_PLANNER_FILE
71
+ const masterFile = options.masterFile || DEFAULT_MASTER_FILE
72
+ const logger = options.logger || console
73
+ const getQuestList = options.getQuestList || (() => ({ available: false, generation: 0 }))
74
+ const getQuestAction = options.getQuestAction || (() => ({ available: false, generation: 0 }))
75
+ const getEquipmentAction = options.getEquipmentAction ||
76
+ (() => ({ available: false, generation: 0 }))
77
+ const getEquipmentSelection = options.getEquipmentSelection ||
78
+ (() => ({ available: false, generation: 0 }))
79
+ const getUnsetSlot = options.getUnsetSlot ||
80
+ (() => ({ available: false, generation: 0 }))
81
+ const getFleetAction = options.getFleetAction ||
82
+ (() => ({ available: false, generation: 0 }))
83
+ const getActionEvents = options.getActionEvents ||
84
+ (() => ({
85
+ available: false,
86
+ sessionId: null,
87
+ latestGeneration: 0,
88
+ events: [],
89
+ }))
90
+ const getActionEventsWait = options.getActionEventsWait ||
91
+ (typeof getActionEvents.wait === 'function'
92
+ ? getActionEvents.wait.bind(getActionEvents)
93
+ : null)
94
+ const getApiResponses = options.getApiResponses ||
95
+ (() => ({
96
+ available: false,
97
+ latestGeneration: 0,
98
+ responses: [],
99
+ }))
100
+ const getBattleTelemetry = options.getBattleTelemetry ||
101
+ (() => ({ available: false, generation: 0 }))
102
+ const inputEnabled = options.inputEnabled === true
103
+ const debugEvalEnabled = options.debugEvalEnabled === true
104
+ const inputToken = options.inputToken || (
105
+ inputEnabled ? loadOrCreateInputToken(options.inputTokenFile) : null
106
+ )
107
+ const inputLease = options.inputLease || createPoiInputLease(options.inputLeaseOptions)
108
+ let captureScreenshot = options.captureScreenshot || null
109
+ let performInput = options.performInput || null
110
+ let dataQuery = options.dataQuery || null
111
+
112
+ let server = null
113
+ let actualPort = 0
114
+ let inputPending = Promise.resolve()
115
+ const pendingActionEventWaits = new Set()
116
+
117
+ function readStore() {
118
+ const store = getStore()
119
+ if (!store || !store.info) {
120
+ throw new Error('POI store not ready. Enter the game first.')
121
+ }
122
+ return store
123
+ }
124
+
125
+ function sendJson(res, statusCode, data, options = {}) {
126
+ const headers = {
127
+ 'Content-Type': 'application/json',
128
+ }
129
+ if (options.allowCors !== false) {
130
+ headers['Access-Control-Allow-Origin'] = '*'
131
+ }
132
+ if (options.noStore) {
133
+ headers['Cache-Control'] = 'no-store'
134
+ }
135
+ Object.assign(headers, options.headers)
136
+ res.writeHead(statusCode, headers)
137
+ res.end(JSON.stringify(data))
138
+ }
139
+
140
+ function sendInputJson(res, statusCode, data, options = {}) {
141
+ sendJson(res, statusCode, data, {
142
+ allowCors: false,
143
+ noStore: true,
144
+ ...options,
145
+ })
146
+ }
147
+
148
+ function currentDataQuery() {
149
+ if (!dataQuery) {
150
+ dataQuery = createPoiDataQuery({
151
+ getStore,
152
+ getApiResponses,
153
+ resolveWebContents: options.resolveWebContents,
154
+ cacheRoot: options.cacheRoot,
155
+ logger,
156
+ })
157
+ }
158
+ return dataQuery
159
+ }
160
+
161
+ async function handleDataRequest(req, res, endpoint) {
162
+ if (req.method !== 'POST') {
163
+ drainRequest(req)
164
+ sendInputJson(res, 405, { error: `${endpoint} only accepts POST requests.` })
165
+ return
166
+ }
167
+ if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
168
+ drainRequest(req)
169
+ sendInputJson(
170
+ res,
171
+ 401,
172
+ { error: 'A valid Bearer token is required.' },
173
+ { headers: { 'WWW-Authenticate': 'Bearer' } },
174
+ )
175
+ return
176
+ }
177
+ try {
178
+ const body = await readRequestBody(
179
+ req,
180
+ MAX_QUERY_BODY_BYTES,
181
+ 'Data request body exceeds 64KB.',
182
+ )
183
+ const request = JSON.parse(body || '{}')
184
+ if (endpoint === '/query') {
185
+ sendInputJson(res, 200, await currentDataQuery().query(request))
186
+ return
187
+ }
188
+ if (!debugEvalEnabled) {
189
+ sendInputJson(res, 403, {
190
+ code: 'DEBUG_EVAL_DISABLED',
191
+ error: 'Dangerous WebView debug evaluation is disabled in Poi settings.',
192
+ })
193
+ return
194
+ }
195
+ sendInputJson(
196
+ res,
197
+ 200,
198
+ await currentDataQuery().runtime.evaluate(request),
199
+ )
200
+ } catch (error) {
201
+ sendInputJson(
202
+ res,
203
+ error.statusCode || (error.code === 'BODY_TOO_LARGE' ? 413 : 400),
204
+ {
205
+ ...(error.code ? { code: error.code } : {}),
206
+ ...(error.details ? { details: error.details } : {}),
207
+ error: error.message,
208
+ },
209
+ )
210
+ }
211
+ }
212
+
213
+ function enqueueInput(operation, claim) {
214
+ const execute = async () => {
215
+ inputLease.assertClaimActive(claim)
216
+ if (!performInput) {
217
+ performInput = createPoiInputProvider({ getStore })
218
+ }
219
+ const operationName = await performInput(operation)
220
+ return {
221
+ ok: true,
222
+ operation: operationName,
223
+ sequence: claim.sequence,
224
+ leaseId: claim.leaseId,
225
+ ownerSessionId: claim.ownerSessionId,
226
+ runId: claim.runId,
227
+ action: claim.action,
228
+ acceptedAt: claim.acceptedAt,
229
+ }
230
+ }
231
+ const result = inputPending.then(execute, execute)
232
+ inputPending = result.catch(() => {})
233
+ return result
234
+ }
235
+
236
+ async function handleInputRequest(req, res) {
237
+ if (req.method !== 'POST') {
238
+ drainRequest(req)
239
+ sendInputJson(res, 405, { error: 'Input endpoint only accepts POST requests.' })
240
+ return
241
+ }
242
+ if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
243
+ drainRequest(req)
244
+ sendInputJson(
245
+ res,
246
+ 401,
247
+ { error: 'A valid Bearer token is required.' },
248
+ { headers: { 'WWW-Authenticate': 'Bearer' } },
249
+ )
250
+ return
251
+ }
252
+ if (requestContentLength(req) > INPUT_BODY_LIMIT) {
253
+ drainRequest(req)
254
+ sendInputJson(res, 413, { error: 'Input request body exceeds 64KB.' })
255
+ return
256
+ }
257
+ if (!inputEnabled) {
258
+ drainRequest(req)
259
+ sendInputJson(res, 403, { error: 'WebView input is disabled.' })
260
+ return
261
+ }
262
+
263
+ try {
264
+ const body = await readRequestBody(
265
+ req,
266
+ INPUT_BODY_LIMIT,
267
+ 'Input request body exceeds 64KB.',
268
+ )
269
+ const operation = JSON.parse(body || '{}')
270
+ const { input, claim } = claimInputOperation(inputLease, operation)
271
+ sendInputJson(res, 200, await enqueueInput(input, claim))
272
+ } catch (error) {
273
+ const statusCode = error.statusCode || (error.code === 'BODY_TOO_LARGE'
274
+ ? 413
275
+ : /WebView|dimensions/.test(error.message)
276
+ ? 503
277
+ : 400)
278
+ sendInputJson(res, statusCode, {
279
+ ...(error.code ? { code: error.code } : {}),
280
+ error: error.message,
281
+ })
282
+ }
283
+ }
284
+
285
+ async function handleInputLeaseRequest(req, res, endpoint) {
286
+ if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
287
+ drainRequest(req)
288
+ sendInputJson(
289
+ res,
290
+ 401,
291
+ { error: 'A valid Bearer token is required.' },
292
+ { headers: { 'WWW-Authenticate': 'Bearer' } },
293
+ )
294
+ return
295
+ }
296
+ if (endpoint === '/input/lease') {
297
+ if (req.method !== 'GET') {
298
+ drainRequest(req)
299
+ sendInputJson(res, 405, { error: 'Input lease status only accepts GET requests.' })
300
+ return
301
+ }
302
+ const lease = inputLease.status()
303
+ sendInputJson(res, 200, { active: lease !== null, lease })
304
+ return
305
+ }
306
+ if (req.method !== 'POST') {
307
+ drainRequest(req)
308
+ sendInputJson(res, 405, { error: 'Input lease changes only accept POST requests.' })
309
+ return
310
+ }
311
+ if (!inputEnabled) {
312
+ drainRequest(req)
313
+ sendInputJson(res, 403, { error: 'WebView input is disabled.' })
314
+ return
315
+ }
316
+ if (requestContentLength(req) > INPUT_BODY_LIMIT) {
317
+ drainRequest(req)
318
+ sendInputJson(res, 413, { error: 'Input request body exceeds 64KB.' })
319
+ return
320
+ }
321
+
322
+ try {
323
+ const body = await readRequestBody(
324
+ req,
325
+ INPUT_BODY_LIMIT,
326
+ 'Input request body exceeds 64KB.',
327
+ )
328
+ const request = JSON.parse(body || '{}')
329
+ if (endpoint === '/input/lease/acquire') {
330
+ const lease = inputLease.acquire(request)
331
+ sendInputJson(res, 200, { active: true, lease })
332
+ return
333
+ }
334
+ if (endpoint === '/input/lease/renew') {
335
+ const lease = inputLease.renew(request)
336
+ sendInputJson(res, 200, { active: true, lease })
337
+ return
338
+ }
339
+ if (endpoint === '/input/lease/release') {
340
+ inputLease.release(request)
341
+ sendInputJson(res, 200, { active: false, released: true })
342
+ return
343
+ }
344
+ if (endpoint === '/input/lease/revoke') {
345
+ inputLease.revoke(request)
346
+ sendInputJson(res, 200, { active: false, revoked: true })
347
+ return
348
+ }
349
+ sendInputJson(res, 404, { error: `Unknown input lease endpoint: ${endpoint}` })
350
+ } catch (error) {
351
+ const statusCode = error.statusCode || (error.code === 'BODY_TOO_LARGE' ? 413 : 400)
352
+ sendInputJson(res, statusCode, {
353
+ ...(error.code ? { code: error.code } : {}),
354
+ error: error.message,
355
+ })
356
+ }
357
+ }
358
+
359
+ async function handleActionEventsWait(req, res, requestUrl) {
360
+ if (req.method !== 'GET') {
361
+ drainRequest(req)
362
+ sendJson(res, 405, {
363
+ error: 'Action events wait endpoint only accepts GET requests.',
364
+ })
365
+ return
366
+ }
367
+
368
+ const controller = new AbortController()
369
+ let responded = false
370
+ const abort = () => {
371
+ if (!responded) controller.abort()
372
+ }
373
+ const cleanup = () => {
374
+ req.removeListener('aborted', abort)
375
+ res.removeListener('close', abort)
376
+ pendingActionEventWaits.delete(controller)
377
+ }
378
+ req.once('aborted', abort)
379
+ res.once('close', abort)
380
+ pendingActionEventWaits.add(controller)
381
+
382
+ const options = {
383
+ after: clampedQueryInteger(
384
+ requestUrl.searchParams.get('after'),
385
+ 0,
386
+ 0,
387
+ Number.MAX_SAFE_INTEGER,
388
+ ),
389
+ limit: clampedQueryInteger(
390
+ requestUrl.searchParams.get('limit'),
391
+ DEFAULT_ACTION_EVENT_LIMIT,
392
+ 1,
393
+ MAX_ACTION_EVENT_LIMIT,
394
+ ),
395
+ timeoutMs: clampedQueryInteger(
396
+ requestUrl.searchParams.get('timeoutMs'),
397
+ DEFAULT_ACTION_EVENT_WAIT_TIMEOUT_MS,
398
+ 1,
399
+ MAX_ACTION_EVENT_WAIT_TIMEOUT_MS,
400
+ ),
401
+ signal: controller.signal,
402
+ }
403
+
404
+ try {
405
+ const result = await awaitActionEventsWait(options)
406
+ if (controller.signal.aborted || res.destroyed || res.writableEnded) return
407
+ responded = true
408
+ sendJson(res, 200, {
409
+ ...result,
410
+ timedOut: result && typeof result.timedOut === 'boolean'
411
+ ? result.timedOut
412
+ : false,
413
+ })
414
+ } catch (error) {
415
+ if (controller.signal.aborted && (res.destroyed || res.writableEnded)) return
416
+ if (res.destroyed || res.writableEnded) return
417
+ responded = true
418
+ sendJson(res, error.statusCode || 503, {
419
+ ...(error.code ? { code: error.code } : {}),
420
+ error: error.message,
421
+ }, controller.signal.aborted ? { headers: { Connection: 'close' } } : {})
422
+ } finally {
423
+ cleanup()
424
+ }
425
+ }
426
+
427
+ function awaitActionEventsWait(options) {
428
+ const operation = () => getActionEventsWait
429
+ ? getActionEventsWait(options)
430
+ : { ...getActionEvents(options), timedOut: true }
431
+ if (!options.signal) return Promise.resolve().then(operation)
432
+ if (options.signal.aborted) return Promise.reject(createActionEventAbortError())
433
+
434
+ return new Promise((resolve, reject) => {
435
+ let settled = false
436
+ const finish = (settlement, value) => {
437
+ if (settled) return
438
+ settled = true
439
+ options.signal.removeEventListener('abort', onAbort)
440
+ settlement(value)
441
+ }
442
+ const onAbort = () => finish(reject, createActionEventAbortError())
443
+ options.signal.addEventListener('abort', onAbort, { once: true })
444
+ let pending
445
+ try {
446
+ pending = operation()
447
+ } catch (error) {
448
+ finish(reject, error)
449
+ return
450
+ }
451
+ Promise.resolve(pending).then(
452
+ (value) => finish(resolve, value),
453
+ (error) => finish(reject, error),
454
+ )
455
+ })
456
+ }
457
+
458
+ function handleMcpRequest(req, res) {
459
+ if (req.method !== 'POST') {
460
+ sendJson(res, 405, { error: 'MCP endpoint only accepts POST requests.' })
461
+ return
462
+ }
463
+
464
+ readRequestBody(req)
465
+ .then((body) => {
466
+ const message = JSON.parse(body || '{}')
467
+ const response = handleMcpMessage(message, readStore, plannerFile)
468
+
469
+ if (response == null) {
470
+ res.writeHead(202, {
471
+ 'Access-Control-Allow-Origin': '*',
472
+ 'Content-Type': 'application/json',
473
+ })
474
+ res.end('')
475
+ return
476
+ }
477
+
478
+ sendJson(res, 200, response)
479
+ })
480
+ .catch((error) => {
481
+ sendJson(res, 200, jsonRpcError(null, -32700, error.message))
482
+ })
483
+ }
484
+
485
+ async function handleRequest(req, res) {
486
+ try {
487
+ if (req.url === '/shutdown') {
488
+ sendJson(res, 200, { status: 'shutting down' })
489
+ stop()
490
+ return
491
+ }
492
+
493
+ const requestUrl = new URL(
494
+ req.url,
495
+ `http://127.0.0.1:${actualPort || configuredPort}`,
496
+ )
497
+ const endpoint = requestUrl.pathname
498
+
499
+ if (endpoint === '/health') {
500
+ sendJson(res, 200, { status: 'ok' })
501
+ return
502
+ }
503
+
504
+ if (endpoint === '/mcp') {
505
+ handleMcpRequest(req, res)
506
+ return
507
+ }
508
+
509
+ if (endpoint === '/query' || endpoint === '/debug/evaluate') {
510
+ await handleDataRequest(req, res, endpoint)
511
+ return
512
+ }
513
+
514
+ if (endpoint === '/debug/status') {
515
+ if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
516
+ drainRequest(req)
517
+ sendInputJson(res, 401, { error: 'A valid Bearer token is required.' })
518
+ return
519
+ }
520
+ sendInputJson(res, 200, {
521
+ enabled: debugEvalEnabled,
522
+ endpoint: '/debug/evaluate',
523
+ warning: 'Arbitrary JavaScript can read or modify the signed-in game WebView.',
524
+ })
525
+ return
526
+ }
527
+
528
+ if (endpoint === '/screenshot') {
529
+ if (req.method !== 'GET') {
530
+ sendJson(
531
+ res,
532
+ 405,
533
+ { error: 'Screenshot endpoint only accepts GET requests.' },
534
+ { allowCors: false, noStore: true },
535
+ )
536
+ return
537
+ }
538
+ try {
539
+ if (!captureScreenshot) {
540
+ captureScreenshot = createPoiScreenshotProvider()
541
+ }
542
+ sendJson(
543
+ res,
544
+ 200,
545
+ await captureScreenshot(),
546
+ { allowCors: false, noStore: true },
547
+ )
548
+ } catch (error) {
549
+ sendJson(
550
+ res,
551
+ 503,
552
+ { error: error.message },
553
+ { allowCors: false, noStore: true },
554
+ )
555
+ }
556
+ return
557
+ }
558
+
559
+ if (endpoint === '/input/status') {
560
+ if (req.method !== 'GET') {
561
+ drainRequest(req)
562
+ sendInputJson(
563
+ res,
564
+ 405,
565
+ { error: 'Input status endpoint only accepts GET requests.' },
566
+ )
567
+ return
568
+ }
569
+ sendInputJson(res, 200, { enabled: inputEnabled })
570
+ return
571
+ }
572
+
573
+ if (
574
+ endpoint === '/input/lease' ||
575
+ endpoint === '/input/lease/acquire' ||
576
+ endpoint === '/input/lease/renew' ||
577
+ endpoint === '/input/lease/release' ||
578
+ endpoint === '/input/lease/revoke'
579
+ ) {
580
+ await handleInputLeaseRequest(req, res, endpoint)
581
+ return
582
+ }
583
+
584
+ if (endpoint === '/input') {
585
+ await handleInputRequest(req, res)
586
+ return
587
+ }
588
+
589
+ if (endpoint === '/quest-list') {
590
+ sendJson(res, 200, getQuestList())
591
+ return
592
+ }
593
+
594
+ if (endpoint === '/quest-action') {
595
+ sendJson(res, 200, getQuestAction())
596
+ return
597
+ }
598
+
599
+ if (endpoint === '/equipment-action') {
600
+ sendJson(res, 200, getEquipmentAction())
601
+ return
602
+ }
603
+
604
+ if (endpoint === '/equipment-selection') {
605
+ sendJson(res, 200, getEquipmentSelection())
606
+ return
607
+ }
608
+
609
+ if (endpoint === '/unsetslot') {
610
+ sendJson(res, 200, getUnsetSlot())
611
+ return
612
+ }
613
+
614
+ if (endpoint === '/fleet-action') {
615
+ sendJson(res, 200, getFleetAction())
616
+ return
617
+ }
618
+
619
+ if (endpoint === '/action-events/wait') {
620
+ await handleActionEventsWait(req, res, requestUrl)
621
+ return
622
+ }
623
+
624
+ if (endpoint === '/action-events') {
625
+ if (req.method !== 'GET') {
626
+ drainRequest(req)
627
+ sendJson(res, 405, {
628
+ error: 'Action events endpoint only accepts GET requests.',
629
+ })
630
+ return
631
+ }
632
+ sendJson(res, 200, getActionEvents({
633
+ after: clampedQueryInteger(
634
+ requestUrl.searchParams.get('after'),
635
+ 0,
636
+ 0,
637
+ Number.MAX_SAFE_INTEGER,
638
+ ),
639
+ limit: clampedQueryInteger(
640
+ requestUrl.searchParams.get('limit'),
641
+ DEFAULT_ACTION_EVENT_LIMIT,
642
+ 1,
643
+ MAX_ACTION_EVENT_LIMIT,
644
+ ),
645
+ }))
646
+ return
647
+ }
648
+
649
+ const store = readStore()
650
+ const info = store.info
651
+
652
+ switch (endpoint) {
653
+ case '/basic':
654
+ sendJson(res, 200, info.basic || {})
655
+ break
656
+ case '/fleets':
657
+ sendJson(res, 200, info.fleets || [])
658
+ break
659
+ case '/ships':
660
+ sendJson(res, 200, info.ships || {})
661
+ break
662
+ case '/equipment':
663
+ sendJson(res, 200, info.equips || {})
664
+ break
665
+ case '/resources':
666
+ sendJson(res, 200, info.resources || [])
667
+ break
668
+ case '/quests':
669
+ sendJson(res, 200, {
670
+ activeQuests: (info.quests && info.quests.activeQuests) || {},
671
+ records: (info.quests && info.quests.records) || {},
672
+ })
673
+ break
674
+ case '/airbase':
675
+ sendJson(res, 200, info.airbase || [])
676
+ break
677
+ case '/names':
678
+ sendJson(res, 200, extractNames(store))
679
+ break
680
+ case '/master':
681
+ sendJson(res, 200, extractMasterData(store, masterFile))
682
+ break
683
+ case '/event':
684
+ sendJson(res, 200, extractEventData(store))
685
+ break
686
+ case '/planner':
687
+ sendJson(res, 200, extractPlannerData(store, plannerFile))
688
+ break
689
+ case '/battle':
690
+ sendJson(res, 200, combineBattleTelemetry(
691
+ getBattleTelemetry(),
692
+ extractProphetBattle(store),
693
+ ))
694
+ break
695
+ case '/all':
696
+ sendJson(res, 200, {
697
+ basic: info.basic || {},
698
+ fleets: info.fleets || [],
699
+ ships: info.ships || {},
700
+ equipment: info.equips || {},
701
+ resources: info.resources || [],
702
+ quests: {
703
+ activeQuests: (info.quests && info.quests.activeQuests) || {},
704
+ records: (info.quests && info.quests.records) || {},
705
+ },
706
+ airbase: info.airbase || [],
707
+ repairs: info.repairs || [],
708
+ constructions: info.constructions || [],
709
+ maps: info.maps || {},
710
+ useitems: info.useitems || {},
711
+ sortie: store.sortie || {},
712
+ names: extractNames(store),
713
+ })
714
+ break
715
+ default:
716
+ sendJson(res, 404, { error: `Unknown endpoint: ${endpoint}` })
717
+ }
718
+ } catch (error) {
719
+ sendJson(res, 503, { error: error.message })
720
+ }
721
+ }
722
+
723
+ function start() {
724
+ if (server) return Promise.resolve()
725
+
726
+ fs.mkdirSync(path.dirname(portFile), { recursive: true })
727
+ server = http.createServer(handleRequest)
728
+
729
+ return new Promise((resolve, reject) => {
730
+ const onError = (error) => {
731
+ cleanupPortFile(portFile)
732
+ server = null
733
+ reject(error)
734
+ }
735
+
736
+ server.once('error', onError)
737
+ server.listen(configuredPort, '127.0.0.1', () => {
738
+ server.removeListener('error', onError)
739
+ actualPort = server.address().port
740
+ fs.writeFileSync(portFile, String(actualPort), 'utf8')
741
+ logger.log(`[poi-plugin-mcp] HTTP API started on http://127.0.0.1:${actualPort}`)
742
+ resolve()
743
+ })
744
+ })
745
+ }
746
+
747
+ function stop() {
748
+ for (const controller of Array.from(pendingActionEventWaits)) controller.abort()
749
+
750
+ if (!server) {
751
+ cleanupPortFile(portFile)
752
+ actualPort = 0
753
+ return Promise.resolve()
754
+ }
755
+
756
+ const closingServer = server
757
+ server = null
758
+
759
+ return new Promise((resolve) => {
760
+ closingServer.close(() => {
761
+ cleanupPortFile(portFile)
762
+ actualPort = 0
763
+ resolve()
764
+ })
765
+ })
766
+ }
767
+
768
+ return {
769
+ start,
770
+ stop,
771
+ getPort() {
772
+ return actualPort
773
+ },
774
+ }
775
+ }
776
+
777
+ function clampedQueryInteger(value, fallback, minimum, maximum) {
778
+ if (value == null || value === '') return fallback
779
+ const parsed = Number(value)
780
+ if (!Number.isFinite(parsed)) return fallback
781
+ return Math.min(maximum, Math.max(minimum, Math.trunc(parsed)))
782
+ }
783
+
784
+ function claimInputOperation(inputLease, request) {
785
+ if (!request || typeof request !== 'object' || Array.isArray(request)) {
786
+ throw inputBridgeError('INPUT_LEASE_REQUIRED', 'Input lease fields are required.', 409)
787
+ }
788
+ const {
789
+ leaseId,
790
+ ownerSessionId,
791
+ runId,
792
+ action,
793
+ sequence,
794
+ ...input
795
+ } = request
796
+ if (
797
+ typeof leaseId !== 'string' ||
798
+ typeof ownerSessionId !== 'string' ||
799
+ typeof runId !== 'string' ||
800
+ typeof action !== 'string' ||
801
+ !Number.isSafeInteger(sequence)
802
+ ) {
803
+ throw inputBridgeError('INPUT_LEASE_REQUIRED', 'Input lease fields are required.', 409)
804
+ }
805
+ return {
806
+ input,
807
+ claim: inputLease.consumeInput({
808
+ leaseId,
809
+ ownerSessionId,
810
+ runId,
811
+ action,
812
+ sequence,
813
+ }),
814
+ }
815
+ }
816
+
817
+ function inputBridgeError(code, message, statusCode) {
818
+ const error = new Error(message)
819
+ error.code = code
820
+ error.statusCode = statusCode
821
+ return error
822
+ }
823
+
824
+ function createActionEventAbortError() {
825
+ const error = new Error('Action event wait aborted.')
826
+ error.name = 'AbortError'
827
+ error.code = 'ABORT_ERR'
828
+ return error
829
+ }
830
+
831
+ function readRequestBody(
832
+ req,
833
+ maxBytes = 1024 * 1024,
834
+ tooLargeMessage = 'MCP request body is too large.',
835
+ ) {
836
+ return new Promise((resolve, reject) => {
837
+ let body = ''
838
+ let bodyBytes = 0
839
+ let tooLarge = false
840
+ req.setEncoding('utf8')
841
+ req.on('data', (chunk) => {
842
+ if (tooLarge) return
843
+ bodyBytes += Buffer.byteLength(chunk)
844
+ if (bodyBytes > maxBytes) {
845
+ tooLarge = true
846
+ body = ''
847
+ return
848
+ }
849
+ body += chunk
850
+ })
851
+ req.on('end', () => {
852
+ if (tooLarge) {
853
+ const error = new Error(tooLargeMessage)
854
+ error.code = 'BODY_TOO_LARGE'
855
+ reject(error)
856
+ } else {
857
+ resolve(body)
858
+ }
859
+ })
860
+ req.on('error', reject)
861
+ })
862
+ }
863
+
864
+ function requestContentLength(req) {
865
+ const value = req.headers['content-length']
866
+ if (value == null) return 0
867
+ const length = Number(value)
868
+ return Number.isSafeInteger(length) && length >= 0 ? length : Infinity
869
+ }
870
+
871
+ function drainRequest(req) {
872
+ req.resume()
873
+ }
874
+
875
+ function hasValidBearerToken(authorization, expectedToken) {
876
+ if (
877
+ typeof authorization !== 'string' ||
878
+ typeof expectedToken !== 'string' ||
879
+ !authorization.startsWith('Bearer ')
880
+ ) {
881
+ return false
882
+ }
883
+
884
+ const supplied = Buffer.from(authorization.slice('Bearer '.length), 'utf8')
885
+ const expected = Buffer.from(expectedToken, 'utf8')
886
+ return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)
887
+ }
888
+
889
+ function handleMcpMessage(message, readStore, plannerFile) {
890
+ const { id, method, params } = message || {}
891
+
892
+ switch (method) {
893
+ case 'initialize':
894
+ return jsonRpcResult(id, {
895
+ protocolVersion: MCP_PROTOCOL_VERSION,
896
+ capabilities: {
897
+ resources: { subscribe: false },
898
+ tools: {},
899
+ },
900
+ serverInfo: { name: 'poi-plugin-mcp', version: packageJson.version },
901
+ })
902
+
903
+ case 'notifications/initialized':
904
+ case 'notifications/cancelled':
905
+ return null
906
+
907
+ case 'ping':
908
+ return jsonRpcResult(id, {})
909
+
910
+ case 'resources/list':
911
+ return jsonRpcResult(id, {
912
+ resources: MCP_RESOURCE_ENDPOINTS.map(({ uri }) => ({
913
+ uri,
914
+ name: uri.replace('poi://', ''),
915
+ mimeType: 'application/json',
916
+ })),
917
+ })
918
+
919
+ case 'resources/read': {
920
+ const uri = params && params.uri
921
+ const endpoint = MCP_RESOURCE_ENDPOINTS.find((item) => item.uri === uri)
922
+ if (!endpoint) return jsonRpcError(id, -32602, `Unknown resource: ${uri}`)
923
+ const data = readBridgeData(endpoint.path, readStore, plannerFile)
924
+ return jsonRpcResult(id, {
925
+ contents: [{
926
+ uri,
927
+ mimeType: 'application/json',
928
+ text: JSON.stringify(data, null, 2),
929
+ }],
930
+ })
931
+ }
932
+
933
+ case 'tools/list':
934
+ return jsonRpcResult(id, { tools: MCP_TOOLS })
935
+
936
+ case 'tools/call': {
937
+ const toolName = params && params.name
938
+ const toolArgs = (params && params.arguments) || {}
939
+ const result = callMcpTool(toolName, toolArgs, readStore, plannerFile)
940
+ if (result.error) return jsonRpcError(id, -32602, result.error)
941
+ return jsonRpcResult(id, {
942
+ content: [{ type: 'text', text: JSON.stringify(result.value, null, 2) }],
943
+ })
944
+ }
945
+
946
+ default:
947
+ return jsonRpcError(id, -32601, `Unknown method: ${method}`)
948
+ }
949
+ }
950
+
951
+ const MCP_RESOURCE_ENDPOINTS = [
952
+ { uri: 'poi://basic', path: '/basic' },
953
+ { uri: 'poi://fleets', path: '/fleets' },
954
+ { uri: 'poi://ships', path: '/ships' },
955
+ { uri: 'poi://equipment', path: '/equipment' },
956
+ { uri: 'poi://resources', path: '/resources' },
957
+ { uri: 'poi://quests', path: '/quests' },
958
+ { uri: 'poi://airbase', path: '/airbase' },
959
+ { uri: 'poi://names', path: '/names' },
960
+ { uri: 'poi://master', path: '/master' },
961
+ { uri: 'poi://event', path: '/event' },
962
+ { uri: 'poi://planner', path: '/planner' },
963
+ { uri: 'poi://all', path: '/all' },
964
+ ]
965
+
966
+ const MCP_TOOLS = [
967
+ {
968
+ name: 'get_fleet_status',
969
+ description:
970
+ 'Inspect one owned fleet (1-4): names, slots, expansion, speed, morale, fuel/ammo, Formula 33 LOS (Cn 1-4), and fighter power. Use this instead of get_all when freezing or reading a sortie fleet.',
971
+ inputSchema: {
972
+ type: 'object',
973
+ properties: { fleetId: { type: 'number', description: 'Fleet number, 1-4.' } },
974
+ required: ['fleetId'],
975
+ },
976
+ },
977
+ {
978
+ name: 'search_ships',
979
+ description: 'Search owned ship instances by level and morale.',
980
+ inputSchema: {
981
+ type: 'object',
982
+ properties: {
983
+ minLevel: { type: 'number' },
984
+ maxLevel: { type: 'number' },
985
+ minMorale: { type: 'number' },
986
+ },
987
+ },
988
+ },
989
+ {
990
+ name: 'search_equipment',
991
+ description: 'Search owned equipment instances by improvement level.',
992
+ inputSchema: {
993
+ type: 'object',
994
+ properties: {
995
+ minLevel: { type: 'number', description: 'Minimum improvement level.' },
996
+ },
997
+ },
998
+ },
999
+ {
1000
+ name: 'get_resources',
1001
+ description:
1002
+ 'Get current account resources as named fields. Poi array order is fuel, ammo, steel, bauxite, instantConstruction (喷火), repairBuckets (桶), developmentMaterials, improvementMaterials (螺丝). HTTP /resources remains the raw 8-number array.',
1003
+ inputSchema: { type: 'object', properties: {} },
1004
+ },
1005
+ {
1006
+ name: 'get_all',
1007
+ description:
1008
+ 'Last-resort full account dump (all ships, equipment, quests, maps). Prefer get_fleet_status, search_*, or get_resources. Do not use this to name one fleet.',
1009
+ inputSchema: {
1010
+ type: 'object',
1011
+ properties: {
1012
+ include: {
1013
+ type: 'array',
1014
+ items: { type: 'string', enum: ['master', 'event', 'planner'] },
1015
+ },
1016
+ },
1017
+ },
1018
+ },
1019
+ ]
1020
+
1021
+ function callMcpTool(toolName, args, readStore, plannerFile) {
1022
+ switch (toolName) {
1023
+ case 'get_fleet_status':
1024
+ return { value: buildFleetStatus(args || {}, readStore) }
1025
+ case 'search_ships':
1026
+ return { value: searchShips(args || {}, readStore) }
1027
+ case 'search_equipment':
1028
+ return { value: searchEquipment(args || {}, readStore) }
1029
+ case 'get_resources':
1030
+ return { value: decodePoiResources(readBridgeData('/resources', readStore, plannerFile)) }
1031
+ case 'get_all':
1032
+ return { value: buildAllPayload(args || {}, readStore, plannerFile) }
1033
+ default:
1034
+ return { error: `Unknown tool: ${toolName}` }
1035
+ }
1036
+ }
1037
+
1038
+ function readBridgeData(endpoint, readStore, plannerFile) {
1039
+ const store = readStore()
1040
+ const info = store.info
1041
+
1042
+ switch (endpoint) {
1043
+ case '/basic':
1044
+ return info.basic || {}
1045
+ case '/fleets':
1046
+ return info.fleets || []
1047
+ case '/ships':
1048
+ return info.ships || {}
1049
+ case '/equipment':
1050
+ return info.equips || {}
1051
+ case '/resources':
1052
+ return info.resources || []
1053
+ case '/quests':
1054
+ return {
1055
+ activeQuests: (info.quests && info.quests.activeQuests) || {},
1056
+ records: (info.quests && info.quests.records) || {},
1057
+ }
1058
+ case '/airbase':
1059
+ return info.airbase || []
1060
+ case '/names':
1061
+ return extractNames(store)
1062
+ case '/master':
1063
+ return extractMasterData(store)
1064
+ case '/event':
1065
+ return extractEventData(store)
1066
+ case '/planner':
1067
+ return extractPlannerData(store, plannerFile)
1068
+ case '/all':
1069
+ return {
1070
+ basic: info.basic || {},
1071
+ fleets: info.fleets || [],
1072
+ ships: info.ships || {},
1073
+ equipment: info.equips || {},
1074
+ resources: info.resources || [],
1075
+ quests: {
1076
+ activeQuests: (info.quests && info.quests.activeQuests) || {},
1077
+ records: (info.quests && info.quests.records) || {},
1078
+ },
1079
+ airbase: info.airbase || [],
1080
+ repairs: info.repairs || [],
1081
+ constructions: info.constructions || [],
1082
+ maps: info.maps || {},
1083
+ useitems: info.useitems || {},
1084
+ sortie: store.sortie || {},
1085
+ names: extractNames(store),
1086
+ }
1087
+ default:
1088
+ throw new Error(`Unknown endpoint: ${endpoint}`)
1089
+ }
1090
+ }
1091
+
1092
+ function buildFleetStatus(args, readStore) {
1093
+ const fleetId = Number(args.fleetId)
1094
+ const store = readStore()
1095
+ const info = store.info || {}
1096
+ const fleets = Array.isArray(info.fleets) ? info.fleets : []
1097
+ const fleet = fleets[fleetId - 1]
1098
+ if (!fleet) return { error: `Fleet #${args.fleetId} not found` }
1099
+
1100
+ const ships = info.ships || {}
1101
+ const equips = info.equips || {}
1102
+ const names = extractNames(store)
1103
+ const master = extractMasterData(store)
1104
+ const hqLevel = Number(info.basic && info.basic.api_level)
1105
+ const metricShips = collectFleetMetricShips(fleet, ships, equips, master)
1106
+ const metrics = Number.isInteger(hqLevel) && hqLevel >= 1
1107
+ ? inspectFleetMetrics(metricShips, hqLevel)
1108
+ : null
1109
+
1110
+ return {
1111
+ id: fleet.api_id,
1112
+ name: fleet.api_name,
1113
+ mission: fleet.api_mission,
1114
+ metrics,
1115
+ ships: (fleet.api_ship || []).filter((id) => id > 0).map((shipId, index) =>
1116
+ projectFleetShip(ships[shipId], shipId, index + 1, equips, names, master),
1117
+ ),
1118
+ }
1119
+ }
1120
+
1121
+ function projectFleetShip(ship, shipId, position, equips, names, master) {
1122
+ if (!ship) return { id: shipId, position }
1123
+
1124
+ const masterShip = master.ships && master.ships[ship.api_ship_id]
1125
+ const shipType = masterShip && master.shipTypes && master.shipTypes[masterShip.api_stype]
1126
+ const maxHp = Number(ship.api_maxhp) || 0
1127
+ const speedRaw = Number(ship.api_soku ?? (masterShip && masterShip.api_soku) ?? 0)
1128
+ const speedKind = speedFromRaw(speedRaw)
1129
+
1130
+ return {
1131
+ position,
1132
+ id: ship.api_id,
1133
+ shipId: ship.api_ship_id,
1134
+ masterId: ship.api_ship_id,
1135
+ name:
1136
+ (names.ships && names.ships[ship.api_ship_id]) ||
1137
+ (masterShip && masterShip.api_name) ||
1138
+ '',
1139
+ typeName: (shipType && shipType.api_name) || '',
1140
+ stype: (masterShip && masterShip.api_stype) || null,
1141
+ level: ship.api_lv,
1142
+ hp: `${ship.api_nowhp}/${ship.api_maxhp}`,
1143
+ hpMod4: maxHp % 4,
1144
+ morale: ship.api_cond,
1145
+ moraleMeaning: moraleMeaning(ship.api_cond || 0),
1146
+ speed: speedRaw,
1147
+ speedMeaning: speedMeaning(speedKind),
1148
+ fuel: ship.api_fuel,
1149
+ ammo: ship.api_bull,
1150
+ locked: ship.api_locked,
1151
+ slotnum: ship.api_slotnum || (ship.api_slot || []).filter((id) => id !== -1).length,
1152
+ onslot: Array.isArray(ship.api_onslot) ? ship.api_onslot : [],
1153
+ sallyArea: ship.api_sally_area || 0,
1154
+ fire: ship.api_karyoku || null,
1155
+ torp: ship.api_raisou || null,
1156
+ aa: ship.api_taiku || null,
1157
+ armor: ship.api_soukou || null,
1158
+ luck: ship.api_lucky || null,
1159
+ los: ship.api_sakuteki || null,
1160
+ asw: ship.api_taisen || null,
1161
+ slotItems: (ship.api_slot || [])
1162
+ .filter((equipId) => equipId > 0)
1163
+ .map((equipId) => describeEquip(equipId, equips, names, master))
1164
+ .filter(Boolean),
1165
+ expansion: describeExpansion(ship.api_slot_ex, equips, names, master),
1166
+ }
1167
+ }
1168
+
1169
+ function describeEquip(equipId, equips, names, master) {
1170
+ if (!equipId || equipId <= 0) return null
1171
+ const equip = equips[equipId]
1172
+ if (!equip) return { id: equipId, missing: true }
1173
+ const masterId = equip.api_slotitem_id
1174
+ const masterEquip = master.equipment && master.equipment[masterId]
1175
+ const typeIds = masterEquip && Array.isArray(masterEquip.api_type) ? masterEquip.api_type : []
1176
+ const typeId = typeIds[2] || typeIds[1] || typeIds[0]
1177
+ const equipType = typeId && master.equipmentTypes && master.equipmentTypes[typeId]
1178
+ return {
1179
+ id: equip.api_id,
1180
+ equipId: masterId,
1181
+ name:
1182
+ (names.equipment && names.equipment[masterId]) ||
1183
+ (masterEquip && masterEquip.api_name) ||
1184
+ '',
1185
+ typeName: (equipType && equipType.api_name) || '',
1186
+ level: equip.api_level || 0,
1187
+ prof: equip.api_alv || 0,
1188
+ }
1189
+ }
1190
+
1191
+ function describeExpansion(rawEx, equips, names, master) {
1192
+ const raw = Number(rawEx)
1193
+ if (!Number.isFinite(raw) || raw === 0) {
1194
+ return { raw: Number.isFinite(raw) ? raw : 0, state: 'closed', meaning: '未开孔', item: null }
1195
+ }
1196
+ if (raw < 0) {
1197
+ return { raw, state: 'open_empty', meaning: '已开孔但为空', item: null }
1198
+ }
1199
+ return {
1200
+ raw,
1201
+ state: 'equipped',
1202
+ meaning: '已装备',
1203
+ item: describeEquip(raw, equips, names, master),
1204
+ }
1205
+ }
1206
+
1207
+ function searchShips(args, readStore) {
1208
+ const store = readStore()
1209
+ const master = extractMasterData(store)
1210
+ const ships = Object.values((store.info && store.info.ships) || {}).filter((ship) => {
1211
+ if (!ship) return false
1212
+ if (args.minLevel != null && ship.api_lv < Number(args.minLevel)) return false
1213
+ if (args.maxLevel != null && ship.api_lv > Number(args.maxLevel)) return false
1214
+ if (args.minMorale != null && ship.api_cond < Number(args.minMorale)) return false
1215
+ return true
1216
+ }).map((ship) => enrichShip(ship, master))
1217
+
1218
+ return { total: ships.length, ships }
1219
+ }
1220
+
1221
+ function searchEquipment(args, readStore) {
1222
+ const store = readStore()
1223
+ const master = extractMasterData(store)
1224
+ const equipment = Object.values((store.info && store.info.equips) || {}).filter((equip) => {
1225
+ if (!equip) return false
1226
+ if (args.minLevel != null && (equip.api_level || 0) < Number(args.minLevel)) return false
1227
+ return true
1228
+ }).map((equip) => enrichEquipment(equip, master))
1229
+
1230
+ return { total: equipment.length, equipment }
1231
+ }
1232
+
1233
+ function buildAllPayload(args, readStore, plannerFile) {
1234
+ const payload = readBridgeData('/all', readStore, plannerFile)
1235
+ const include = Array.isArray(args.include) ? new Set(args.include) : new Set()
1236
+
1237
+ if (include.has('master')) payload.master = readBridgeData('/master', readStore, plannerFile)
1238
+ if (include.has('event')) payload.event = readBridgeData('/event', readStore, plannerFile)
1239
+ if (include.has('planner')) payload.planner = readBridgeData('/planner', readStore, plannerFile)
1240
+
1241
+ return payload
1242
+ }
1243
+
1244
+ function enrichShip(ship, master) {
1245
+ const masterShip = master.ships && master.ships[ship.api_ship_id]
1246
+ const shipType = masterShip && master.shipTypes && master.shipTypes[masterShip.api_stype]
1247
+
1248
+ return {
1249
+ ...ship,
1250
+ instanceId: ship.api_id,
1251
+ masterId: ship.api_ship_id,
1252
+ name: (masterShip && masterShip.api_name) || '',
1253
+ typeName: (shipType && shipType.api_name) || '',
1254
+ }
1255
+ }
1256
+
1257
+ function enrichEquipment(equip, master) {
1258
+ const masterEquip = master.equipment && master.equipment[equip.api_slotitem_id]
1259
+ const typeIds = masterEquip && Array.isArray(masterEquip.api_type) ? masterEquip.api_type : []
1260
+ const typeId = typeIds[2] || typeIds[1] || typeIds[0]
1261
+ const equipType = typeId && master.equipmentTypes && master.equipmentTypes[typeId]
1262
+
1263
+ return {
1264
+ ...equip,
1265
+ instanceId: equip.api_id,
1266
+ masterId: equip.api_slotitem_id,
1267
+ name: (masterEquip && masterEquip.api_name) || '',
1268
+ typeName: (equipType && equipType.api_name) || '',
1269
+ }
1270
+ }
1271
+
1272
+ function jsonRpcResult(id, result) {
1273
+ return { jsonrpc: JSONRPC_VERSION, id, result }
1274
+ }
1275
+
1276
+ function jsonRpcError(id, code, message) {
1277
+ return { jsonrpc: JSONRPC_VERSION, id, error: { code, message } }
1278
+ }
1279
+
1280
+ function defaultGetStore(storePath) {
1281
+ if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
1282
+ return window.getStore(storePath)
1283
+ }
1284
+ return null
1285
+ }
1286
+
1287
+ function extractProphetBattle(store) {
1288
+ const battle = store &&
1289
+ store.ext &&
1290
+ store.ext['poi-plugin-prophet'] &&
1291
+ store.ext['poi-plugin-prophet']._ &&
1292
+ store.ext['poi-plugin-prophet']._.battle
1293
+ if (!battle || typeof battle !== 'object') {
1294
+ return {
1295
+ available: false,
1296
+ source: 'poi-plugin-prophet',
1297
+ engine: 'poi-lib-battle',
1298
+ engineVersion: '3.0.5',
1299
+ }
1300
+ }
1301
+
1302
+ const fleets = {
1303
+ main: compactBattleFleet(battle.mainFleet),
1304
+ escort: compactBattleFleet(battle.escortFleet),
1305
+ enemy: compactBattleFleet(battle.enemyFleet),
1306
+ enemyEscort: compactBattleFleet(battle.enemyEscort),
1307
+ }
1308
+ const result = compactBattleResult(battle.result)
1309
+ return {
1310
+ available: true,
1311
+ source: 'poi-plugin-prophet',
1312
+ engine: 'poi-lib-battle',
1313
+ engineVersion: '3.0.5',
1314
+ sortieState: finiteNumber(battle.sortieState, 0),
1315
+ sortieStateName: sortieStateName(battle.sortieState),
1316
+ mapAreaId: finiteNumber(battle.mapAreaId, 0),
1317
+ eventId: finiteNumber(battle.eventId, 0),
1318
+ eventKind: finiteNumber(battle.eventKind, 0),
1319
+ isBaseDefense: battle.isBaseDefense === true,
1320
+ isHeavyBomberDefense: battle.isHeavyBomberDefense === true,
1321
+ smokeType: finiteNumber(battle.smokeType, 0),
1322
+ airControl: stringValue(battle.airControl),
1323
+ battleForm: stringValue(battle.battleForm),
1324
+ enemyFormation: stringValue(battle.eFormation),
1325
+ rank: typeof result.rank === 'string' ? result.rank : null,
1326
+ mvpIndex0Based: compactMvp(result.mvp),
1327
+ heavilyDamaged: findHeavilyDamaged(fleets),
1328
+ fleets,
1329
+ }
1330
+ }
1331
+
1332
+ function combineBattleTelemetry(telemetry, predicted) {
1333
+ const current = telemetry && typeof telemetry === 'object'
1334
+ ? telemetry
1335
+ : { available: false, generation: 0 }
1336
+ return {
1337
+ available: current.available === true || predicted.available === true,
1338
+ generation: finiteNumber(current.generation, 0),
1339
+ status: typeof current.status === 'string' ? current.status : 'unavailable',
1340
+ observed: current.observed || null,
1341
+ predicted,
1342
+ official: current.official || null,
1343
+ }
1344
+ }
1345
+
1346
+ function compactBattleResult(result) {
1347
+ if (!result || typeof result !== 'object') return {}
1348
+ return Object.fromEntries(
1349
+ ['rank', 'mvp', 'getShip', 'getItem']
1350
+ .filter((key) => result[key] !== undefined)
1351
+ .map((key) => [key, result[key]]),
1352
+ )
1353
+ }
1354
+
1355
+ function compactMvp(value) {
1356
+ const values = Array.isArray(value) ? value : [value, null]
1357
+ return {
1358
+ main: Number.isInteger(values[0]) && values[0] >= 0 ? values[0] : null,
1359
+ escort: Number.isInteger(values[1]) && values[1] >= 0 ? values[1] : null,
1360
+ }
1361
+ }
1362
+
1363
+ function findHeavilyDamaged(fleets) {
1364
+ return ['main', 'escort'].flatMap((fleetName) =>
1365
+ fleets[fleetName].flatMap((ship) => {
1366
+ if (
1367
+ !Number.isFinite(ship.currentHp) ||
1368
+ !Number.isFinite(ship.maxHp) ||
1369
+ ship.maxHp <= 0 ||
1370
+ ship.currentHp > ship.maxHp * 0.25
1371
+ ) {
1372
+ return []
1373
+ }
1374
+ return [{
1375
+ fleet: fleetName,
1376
+ position: ship.position,
1377
+ instanceId: ship.instanceId,
1378
+ currentHp: ship.currentHp,
1379
+ maxHp: ship.maxHp,
1380
+ }]
1381
+ }),
1382
+ )
1383
+ }
1384
+
1385
+ function compactBattleFleet(fleet) {
1386
+ if (!Array.isArray(fleet)) return []
1387
+ return fleet.flatMap((ship) => {
1388
+ if (!ship || typeof ship !== 'object') return []
1389
+ const raw = ship.raw && typeof ship.raw === 'object' ? ship.raw : {}
1390
+ return [{
1391
+ id: nullableNumber(ship.id),
1392
+ owner: nullableNumber(ship.owner),
1393
+ position: nullableNumber(ship.pos),
1394
+ maxHp: nullableNumber(ship.maxHP),
1395
+ initialHp: nullableNumber(ship.initHP),
1396
+ currentHp: nullableNumber(ship.nowHP),
1397
+ lostHp: nullableNumber(ship.lostHP),
1398
+ damage: nullableNumber(ship.damage),
1399
+ items: Array.isArray(ship.items) ? [...ship.items] : [],
1400
+ useItem: ship.useItem == null ? null : ship.useItem,
1401
+ instanceId: Number.isInteger(raw.api_id) ? raw.api_id : null,
1402
+ masterId: Number.isInteger(raw.api_ship_id) ? raw.api_ship_id : null,
1403
+ }]
1404
+ })
1405
+ }
1406
+
1407
+ function sortieStateName(value) {
1408
+ return ['in_port', 'navigation', 'battle', 'practice'][value] || 'unknown'
1409
+ }
1410
+
1411
+ function finiteNumber(value, fallback) {
1412
+ return Number.isFinite(value) ? value : fallback
1413
+ }
1414
+
1415
+ function nullableNumber(value) {
1416
+ return Number.isFinite(value) ? value : null
1417
+ }
1418
+
1419
+ function stringValue(value) {
1420
+ return typeof value === 'string' ? value : ''
1421
+ }
1422
+
1423
+ function cleanupPortFile(portFile) {
1424
+ try {
1425
+ fs.unlinkSync(portFile)
1426
+ } catch (_) {}
1427
+ }
1428
+
1429
+ function extractNames(store) {
1430
+ const result = { ships: {}, equipment: {}, missions: {} }
1431
+ const constants = store.const || {}
1432
+
1433
+ collectApiNames(constants.$ships, result.ships)
1434
+ collectApiNames(constants.$equips, result.equipment)
1435
+ collectApiNames(constants.$missions, result.missions)
1436
+
1437
+ const wctf = store.wctf || {}
1438
+ if (Object.keys(result.ships).length === 0) collectSimpleNames(wctf.ships, result.ships)
1439
+ if (Object.keys(result.equipment).length === 0) collectSimpleNames(wctf.items, result.equipment)
1440
+
1441
+ return result
1442
+ }
1443
+
1444
+ function collectApiNames(source, target) {
1445
+ if (!source || typeof source !== 'object') return
1446
+
1447
+ for (const [id, value] of Object.entries(source)) {
1448
+ if (value && value.api_name) target[id] = value.api_name
1449
+ }
1450
+ }
1451
+
1452
+ function collectItemBonuses(wctf) {
1453
+ if (!wctf || typeof wctf !== 'object') return undefined
1454
+ const items = wctf.items
1455
+ if (!items || typeof items !== 'object') return undefined
1456
+ const byMasterId = {}
1457
+ for (const [id, item] of Object.entries(items)) {
1458
+ if (!item || typeof item !== 'object' || !Array.isArray(item.bonus)) continue
1459
+ byMasterId[id] = item.bonus
1460
+ }
1461
+ return Object.keys(byMasterId).length > 0
1462
+ ? {
1463
+ available: true,
1464
+ source: 'wctf',
1465
+ version: wctf.version || null,
1466
+ lastModified: wctf.lastModified || null,
1467
+ byMasterId,
1468
+ }
1469
+ : undefined
1470
+ }
1471
+
1472
+ function collectSimpleNames(source, target) {
1473
+ if (!source || typeof source !== 'object') return
1474
+
1475
+ for (const [id, value] of Object.entries(source)) {
1476
+ if (value && value.name) target[id] = value.name
1477
+ }
1478
+ }
1479
+
1480
+ function extractMasterData(store, masterFile = DEFAULT_MASTER_FILE) {
1481
+ const constants = store.const || {}
1482
+
1483
+ return {
1484
+ ships: constants.$ships || {},
1485
+ equipment: constants.$equips || {},
1486
+ shipTypes: constants.$shipTypes || {},
1487
+ equipmentTypes:
1488
+ constants.$equipTypes ||
1489
+ constants.$equipmentTypes ||
1490
+ constants.$slotitemTypes ||
1491
+ constants.$slotItemTypes ||
1492
+ {},
1493
+ missions: constants.$missions || {},
1494
+ equipmentRules: readEquipmentRules(masterFile),
1495
+ itemBonuses: collectItemBonuses(store.wctf),
1496
+ }
1497
+ }
1498
+
1499
+ function readEquipmentRules(masterFile) {
1500
+ try {
1501
+ const stat = fs.statSync(masterFile)
1502
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MASTER_FILE_LIMIT) {
1503
+ return { available: false, source: 'navy-album-master-cache' }
1504
+ }
1505
+ const master = JSON.parse(fs.readFileSync(masterFile, 'utf8'))
1506
+ const equipmentShip = objectOrEmpty(master.api_mst_equip_ship)
1507
+ const equipmentExslotTypes = Array.isArray(master.api_mst_equip_exslot)
1508
+ ? master.api_mst_equip_exslot.filter(Number.isInteger)
1509
+ : []
1510
+ const equipmentExslotShip = objectOrEmpty(
1511
+ master.api_mst_equip_exslot_ship,
1512
+ )
1513
+ const equipmentLimitExslot = objectOrEmpty(
1514
+ master.api_mst_equip_limit_exslot,
1515
+ )
1516
+ if (
1517
+ Object.keys(equipmentShip).length === 0 ||
1518
+ equipmentExslotTypes.length === 0
1519
+ ) {
1520
+ return { available: false, source: 'navy-album-master-cache' }
1521
+ }
1522
+ return {
1523
+ available: true,
1524
+ source: 'navy-album-master-cache',
1525
+ equipmentShip,
1526
+ equipmentExslotTypes,
1527
+ equipmentExslotShip,
1528
+ equipmentLimitExslot,
1529
+ }
1530
+ } catch (_) {
1531
+ return { available: false, source: 'navy-album-master-cache' }
1532
+ }
1533
+ }
1534
+
1535
+ function objectOrEmpty(value) {
1536
+ return value && typeof value === 'object' && !Array.isArray(value)
1537
+ ? value
1538
+ : {}
1539
+ }
1540
+
1541
+ function extractEventData(store) {
1542
+ const tags = extractShipTags(store)
1543
+ const ships = {}
1544
+
1545
+ for (const ship of Object.values((store.info && store.info.ships) || {})) {
1546
+ if (!ship || typeof ship !== 'object') continue
1547
+ const area = ship.api_sally_area || 0
1548
+ const tag = tags[area - 1] || emptyTag(area)
1549
+
1550
+ ships[ship.api_id] = {
1551
+ instanceId: ship.api_id,
1552
+ masterId: ship.api_ship_id,
1553
+ shipId: ship.api_id,
1554
+ modelId: ship.api_ship_id,
1555
+ area,
1556
+ mapName: tag.mapName,
1557
+ fleetName: tag.fleetName,
1558
+ color: tag.color,
1559
+ }
1560
+ }
1561
+
1562
+ return {
1563
+ tags,
1564
+ ships,
1565
+ }
1566
+ }
1567
+
1568
+ function extractPlannerData(store, plannerFile = DEFAULT_PLANNER_FILE) {
1569
+ const tags = extractShipTags(store)
1570
+ const current = readPlannerCurrent(plannerFile)
1571
+ const length = Math.max(tags.length, current.length)
1572
+ const areas = []
1573
+ const shipMap = {}
1574
+
1575
+ for (let index = 0; index < length; index += 1) {
1576
+ const area = index + 1
1577
+ const tag = tags[index] || emptyTag(area)
1578
+ const shipIds = Array.isArray(current[index]) ? current[index] : []
1579
+
1580
+ areas.push({
1581
+ area,
1582
+ mapName: tag.mapName,
1583
+ fleetName: tag.fleetName,
1584
+ color: tag.color,
1585
+ shipIds,
1586
+ })
1587
+
1588
+ for (const shipId of shipIds) {
1589
+ shipMap[shipId] = {
1590
+ area,
1591
+ mapName: tag.mapName,
1592
+ fleetName: tag.fleetName,
1593
+ color: tag.color,
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ return {
1599
+ areas,
1600
+ shipMap,
1601
+ }
1602
+ }
1603
+
1604
+ function readPlannerCurrent(plannerFile) {
1605
+ try {
1606
+ const data = JSON.parse(fs.readFileSync(plannerFile, 'utf8'))
1607
+ if (Array.isArray(data.planner)) return data.planner
1608
+ if (data.planner && Array.isArray(data.planner.current)) {
1609
+ return data.planner.current
1610
+ }
1611
+ } catch (_) {}
1612
+
1613
+ return []
1614
+ }
1615
+
1616
+ function extractShipTags(store) {
1617
+ const shiptag = (store.fcd && store.fcd.shiptag) || {}
1618
+ const mapNames = Array.isArray(shiptag.mapname) ? shiptag.mapname : []
1619
+ const fleetNames = selectFleetNames(shiptag.fleetname)
1620
+ const colors = Array.isArray(shiptag.color) ? shiptag.color : []
1621
+
1622
+ return mapNames.map((mapName, index) => ({
1623
+ area: index + 1,
1624
+ mapName,
1625
+ fleetName: fleetNames[index] || mapName,
1626
+ color: colors[index] || '',
1627
+ }))
1628
+ }
1629
+
1630
+ function selectFleetNames(fleetname) {
1631
+ if (Array.isArray(fleetname)) return fleetname
1632
+ if (!fleetname || typeof fleetname !== 'object') return []
1633
+
1634
+ const language = getWindowLanguage()
1635
+ return (
1636
+ fleetname[language] ||
1637
+ fleetname['zh-CN'] ||
1638
+ fleetname['zh-TW'] ||
1639
+ fleetname.ja ||
1640
+ fleetname['ja-JP'] ||
1641
+ fleetname['en-US'] ||
1642
+ Object.values(fleetname).find(Array.isArray) ||
1643
+ []
1644
+ )
1645
+ }
1646
+
1647
+ function getWindowLanguage() {
1648
+ if (typeof window !== 'undefined' && window.language) {
1649
+ return window.language
1650
+ }
1651
+ return 'zh-CN'
1652
+ }
1653
+
1654
+ function emptyTag(area) {
1655
+ return {
1656
+ area,
1657
+ mapName: '',
1658
+ fleetName: '',
1659
+ color: '',
1660
+ }
1661
+ }
1662
+
1663
+ module.exports = {
1664
+ createPoiDataBridge,
1665
+ DEFAULT_PORT,
1666
+ DEFAULT_PORT_FILE,
1667
+ DEFAULT_PLANNER_FILE,
1668
+ INPUT_BODY_LIMIT,
1669
+ extractEventData,
1670
+ extractMasterData,
1671
+ extractPlannerData,
1672
+ extractNames,
1673
+ }