poi-plugin-mcp 0.2.3 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -2
- package/index.js +13 -1
- package/lib/bridge-controller.js +14 -1
- package/lib/input-token.js +63 -0
- package/lib/poi-http-bridge.js +305 -7
- package/lib/poi-input.js +205 -0
- package/lib/poi-telemetry.js +215 -0
- package/lib/settings-view.js +36 -2
- package/lib/settings.js +4 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# poi-plugin-mcp
|
|
2
2
|
|
|
3
|
-
Poi plugin that starts a
|
|
3
|
+
Poi plugin that starts a loopback HTTP/MCP bridge inside Poi so local tools can
|
|
4
|
+
read current KanColle data, capture the game WebView, and use explicitly enabled
|
|
5
|
+
authenticated input.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -42,8 +44,11 @@ The Poi plugin settings panel supports:
|
|
|
42
44
|
|
|
43
45
|
- changing the HTTP port, saved in `~/.poi-mcp/settings.json`
|
|
44
46
|
- manually starting and stopping the local bridge
|
|
47
|
+
- enabling WebView input, which defaults to off
|
|
45
48
|
|
|
46
|
-
The default port is `17777`.
|
|
49
|
+
The default port is `17777`. A random 32-byte hex input token is generated at
|
|
50
|
+
`~/.poi-mcp/input-token` with restrictive best-effort file permissions. The
|
|
51
|
+
token is never returned by an HTTP endpoint.
|
|
47
52
|
|
|
48
53
|
## HTTP Endpoints
|
|
49
54
|
|
|
@@ -62,6 +67,8 @@ The default port is `17777`.
|
|
|
62
67
|
| `/event` | Event ship tag definitions plus owned ships' current sally area |
|
|
63
68
|
| `/planner` | Ship Info deck planner areas and ship assignments |
|
|
64
69
|
| `/screenshot` | In-memory PNG capture of the game WebView |
|
|
70
|
+
| `/input/status` | Whether authenticated WebView input is enabled |
|
|
71
|
+
| `/input` | Authenticated, serialized WebView input |
|
|
65
72
|
| `/all` | Combined basic runtime data |
|
|
66
73
|
|
|
67
74
|
`/screenshot` accepts `GET` only. It uses Poi's existing
|
|
@@ -70,6 +77,39 @@ does not save a file, write the clipboard, capture the desktop, or appear in
|
|
|
70
77
|
MCP resources and tools. Its response disables CORS and uses
|
|
71
78
|
`Cache-Control: no-store`.
|
|
72
79
|
|
|
80
|
+
### WebView Input
|
|
81
|
+
|
|
82
|
+
`GET /input/status` returns only `{"enabled":true|false}`. `POST /input`
|
|
83
|
+
requires `Authorization: Bearer <token>`, accepts at most 64 KiB of JSON, and
|
|
84
|
+
works only while WebView input is enabled in the plugin settings. Both routes
|
|
85
|
+
disable CORS and return `Cache-Control: no-store`.
|
|
86
|
+
|
|
87
|
+
Each POST accepts exactly one operation:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{"operation":"click","x":600,"y":360,"button":"left"}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Click coordinates use a canonical 1200x720 layout and are scaled to Poi's
|
|
94
|
+
current game WebView. Supported buttons are `left`, `middle`, and `right`.
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{"operation":"key","event":"keyDown","key":"Enter"}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Key events are `keyDown` or `keyUp`. Supported keys are `Backspace`, `Delete`,
|
|
101
|
+
`End`, `Enter`, `Escape`, `Home`, `PageDown`, `PageUp`, `Space`, `Tab`, and the
|
|
102
|
+
four arrow keys.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{"operation":"text","text":"literal text"}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Literal text must contain 1-256 printable characters. Successful requests
|
|
109
|
+
return a bounded response such as
|
|
110
|
+
`{"ok":true,"operation":"text","sequence":1}`. Complete operations execute in
|
|
111
|
+
sequence, so concurrent requests cannot interleave their WebView events.
|
|
112
|
+
|
|
73
113
|
## MCP Endpoint
|
|
74
114
|
|
|
75
115
|
The same local server also exposes a JSON-RPC MCP endpoint:
|
package/index.js
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
const { createBridgeController } = require('./lib/bridge-controller')
|
|
2
|
+
const { createPoiTelemetry } = require('./lib/poi-telemetry')
|
|
2
3
|
const { createSettingsClass } = require('./lib/settings-view')
|
|
3
4
|
|
|
4
|
-
const
|
|
5
|
+
const telemetry = createPoiTelemetry()
|
|
6
|
+
const controller = createBridgeController({
|
|
7
|
+
getQuestList: telemetry.getQuestList,
|
|
8
|
+
getQuestAction: telemetry.getQuestAction,
|
|
9
|
+
getBattleTelemetry: telemetry.getBattleTelemetry,
|
|
10
|
+
})
|
|
5
11
|
const settingsClass = createSettingsClass(controller)
|
|
6
12
|
|
|
7
13
|
function pluginDidLoad() {
|
|
14
|
+
if (typeof window !== 'undefined') {
|
|
15
|
+
window.addEventListener('game.response', telemetry.handleGameResponse)
|
|
16
|
+
}
|
|
8
17
|
controller.load().catch((error) => {
|
|
9
18
|
console.error('[poi-plugin-mcp] Failed to start:', error.message)
|
|
10
19
|
})
|
|
11
20
|
}
|
|
12
21
|
|
|
13
22
|
function pluginWillUnload() {
|
|
23
|
+
if (typeof window !== 'undefined') {
|
|
24
|
+
window.removeEventListener('game.response', telemetry.handleGameResponse)
|
|
25
|
+
}
|
|
14
26
|
controller.unload().catch((error) => {
|
|
15
27
|
console.error('[poi-plugin-mcp] Failed to stop:', error.message)
|
|
16
28
|
})
|
package/lib/bridge-controller.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { createPoiDataBridge } = require('./poi-http-bridge')
|
|
2
|
+
const { loadOrCreateInputToken } = require('./input-token')
|
|
2
3
|
const {
|
|
3
4
|
DEFAULT_SETTINGS_FILE,
|
|
4
5
|
loadSettings,
|
|
@@ -13,6 +14,7 @@ function createBridgeController(options = {}) {
|
|
|
13
14
|
|
|
14
15
|
let settings = loadSettings(settingsPath)
|
|
15
16
|
let bridge = null
|
|
17
|
+
let inputToken = options.inputToken || null
|
|
16
18
|
let pending = Promise.resolve()
|
|
17
19
|
|
|
18
20
|
function enqueue(action) {
|
|
@@ -21,9 +23,18 @@ function createBridgeController(options = {}) {
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
function createCurrentBridge() {
|
|
26
|
+
if (!inputToken) {
|
|
27
|
+
inputToken = loadOrCreateInputToken(options.inputTokenFile)
|
|
28
|
+
}
|
|
24
29
|
return createBridge({
|
|
25
30
|
getStore: options.getStore,
|
|
26
31
|
captureScreenshot: options.captureScreenshot,
|
|
32
|
+
performInput: options.performInput,
|
|
33
|
+
getQuestList: options.getQuestList,
|
|
34
|
+
getQuestAction: options.getQuestAction,
|
|
35
|
+
getBattleTelemetry: options.getBattleTelemetry,
|
|
36
|
+
inputEnabled: settings.inputEnabled,
|
|
37
|
+
inputToken,
|
|
27
38
|
port: settings.port,
|
|
28
39
|
portFile: options.portFile,
|
|
29
40
|
logger,
|
|
@@ -83,6 +94,7 @@ function createBridgeController(options = {}) {
|
|
|
83
94
|
const normalized = normalizeSettings({ ...settings, ...nextSettings })
|
|
84
95
|
const portChanged = normalized.port !== settings.port
|
|
85
96
|
const enabledChanged = normalized.enabled !== settings.enabled
|
|
97
|
+
const inputEnabledChanged = normalized.inputEnabled !== settings.inputEnabled
|
|
86
98
|
|
|
87
99
|
persist(normalized)
|
|
88
100
|
|
|
@@ -91,7 +103,7 @@ function createBridgeController(options = {}) {
|
|
|
91
103
|
return
|
|
92
104
|
}
|
|
93
105
|
|
|
94
|
-
if (portChanged || enabledChanged || !bridge) {
|
|
106
|
+
if (portChanged || enabledChanged || inputEnabledChanged || !bridge) {
|
|
95
107
|
await stopCurrentBridge()
|
|
96
108
|
await ensureStarted()
|
|
97
109
|
}
|
|
@@ -109,6 +121,7 @@ function createBridgeController(options = {}) {
|
|
|
109
121
|
running: actualPort > 0,
|
|
110
122
|
port: settings.port,
|
|
111
123
|
actualPort,
|
|
124
|
+
inputEnabled: settings.inputEnabled,
|
|
112
125
|
}
|
|
113
126
|
},
|
|
114
127
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const DEFAULT_INPUT_TOKEN_FILE = path.join(os.homedir(), '.poi-mcp', 'input-token')
|
|
7
|
+
const TOKEN_PATTERN = /^[a-f0-9]{64}$/
|
|
8
|
+
|
|
9
|
+
function loadOrCreateInputToken(tokenFile = DEFAULT_INPUT_TOKEN_FILE) {
|
|
10
|
+
const existing = readValidToken(tokenFile)
|
|
11
|
+
if (existing) {
|
|
12
|
+
restrictPermissions(tokenFile)
|
|
13
|
+
return existing
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const directory = path.dirname(tokenFile)
|
|
17
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
18
|
+
restrictPermissions(directory, 0o700)
|
|
19
|
+
|
|
20
|
+
const token = crypto.randomBytes(32).toString('hex')
|
|
21
|
+
try {
|
|
22
|
+
fs.writeFileSync(tokenFile, `${token}\n`, {
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
flag: 'wx',
|
|
25
|
+
mode: 0o600,
|
|
26
|
+
})
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error.code !== 'EEXIST') throw error
|
|
29
|
+
|
|
30
|
+
const concurrent = readValidToken(tokenFile)
|
|
31
|
+
if (concurrent) {
|
|
32
|
+
restrictPermissions(tokenFile)
|
|
33
|
+
return concurrent
|
|
34
|
+
}
|
|
35
|
+
fs.writeFileSync(tokenFile, `${token}\n`, {
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
mode: 0o600,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
restrictPermissions(tokenFile)
|
|
42
|
+
return token
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readValidToken(tokenFile) {
|
|
46
|
+
try {
|
|
47
|
+
const token = fs.readFileSync(tokenFile, 'utf8').trim()
|
|
48
|
+
return TOKEN_PATTERN.test(token) ? token : null
|
|
49
|
+
} catch (_) {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function restrictPermissions(target, mode = 0o600) {
|
|
55
|
+
try {
|
|
56
|
+
fs.chmodSync(target, mode)
|
|
57
|
+
} catch (_) {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
DEFAULT_INPUT_TOKEN_FILE,
|
|
62
|
+
loadOrCreateInputToken,
|
|
63
|
+
}
|
package/lib/poi-http-bridge.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const http = require('http')
|
|
3
|
+
const crypto = require('crypto')
|
|
3
4
|
const os = require('os')
|
|
4
5
|
const path = require('path')
|
|
5
6
|
const packageJson = require('../package.json')
|
|
7
|
+
const { loadOrCreateInputToken } = require('./input-token')
|
|
8
|
+
const { createPoiInputProvider } = require('./poi-input')
|
|
6
9
|
const { createPoiScreenshotProvider } = require('./poi-screenshot')
|
|
7
10
|
|
|
8
11
|
const DEFAULT_PORT = 17777
|
|
@@ -14,6 +17,7 @@ const DEFAULT_PLANNER_FILE = path.join(
|
|
|
14
17
|
)
|
|
15
18
|
const JSONRPC_VERSION = '2.0'
|
|
16
19
|
const MCP_PROTOCOL_VERSION = '2024-11-05'
|
|
20
|
+
const INPUT_BODY_LIMIT = 64 * 1024
|
|
17
21
|
|
|
18
22
|
function createPoiDataBridge(options = {}) {
|
|
19
23
|
const getStore = options.getStore || defaultGetStore
|
|
@@ -21,10 +25,21 @@ function createPoiDataBridge(options = {}) {
|
|
|
21
25
|
const portFile = options.portFile || DEFAULT_PORT_FILE
|
|
22
26
|
const plannerFile = options.plannerFile || DEFAULT_PLANNER_FILE
|
|
23
27
|
const logger = options.logger || console
|
|
28
|
+
const getQuestList = options.getQuestList || (() => ({ available: false, generation: 0 }))
|
|
29
|
+
const getQuestAction = options.getQuestAction || (() => ({ available: false, generation: 0 }))
|
|
30
|
+
const getBattleTelemetry = options.getBattleTelemetry ||
|
|
31
|
+
(() => ({ available: false, generation: 0 }))
|
|
32
|
+
const inputEnabled = options.inputEnabled === true
|
|
33
|
+
const inputToken = options.inputToken || (
|
|
34
|
+
inputEnabled ? loadOrCreateInputToken(options.inputTokenFile) : null
|
|
35
|
+
)
|
|
24
36
|
let captureScreenshot = options.captureScreenshot || null
|
|
37
|
+
let performInput = options.performInput || null
|
|
25
38
|
|
|
26
39
|
let server = null
|
|
27
40
|
let actualPort = 0
|
|
41
|
+
let inputPending = Promise.resolve()
|
|
42
|
+
let inputSequence = 0
|
|
28
43
|
|
|
29
44
|
function readStore() {
|
|
30
45
|
const store = getStore()
|
|
@@ -44,10 +59,82 @@ function createPoiDataBridge(options = {}) {
|
|
|
44
59
|
if (options.noStore) {
|
|
45
60
|
headers['Cache-Control'] = 'no-store'
|
|
46
61
|
}
|
|
62
|
+
Object.assign(headers, options.headers)
|
|
47
63
|
res.writeHead(statusCode, headers)
|
|
48
64
|
res.end(JSON.stringify(data))
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
function sendInputJson(res, statusCode, data, options = {}) {
|
|
68
|
+
sendJson(res, statusCode, data, {
|
|
69
|
+
allowCors: false,
|
|
70
|
+
noStore: true,
|
|
71
|
+
...options,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function enqueueInput(operation) {
|
|
76
|
+
const execute = async () => {
|
|
77
|
+
if (!performInput) {
|
|
78
|
+
performInput = createPoiInputProvider({ getStore })
|
|
79
|
+
}
|
|
80
|
+
const operationName = await performInput(operation)
|
|
81
|
+
inputSequence += 1
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
operation: operationName,
|
|
85
|
+
sequence: inputSequence,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const result = inputPending.then(execute, execute)
|
|
89
|
+
inputPending = result.catch(() => {})
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function handleInputRequest(req, res) {
|
|
94
|
+
if (req.method !== 'POST') {
|
|
95
|
+
drainRequest(req)
|
|
96
|
+
sendInputJson(res, 405, { error: 'Input endpoint only accepts POST requests.' })
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
|
|
100
|
+
drainRequest(req)
|
|
101
|
+
sendInputJson(
|
|
102
|
+
res,
|
|
103
|
+
401,
|
|
104
|
+
{ error: 'A valid Bearer token is required.' },
|
|
105
|
+
{ headers: { 'WWW-Authenticate': 'Bearer' } },
|
|
106
|
+
)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (requestContentLength(req) > INPUT_BODY_LIMIT) {
|
|
110
|
+
drainRequest(req)
|
|
111
|
+
sendInputJson(res, 413, { error: 'Input request body exceeds 64KB.' })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (!inputEnabled) {
|
|
115
|
+
drainRequest(req)
|
|
116
|
+
sendInputJson(res, 403, { error: 'WebView input is disabled.' })
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const body = await readRequestBody(
|
|
122
|
+
req,
|
|
123
|
+
INPUT_BODY_LIMIT,
|
|
124
|
+
'Input request body exceeds 64KB.',
|
|
125
|
+
)
|
|
126
|
+
const operation = JSON.parse(body || '{}')
|
|
127
|
+
sendInputJson(res, 200, await enqueueInput(operation))
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const statusCode = error.code === 'BODY_TOO_LARGE'
|
|
130
|
+
? 413
|
|
131
|
+
: /WebView|dimensions/.test(error.message)
|
|
132
|
+
? 503
|
|
133
|
+
: 400
|
|
134
|
+
sendInputJson(res, statusCode, { error: error.message })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
51
138
|
function handleMcpRequest(req, res) {
|
|
52
139
|
if (req.method !== 'POST') {
|
|
53
140
|
sendJson(res, 405, { error: 'MCP endpoint only accepts POST requests.' })
|
|
@@ -126,6 +213,35 @@ function createPoiDataBridge(options = {}) {
|
|
|
126
213
|
return
|
|
127
214
|
}
|
|
128
215
|
|
|
216
|
+
if (endpoint === '/input/status') {
|
|
217
|
+
if (req.method !== 'GET') {
|
|
218
|
+
drainRequest(req)
|
|
219
|
+
sendInputJson(
|
|
220
|
+
res,
|
|
221
|
+
405,
|
|
222
|
+
{ error: 'Input status endpoint only accepts GET requests.' },
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
sendInputJson(res, 200, { enabled: inputEnabled })
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (endpoint === '/input') {
|
|
231
|
+
await handleInputRequest(req, res)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (endpoint === '/quest-list') {
|
|
236
|
+
sendJson(res, 200, getQuestList())
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (endpoint === '/quest-action') {
|
|
241
|
+
sendJson(res, 200, getQuestAction())
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
129
245
|
const store = readStore()
|
|
130
246
|
const info = store.info
|
|
131
247
|
|
|
@@ -166,6 +282,12 @@ function createPoiDataBridge(options = {}) {
|
|
|
166
282
|
case '/planner':
|
|
167
283
|
sendJson(res, 200, extractPlannerData(store, plannerFile))
|
|
168
284
|
break
|
|
285
|
+
case '/battle':
|
|
286
|
+
sendJson(res, 200, combineBattleTelemetry(
|
|
287
|
+
getBattleTelemetry(),
|
|
288
|
+
extractProphetBattle(store),
|
|
289
|
+
))
|
|
290
|
+
break
|
|
169
291
|
case '/all':
|
|
170
292
|
sendJson(res, 200, {
|
|
171
293
|
basic: info.basic || {},
|
|
@@ -241,22 +363,64 @@ function createPoiDataBridge(options = {}) {
|
|
|
241
363
|
}
|
|
242
364
|
}
|
|
243
365
|
|
|
244
|
-
function readRequestBody(
|
|
366
|
+
function readRequestBody(
|
|
367
|
+
req,
|
|
368
|
+
maxBytes = 1024 * 1024,
|
|
369
|
+
tooLargeMessage = 'MCP request body is too large.',
|
|
370
|
+
) {
|
|
245
371
|
return new Promise((resolve, reject) => {
|
|
246
372
|
let body = ''
|
|
373
|
+
let bodyBytes = 0
|
|
374
|
+
let tooLarge = false
|
|
247
375
|
req.setEncoding('utf8')
|
|
248
376
|
req.on('data', (chunk) => {
|
|
377
|
+
if (tooLarge) return
|
|
378
|
+
bodyBytes += Buffer.byteLength(chunk)
|
|
379
|
+
if (bodyBytes > maxBytes) {
|
|
380
|
+
tooLarge = true
|
|
381
|
+
body = ''
|
|
382
|
+
return
|
|
383
|
+
}
|
|
249
384
|
body += chunk
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
385
|
+
})
|
|
386
|
+
req.on('end', () => {
|
|
387
|
+
if (tooLarge) {
|
|
388
|
+
const error = new Error(tooLargeMessage)
|
|
389
|
+
error.code = 'BODY_TOO_LARGE'
|
|
390
|
+
reject(error)
|
|
391
|
+
} else {
|
|
392
|
+
resolve(body)
|
|
253
393
|
}
|
|
254
394
|
})
|
|
255
|
-
req.on('end', () => resolve(body))
|
|
256
395
|
req.on('error', reject)
|
|
257
396
|
})
|
|
258
397
|
}
|
|
259
398
|
|
|
399
|
+
function requestContentLength(req) {
|
|
400
|
+
const value = req.headers['content-length']
|
|
401
|
+
if (value == null) return 0
|
|
402
|
+
const length = Number(value)
|
|
403
|
+
return Number.isSafeInteger(length) && length >= 0 ? length : Infinity
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function drainRequest(req) {
|
|
407
|
+
req.resume()
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function hasValidBearerToken(authorization, expectedToken) {
|
|
411
|
+
if (
|
|
412
|
+
typeof authorization !== 'string' ||
|
|
413
|
+
typeof expectedToken !== 'string' ||
|
|
414
|
+
!authorization.startsWith('Bearer ')
|
|
415
|
+
) {
|
|
416
|
+
return false
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const supplied = Buffer.from(authorization.slice('Bearer '.length), 'utf8')
|
|
420
|
+
const expected = Buffer.from(expectedToken, 'utf8')
|
|
421
|
+
return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)
|
|
422
|
+
}
|
|
423
|
+
|
|
260
424
|
function handleMcpMessage(message, readStore, plannerFile) {
|
|
261
425
|
const { id, method, params } = message || {}
|
|
262
426
|
|
|
@@ -565,13 +729,146 @@ function jsonRpcError(id, code, message) {
|
|
|
565
729
|
return { jsonrpc: JSONRPC_VERSION, id, error: { code, message } }
|
|
566
730
|
}
|
|
567
731
|
|
|
568
|
-
function defaultGetStore() {
|
|
732
|
+
function defaultGetStore(storePath) {
|
|
569
733
|
if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
|
|
570
|
-
return window.getStore()
|
|
734
|
+
return window.getStore(storePath)
|
|
571
735
|
}
|
|
572
736
|
return null
|
|
573
737
|
}
|
|
574
738
|
|
|
739
|
+
function extractProphetBattle(store) {
|
|
740
|
+
const battle = store &&
|
|
741
|
+
store.ext &&
|
|
742
|
+
store.ext['poi-plugin-prophet'] &&
|
|
743
|
+
store.ext['poi-plugin-prophet']._ &&
|
|
744
|
+
store.ext['poi-plugin-prophet']._.battle
|
|
745
|
+
if (!battle || typeof battle !== 'object') {
|
|
746
|
+
return {
|
|
747
|
+
available: false,
|
|
748
|
+
source: 'poi-plugin-prophet',
|
|
749
|
+
engine: 'poi-lib-battle',
|
|
750
|
+
engineVersion: '3.0.5',
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const fleets = {
|
|
755
|
+
main: compactBattleFleet(battle.mainFleet),
|
|
756
|
+
escort: compactBattleFleet(battle.escortFleet),
|
|
757
|
+
enemy: compactBattleFleet(battle.enemyFleet),
|
|
758
|
+
enemyEscort: compactBattleFleet(battle.enemyEscort),
|
|
759
|
+
}
|
|
760
|
+
const result = compactBattleResult(battle.result)
|
|
761
|
+
return {
|
|
762
|
+
available: true,
|
|
763
|
+
source: 'poi-plugin-prophet',
|
|
764
|
+
engine: 'poi-lib-battle',
|
|
765
|
+
engineVersion: '3.0.5',
|
|
766
|
+
sortieState: finiteNumber(battle.sortieState, 0),
|
|
767
|
+
sortieStateName: sortieStateName(battle.sortieState),
|
|
768
|
+
mapAreaId: finiteNumber(battle.mapAreaId, 0),
|
|
769
|
+
eventId: finiteNumber(battle.eventId, 0),
|
|
770
|
+
eventKind: finiteNumber(battle.eventKind, 0),
|
|
771
|
+
airControl: stringValue(battle.airControl),
|
|
772
|
+
battleForm: stringValue(battle.battleForm),
|
|
773
|
+
enemyFormation: stringValue(battle.eFormation),
|
|
774
|
+
rank: typeof result.rank === 'string' ? result.rank : null,
|
|
775
|
+
mvpIndex0Based: compactMvp(result.mvp),
|
|
776
|
+
heavilyDamaged: findHeavilyDamaged(fleets),
|
|
777
|
+
fleets,
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function combineBattleTelemetry(telemetry, predicted) {
|
|
782
|
+
const current = telemetry && typeof telemetry === 'object'
|
|
783
|
+
? telemetry
|
|
784
|
+
: { available: false, generation: 0 }
|
|
785
|
+
return {
|
|
786
|
+
available: current.available === true || predicted.available === true,
|
|
787
|
+
generation: finiteNumber(current.generation, 0),
|
|
788
|
+
status: typeof current.status === 'string' ? current.status : 'unavailable',
|
|
789
|
+
observed: current.observed || null,
|
|
790
|
+
predicted,
|
|
791
|
+
official: current.official || null,
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function compactBattleResult(result) {
|
|
796
|
+
if (!result || typeof result !== 'object') return {}
|
|
797
|
+
return Object.fromEntries(
|
|
798
|
+
['rank', 'mvp', 'getShip', 'getItem']
|
|
799
|
+
.filter((key) => result[key] !== undefined)
|
|
800
|
+
.map((key) => [key, result[key]]),
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function compactMvp(value) {
|
|
805
|
+
const values = Array.isArray(value) ? value : [value, null]
|
|
806
|
+
return {
|
|
807
|
+
main: Number.isInteger(values[0]) && values[0] >= 0 ? values[0] : null,
|
|
808
|
+
escort: Number.isInteger(values[1]) && values[1] >= 0 ? values[1] : null,
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function findHeavilyDamaged(fleets) {
|
|
813
|
+
return ['main', 'escort'].flatMap((fleetName) =>
|
|
814
|
+
fleets[fleetName].flatMap((ship) => {
|
|
815
|
+
if (
|
|
816
|
+
!Number.isFinite(ship.currentHp) ||
|
|
817
|
+
!Number.isFinite(ship.maxHp) ||
|
|
818
|
+
ship.maxHp <= 0 ||
|
|
819
|
+
ship.currentHp > ship.maxHp * 0.25
|
|
820
|
+
) {
|
|
821
|
+
return []
|
|
822
|
+
}
|
|
823
|
+
return [{
|
|
824
|
+
fleet: fleetName,
|
|
825
|
+
position: ship.position,
|
|
826
|
+
instanceId: ship.instanceId,
|
|
827
|
+
currentHp: ship.currentHp,
|
|
828
|
+
maxHp: ship.maxHp,
|
|
829
|
+
}]
|
|
830
|
+
}),
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function compactBattleFleet(fleet) {
|
|
835
|
+
if (!Array.isArray(fleet)) return []
|
|
836
|
+
return fleet.flatMap((ship) => {
|
|
837
|
+
if (!ship || typeof ship !== 'object') return []
|
|
838
|
+
const raw = ship.raw && typeof ship.raw === 'object' ? ship.raw : {}
|
|
839
|
+
return [{
|
|
840
|
+
id: nullableNumber(ship.id),
|
|
841
|
+
owner: nullableNumber(ship.owner),
|
|
842
|
+
position: nullableNumber(ship.pos),
|
|
843
|
+
maxHp: nullableNumber(ship.maxHP),
|
|
844
|
+
initialHp: nullableNumber(ship.initHP),
|
|
845
|
+
currentHp: nullableNumber(ship.nowHP),
|
|
846
|
+
lostHp: nullableNumber(ship.lostHP),
|
|
847
|
+
damage: nullableNumber(ship.damage),
|
|
848
|
+
items: Array.isArray(ship.items) ? [...ship.items] : [],
|
|
849
|
+
useItem: ship.useItem == null ? null : ship.useItem,
|
|
850
|
+
instanceId: Number.isInteger(raw.api_id) ? raw.api_id : null,
|
|
851
|
+
masterId: Number.isInteger(raw.api_ship_id) ? raw.api_ship_id : null,
|
|
852
|
+
}]
|
|
853
|
+
})
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function sortieStateName(value) {
|
|
857
|
+
return ['in_port', 'navigation', 'battle', 'practice'][value] || 'unknown'
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function finiteNumber(value, fallback) {
|
|
861
|
+
return Number.isFinite(value) ? value : fallback
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function nullableNumber(value) {
|
|
865
|
+
return Number.isFinite(value) ? value : null
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function stringValue(value) {
|
|
869
|
+
return typeof value === 'string' ? value : ''
|
|
870
|
+
}
|
|
871
|
+
|
|
575
872
|
function cleanupPortFile(portFile) {
|
|
576
873
|
try {
|
|
577
874
|
fs.unlinkSync(portFile)
|
|
@@ -753,6 +1050,7 @@ module.exports = {
|
|
|
753
1050
|
DEFAULT_PORT,
|
|
754
1051
|
DEFAULT_PORT_FILE,
|
|
755
1052
|
DEFAULT_PLANNER_FILE,
|
|
1053
|
+
INPUT_BODY_LIMIT,
|
|
756
1054
|
extractEventData,
|
|
757
1055
|
extractMasterData,
|
|
758
1056
|
extractPlannerData,
|
package/lib/poi-input.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
const CANONICAL_WIDTH = 1200
|
|
2
|
+
const CANONICAL_HEIGHT = 720
|
|
3
|
+
const DEFAULT_CLICK_DELAY_MS = 10
|
|
4
|
+
const MAX_TEXT_LENGTH = 256
|
|
5
|
+
|
|
6
|
+
const SUPPORTED_BUTTONS = new Set(['left', 'middle', 'right'])
|
|
7
|
+
const SUPPORTED_KEY_EVENTS = new Set(['keyDown', 'keyUp'])
|
|
8
|
+
const SUPPORTED_KEYS = new Set([
|
|
9
|
+
'Backspace',
|
|
10
|
+
'Delete',
|
|
11
|
+
'End',
|
|
12
|
+
'Enter',
|
|
13
|
+
'Escape',
|
|
14
|
+
'Home',
|
|
15
|
+
'PageDown',
|
|
16
|
+
'PageUp',
|
|
17
|
+
'Space',
|
|
18
|
+
'Tab',
|
|
19
|
+
'ArrowDown',
|
|
20
|
+
'ArrowLeft',
|
|
21
|
+
'ArrowRight',
|
|
22
|
+
'ArrowUp',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
function createPoiInputProvider(options = {}) {
|
|
26
|
+
const getStore = options.getStore || defaultGetStore
|
|
27
|
+
const resolveWebContents =
|
|
28
|
+
options.resolveWebContents || defaultResolveWebContents
|
|
29
|
+
const delay = options.delay || defaultDelay
|
|
30
|
+
const clickDelayMs = options.clickDelayMs == null
|
|
31
|
+
? DEFAULT_CLICK_DELAY_MS
|
|
32
|
+
: options.clickDelayMs
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
!Number.isInteger(clickDelayMs) ||
|
|
36
|
+
clickDelayMs < 1 ||
|
|
37
|
+
clickDelayMs > 100
|
|
38
|
+
) {
|
|
39
|
+
throw new Error('clickDelayMs must be an integer from 1 to 100')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return async function performPoiInput(operation) {
|
|
43
|
+
validateOperationObject(operation)
|
|
44
|
+
const layout = readLayout(getStore, resolveWebContents)
|
|
45
|
+
|
|
46
|
+
switch (operation.operation) {
|
|
47
|
+
case 'click':
|
|
48
|
+
validateClick(operation)
|
|
49
|
+
await sendClick(layout, operation, delay, clickDelayMs)
|
|
50
|
+
return 'click'
|
|
51
|
+
case 'key':
|
|
52
|
+
validateKey(operation)
|
|
53
|
+
await layout.webContents.sendInputEvent({
|
|
54
|
+
type: operation.event,
|
|
55
|
+
keyCode: operation.key,
|
|
56
|
+
})
|
|
57
|
+
return 'key'
|
|
58
|
+
case 'text':
|
|
59
|
+
validateText(operation)
|
|
60
|
+
for (const character of operation.text) {
|
|
61
|
+
await layout.webContents.sendInputEvent({
|
|
62
|
+
type: 'char',
|
|
63
|
+
keyCode: character,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
return 'text'
|
|
67
|
+
default:
|
|
68
|
+
throw new Error(`Unsupported input operation: ${String(operation.operation)}`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateOperationObject(operation) {
|
|
74
|
+
if (
|
|
75
|
+
!operation ||
|
|
76
|
+
typeof operation !== 'object' ||
|
|
77
|
+
Array.isArray(operation)
|
|
78
|
+
) {
|
|
79
|
+
throw new Error('Input must be one operation object')
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readLayout(getStore, resolveWebContents) {
|
|
84
|
+
const layout = getStore('layout.webview')
|
|
85
|
+
if (!layout || !layout.ref) {
|
|
86
|
+
throw new Error('Poi game WebView is not ready')
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
!Number.isFinite(layout.width) ||
|
|
90
|
+
layout.width <= 0 ||
|
|
91
|
+
!Number.isFinite(layout.height) ||
|
|
92
|
+
layout.height <= 0
|
|
93
|
+
) {
|
|
94
|
+
throw new Error('Poi game WebView dimensions must be positive finite numbers')
|
|
95
|
+
}
|
|
96
|
+
let webContents
|
|
97
|
+
if (typeof layout.ref.getWebContents === 'function') {
|
|
98
|
+
webContents = layout.ref.getWebContents()
|
|
99
|
+
} else if (typeof layout.ref.getWebContentsId === 'function') {
|
|
100
|
+
const webContentsId = layout.ref.getWebContentsId()
|
|
101
|
+
if (!Number.isInteger(webContentsId) || webContentsId <= 0) {
|
|
102
|
+
throw new Error('Poi game WebContents id is invalid')
|
|
103
|
+
}
|
|
104
|
+
webContents = resolveWebContents(webContentsId)
|
|
105
|
+
} else {
|
|
106
|
+
throw new Error('Poi game WebView is not ready')
|
|
107
|
+
}
|
|
108
|
+
if (!webContents || typeof webContents.sendInputEvent !== 'function') {
|
|
109
|
+
throw new Error('Poi game WebContents is not ready')
|
|
110
|
+
}
|
|
111
|
+
return { ...layout, webContents }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function validateClick(operation) {
|
|
115
|
+
assertExactFields(operation, ['operation', 'x', 'y', 'button'])
|
|
116
|
+
if (!Number.isFinite(operation.x) || !Number.isFinite(operation.y)) {
|
|
117
|
+
throw new Error('Click coordinates must be finite numbers')
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
operation.x < 0 ||
|
|
121
|
+
operation.x >= CANONICAL_WIDTH ||
|
|
122
|
+
operation.y < 0 ||
|
|
123
|
+
operation.y >= CANONICAL_HEIGHT
|
|
124
|
+
) {
|
|
125
|
+
throw new Error('Click coordinates must be within canonical bounds')
|
|
126
|
+
}
|
|
127
|
+
if (!SUPPORTED_BUTTONS.has(operation.button)) {
|
|
128
|
+
throw new Error(`Unsupported mouse button: ${String(operation.button)}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateKey(operation) {
|
|
133
|
+
assertExactFields(operation, ['operation', 'event', 'key'])
|
|
134
|
+
if (!SUPPORTED_KEY_EVENTS.has(operation.event)) {
|
|
135
|
+
throw new Error(`Unsupported key event: ${String(operation.event)}`)
|
|
136
|
+
}
|
|
137
|
+
if (!SUPPORTED_KEYS.has(operation.key)) {
|
|
138
|
+
throw new Error(`Unsupported key: ${String(operation.key)}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateText(operation) {
|
|
143
|
+
assertExactFields(operation, ['operation', 'text'])
|
|
144
|
+
if (
|
|
145
|
+
typeof operation.text !== 'string' ||
|
|
146
|
+
operation.text.length === 0 ||
|
|
147
|
+
operation.text.length > MAX_TEXT_LENGTH
|
|
148
|
+
) {
|
|
149
|
+
throw new Error('Literal text must contain 1 to 256 characters')
|
|
150
|
+
}
|
|
151
|
+
if (/[\u0000-\u001f\u007f-\u009f]/u.test(operation.text)) {
|
|
152
|
+
throw new Error('Literal text must contain printable characters only')
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function assertExactFields(operation, allowedFields) {
|
|
157
|
+
const allowed = new Set(allowedFields)
|
|
158
|
+
const unexpected = Object.keys(operation).find((field) => !allowed.has(field))
|
|
159
|
+
if (unexpected) {
|
|
160
|
+
throw new Error(`Unexpected field for ${operation.operation}: ${unexpected}`)
|
|
161
|
+
}
|
|
162
|
+
const missing = allowedFields.find((field) => !Object.hasOwn(operation, field))
|
|
163
|
+
if (missing) {
|
|
164
|
+
throw new Error(`Missing field for ${operation.operation}: ${missing}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function sendClick(layout, operation, delay, clickDelayMs) {
|
|
169
|
+
const event = {
|
|
170
|
+
x: Math.floor((operation.x * layout.width) / CANONICAL_WIDTH),
|
|
171
|
+
y: Math.floor((operation.y * layout.height) / CANONICAL_HEIGHT),
|
|
172
|
+
button: operation.button,
|
|
173
|
+
clickCount: 1,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await layout.webContents.sendInputEvent({ type: 'mouseDown', ...event })
|
|
177
|
+
try {
|
|
178
|
+
await delay(clickDelayMs)
|
|
179
|
+
} finally {
|
|
180
|
+
await layout.webContents.sendInputEvent({ type: 'mouseUp', ...event })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function defaultDelay(milliseconds) {
|
|
185
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function defaultGetStore(path) {
|
|
189
|
+
if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
|
|
190
|
+
return window.getStore(path)
|
|
191
|
+
}
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function defaultResolveWebContents(webContentsId) {
|
|
196
|
+
const { webContents } = require('@electron/remote')
|
|
197
|
+
return webContents.fromId(webContentsId)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
CANONICAL_HEIGHT,
|
|
202
|
+
CANONICAL_WIDTH,
|
|
203
|
+
MAX_TEXT_LENGTH,
|
|
204
|
+
createPoiInputProvider,
|
|
205
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const QUEST_LIST_PATH = '/kcsapi/api_get_member/questlist'
|
|
2
|
+
const QUEST_ACTION_PATHS = new Set([
|
|
3
|
+
'/kcsapi/api_req_quest/start',
|
|
4
|
+
'/kcsapi/api_req_quest/stop',
|
|
5
|
+
])
|
|
6
|
+
const BATTLE_RESULT_PATHS = new Set([
|
|
7
|
+
'/kcsapi/api_req_practice/battle_result',
|
|
8
|
+
'/kcsapi/api_req_sortie/battleresult',
|
|
9
|
+
'/kcsapi/api_req_combined_battle/battleresult',
|
|
10
|
+
])
|
|
11
|
+
const BATTLE_PATHS = new Set([
|
|
12
|
+
'/kcsapi/api_req_practice/battle',
|
|
13
|
+
'/kcsapi/api_req_practice/midnight_battle',
|
|
14
|
+
'/kcsapi/api_req_sortie/battle',
|
|
15
|
+
'/kcsapi/api_req_sortie/airbattle',
|
|
16
|
+
'/kcsapi/api_req_sortie/ld_airbattle',
|
|
17
|
+
'/kcsapi/api_req_sortie/ld_shooting',
|
|
18
|
+
'/kcsapi/api_req_battle_midnight/battle',
|
|
19
|
+
'/kcsapi/api_req_battle_midnight/sp_midnight',
|
|
20
|
+
'/kcsapi/api_req_combined_battle/battle',
|
|
21
|
+
'/kcsapi/api_req_combined_battle/battle_water',
|
|
22
|
+
'/kcsapi/api_req_combined_battle/airbattle',
|
|
23
|
+
'/kcsapi/api_req_combined_battle/ld_airbattle',
|
|
24
|
+
'/kcsapi/api_req_combined_battle/ld_shooting',
|
|
25
|
+
'/kcsapi/api_req_combined_battle/ec_battle',
|
|
26
|
+
'/kcsapi/api_req_combined_battle/each_battle',
|
|
27
|
+
'/kcsapi/api_req_combined_battle/each_battle_water',
|
|
28
|
+
'/kcsapi/api_req_combined_battle/midnight_battle',
|
|
29
|
+
'/kcsapi/api_req_combined_battle/sp_midnight',
|
|
30
|
+
'/kcsapi/api_req_combined_battle/ec_midnight_battle',
|
|
31
|
+
'/kcsapi/api_req_combined_battle/ec_night_to_day',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
function createPoiTelemetry(options = {}) {
|
|
35
|
+
const now = options.now || (() => new Date())
|
|
36
|
+
let questGeneration = 0
|
|
37
|
+
let questList = null
|
|
38
|
+
let questActionGeneration = 0
|
|
39
|
+
let questAction = null
|
|
40
|
+
let battleGeneration = 0
|
|
41
|
+
let battleTelemetry = null
|
|
42
|
+
|
|
43
|
+
function handleGameResponse(event) {
|
|
44
|
+
const detail = event && event.detail
|
|
45
|
+
if (!detail || typeof detail.path !== 'string') return
|
|
46
|
+
|
|
47
|
+
if (detail.path === QUEST_LIST_PATH) {
|
|
48
|
+
captureQuestList(detail)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
if (QUEST_ACTION_PATHS.has(detail.path)) {
|
|
52
|
+
captureQuestAction(detail)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
if (BATTLE_RESULT_PATHS.has(detail.path)) {
|
|
56
|
+
captureBattleResult(detail)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (BATTLE_PATHS.has(detail.path)) {
|
|
60
|
+
captureBattlePacket(detail)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function captureQuestList(detail) {
|
|
65
|
+
const body = detail.body
|
|
66
|
+
const postBody = detail.postBody
|
|
67
|
+
const tabId = toInteger(postBody && postBody.api_tab_id)
|
|
68
|
+
if (
|
|
69
|
+
!body ||
|
|
70
|
+
!Array.isArray(body.api_list) ||
|
|
71
|
+
tabId == null ||
|
|
72
|
+
tabId < 0 ||
|
|
73
|
+
tabId > 9
|
|
74
|
+
) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const quests = body.api_list.flatMap((quest, index) => {
|
|
79
|
+
if (!quest || typeof quest !== 'object' || !Number.isInteger(quest.api_no)) {
|
|
80
|
+
return []
|
|
81
|
+
}
|
|
82
|
+
return [{ ...quest, listIndex: index + 1 }]
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
questGeneration += 1
|
|
86
|
+
questList = {
|
|
87
|
+
available: true,
|
|
88
|
+
generation: questGeneration,
|
|
89
|
+
capturedAt: now().toISOString(),
|
|
90
|
+
tabId,
|
|
91
|
+
pageNo: nonNegativeInteger(body.api_disp_page),
|
|
92
|
+
pageCount: nonNegativeInteger(body.api_page_count),
|
|
93
|
+
count: nonNegativeInteger(body.api_count),
|
|
94
|
+
execCount: nonNegativeInteger(body.api_exec_count),
|
|
95
|
+
execType: nonNegativeInteger(body.api_exec_type),
|
|
96
|
+
quests,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function captureQuestAction(detail) {
|
|
101
|
+
const postBody = detail.postBody
|
|
102
|
+
const questId = toInteger(postBody && postBody.api_quest_id)
|
|
103
|
+
if (questId == null || questId <= 0) return
|
|
104
|
+
|
|
105
|
+
questActionGeneration += 1
|
|
106
|
+
questAction = {
|
|
107
|
+
available: true,
|
|
108
|
+
generation: questActionGeneration,
|
|
109
|
+
capturedAt: now().toISOString(),
|
|
110
|
+
path: detail.path,
|
|
111
|
+
questId,
|
|
112
|
+
flag: toInteger(postBody && postBody.api_quest_flag),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function captureBattlePacket(detail) {
|
|
117
|
+
if (!battleTelemetry || battleTelemetry.status === 'settled') {
|
|
118
|
+
battleGeneration += 1
|
|
119
|
+
}
|
|
120
|
+
battleTelemetry = {
|
|
121
|
+
available: true,
|
|
122
|
+
generation: battleGeneration,
|
|
123
|
+
status: 'in_progress',
|
|
124
|
+
observed: {
|
|
125
|
+
capturedAt: now().toISOString(),
|
|
126
|
+
path: detail.path,
|
|
127
|
+
time: finiteOrNull(detail.time),
|
|
128
|
+
phaseStartHp: {
|
|
129
|
+
friendlyMain: numberArray(detail.body && detail.body.api_f_nowhps),
|
|
130
|
+
friendlyEscort: numberArray(
|
|
131
|
+
detail.body && detail.body.api_f_nowhps_combined,
|
|
132
|
+
),
|
|
133
|
+
enemyMain: numberArray(detail.body && detail.body.api_e_nowhps),
|
|
134
|
+
enemyEscort: numberArray(
|
|
135
|
+
detail.body && detail.body.api_e_nowhps_combined,
|
|
136
|
+
),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
official: null,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function captureBattleResult(detail) {
|
|
144
|
+
if (!battleTelemetry) battleGeneration += 1
|
|
145
|
+
const body = detail.body && typeof detail.body === 'object'
|
|
146
|
+
? detail.body
|
|
147
|
+
: {}
|
|
148
|
+
battleTelemetry = {
|
|
149
|
+
available: true,
|
|
150
|
+
generation: battleGeneration,
|
|
151
|
+
status: 'settled',
|
|
152
|
+
observed: battleTelemetry ? battleTelemetry.observed : null,
|
|
153
|
+
official: {
|
|
154
|
+
capturedAt: now().toISOString(),
|
|
155
|
+
path: detail.path,
|
|
156
|
+
time: finiteOrNull(detail.time),
|
|
157
|
+
rank: typeof body.api_win_rank === 'string' ? body.api_win_rank : null,
|
|
158
|
+
mvpPosition: {
|
|
159
|
+
main: positiveIntegerOrNull(body.api_mvp),
|
|
160
|
+
escort: positiveIntegerOrNull(body.api_mvp_combined),
|
|
161
|
+
},
|
|
162
|
+
drop: {
|
|
163
|
+
ship: objectOrNull(body.api_get_ship),
|
|
164
|
+
useItem: objectOrNull(body.api_get_useitem),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
handleGameResponse,
|
|
172
|
+
getQuestList() {
|
|
173
|
+
return questList || { available: false, generation: 0 }
|
|
174
|
+
},
|
|
175
|
+
getQuestAction() {
|
|
176
|
+
return questAction || { available: false, generation: 0 }
|
|
177
|
+
},
|
|
178
|
+
getBattleTelemetry() {
|
|
179
|
+
return battleTelemetry || { available: false, generation: 0 }
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function toInteger(value, fallback = null) {
|
|
185
|
+
if (value == null || value === '') return fallback
|
|
186
|
+
const parsed = Number(value)
|
|
187
|
+
return Number.isInteger(parsed) ? parsed : null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function nonNegativeInteger(value) {
|
|
191
|
+
const parsed = toInteger(value, 0)
|
|
192
|
+
return parsed != null && parsed >= 0 ? parsed : 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function positiveIntegerOrNull(value) {
|
|
196
|
+
const parsed = toInteger(value)
|
|
197
|
+
return parsed != null && parsed > 0 ? parsed : null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function finiteOrNull(value) {
|
|
201
|
+
return Number.isFinite(value) ? value : null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function numberArray(value) {
|
|
205
|
+
if (!Array.isArray(value)) return []
|
|
206
|
+
return value.map((item) => finiteOrNull(item))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function objectOrNull(value) {
|
|
210
|
+
return value && typeof value === 'object' ? { ...value } : null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
createPoiTelemetry,
|
|
215
|
+
}
|
package/lib/settings-view.js
CHANGED
|
@@ -54,9 +54,20 @@ function renderStatefulSettings(React, controller) {
|
|
|
54
54
|
validPort,
|
|
55
55
|
onPortChange: (event) => setPortText(event.target.value),
|
|
56
56
|
onApply: () => run(
|
|
57
|
-
() => controller.applySettings({
|
|
57
|
+
() => controller.applySettings({
|
|
58
|
+
port,
|
|
59
|
+
enabled: status.enabled,
|
|
60
|
+
inputEnabled: status.inputEnabled,
|
|
61
|
+
}),
|
|
58
62
|
'Port saved',
|
|
59
63
|
),
|
|
64
|
+
onInputToggle: (event) => {
|
|
65
|
+
const inputEnabled = event.target.checked
|
|
66
|
+
return run(
|
|
67
|
+
() => controller.applySettings({ inputEnabled }),
|
|
68
|
+
inputEnabled ? 'WebView input enabled' : 'WebView input disabled',
|
|
69
|
+
)
|
|
70
|
+
},
|
|
60
71
|
onToggle: () => run(
|
|
61
72
|
() => (status.running ? controller.stopBridge() : controller.startBridge()),
|
|
62
73
|
status.running ? 'Stopped' : 'Started',
|
|
@@ -79,8 +90,17 @@ function renderStaticSettings(e, controller) {
|
|
|
79
90
|
onApply: async () => {
|
|
80
91
|
const input = typeof document !== 'undefined' ? document.getElementById('poi-mcp-port') : null
|
|
81
92
|
const nextPort = input ? Number(input.value) : port
|
|
82
|
-
await controller.applySettings({
|
|
93
|
+
await controller.applySettings({
|
|
94
|
+
port: nextPort,
|
|
95
|
+
enabled: status.enabled,
|
|
96
|
+
inputEnabled: status.inputEnabled,
|
|
97
|
+
})
|
|
83
98
|
},
|
|
99
|
+
onInputToggle: (event) => controller.applySettings({
|
|
100
|
+
port,
|
|
101
|
+
enabled: status.enabled,
|
|
102
|
+
inputEnabled: event.target.checked,
|
|
103
|
+
}),
|
|
84
104
|
onToggle: () => (status.running ? controller.stopBridge() : controller.startBridge()),
|
|
85
105
|
})
|
|
86
106
|
}
|
|
@@ -121,6 +141,20 @@ function renderSettings(e, props) {
|
|
|
121
141
|
}, props.status.running ? 'Stop' : 'Start'),
|
|
122
142
|
e('span', { style: styles.status }, statusText),
|
|
123
143
|
),
|
|
144
|
+
e('div', { style: styles.row },
|
|
145
|
+
e('label', { style: styles.label, htmlFor: 'poi-mcp-input-enabled' }, 'WebView input'),
|
|
146
|
+
e('input', {
|
|
147
|
+
id: 'poi-mcp-input-enabled',
|
|
148
|
+
type: 'checkbox',
|
|
149
|
+
checked: props.onPortChange ? props.status.inputEnabled : undefined,
|
|
150
|
+
defaultChecked: props.onPortChange ? undefined : props.status.inputEnabled,
|
|
151
|
+
disabled: props.busy,
|
|
152
|
+
onChange: props.onInputToggle,
|
|
153
|
+
}),
|
|
154
|
+
e('span', { style: styles.status },
|
|
155
|
+
props.status.inputEnabled ? 'Enabled' : 'Disabled',
|
|
156
|
+
),
|
|
157
|
+
),
|
|
124
158
|
e('div', { style: styles.note },
|
|
125
159
|
'Pi extension defaults to 127.0.0.1:17777; keep this port unless you also update Pi.',
|
|
126
160
|
),
|
package/lib/settings.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require('path')
|
|
|
5
5
|
const DEFAULT_SETTINGS = Object.freeze({
|
|
6
6
|
port: 17777,
|
|
7
7
|
enabled: true,
|
|
8
|
+
inputEnabled: false,
|
|
8
9
|
})
|
|
9
10
|
|
|
10
11
|
const DEFAULT_SETTINGS_FILE = path.join(os.homedir(), '.poi-mcp', 'settings.json')
|
|
@@ -14,6 +15,9 @@ function normalizeSettings(input = {}) {
|
|
|
14
15
|
return {
|
|
15
16
|
port: port == null ? DEFAULT_SETTINGS.port : port,
|
|
16
17
|
enabled: typeof input.enabled === 'boolean' ? input.enabled : DEFAULT_SETTINGS.enabled,
|
|
18
|
+
inputEnabled: typeof input.inputEnabled === 'boolean'
|
|
19
|
+
? input.inputEnabled
|
|
20
|
+
: DEFAULT_SETTINGS.inputEnabled,
|
|
17
21
|
}
|
|
18
22
|
}
|
|
19
23
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "poi-plugin-mcp",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Poi
|
|
3
|
+
"version": "0.2.10",
|
|
4
|
+
"description": "Poi data, WebView capture, and opt-in authenticated input bridge for local KanColle tools.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"poi-plugin",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"poiPlugin": {
|
|
25
25
|
"title": "MCP 数据桥",
|
|
26
26
|
"id": "mcp_data_bridge",
|
|
27
|
-
"description": "Expose Poi state
|
|
27
|
+
"description": "Expose Poi state, in-memory game WebView captures, and opt-in authenticated input on a local bridge.",
|
|
28
28
|
"icon": "fa/database",
|
|
29
29
|
"priority": 53
|
|
30
30
|
},
|