poi-plugin-mcp 0.2.2 → 0.2.9
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 +49 -2
- package/index.js +13 -1
- package/lib/bridge-controller.js +15 -1
- package/lib/input-token.js +63 -0
- package/lib/poi-http-bridge.js +349 -12
- package/lib/poi-input.js +205 -0
- package/lib/poi-screenshot.js +104 -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
|
|
|
@@ -61,8 +66,50 @@ The default port is `17777`.
|
|
|
61
66
|
| `/master` | Master ship, equipment, ship type, equipment type, and mission data |
|
|
62
67
|
| `/event` | Event ship tag definitions plus owned ships' current sally area |
|
|
63
68
|
| `/planner` | Ship Info deck planner areas and ship assignments |
|
|
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 |
|
|
64
72
|
| `/all` | Combined basic runtime data |
|
|
65
73
|
|
|
74
|
+
`/screenshot` accepts `GET` only. It uses Poi's existing
|
|
75
|
+
`screenshot::get` WebContents capture path and returns PNG base64 in JSON. It
|
|
76
|
+
does not save a file, write the clipboard, capture the desktop, or appear in
|
|
77
|
+
MCP resources and tools. Its response disables CORS and uses
|
|
78
|
+
`Cache-Control: no-store`.
|
|
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
|
+
|
|
66
113
|
## MCP Endpoint
|
|
67
114
|
|
|
68
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,8 +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,
|
|
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,
|
|
26
38
|
port: settings.port,
|
|
27
39
|
portFile: options.portFile,
|
|
28
40
|
logger,
|
|
@@ -82,6 +94,7 @@ function createBridgeController(options = {}) {
|
|
|
82
94
|
const normalized = normalizeSettings({ ...settings, ...nextSettings })
|
|
83
95
|
const portChanged = normalized.port !== settings.port
|
|
84
96
|
const enabledChanged = normalized.enabled !== settings.enabled
|
|
97
|
+
const inputEnabledChanged = normalized.inputEnabled !== settings.inputEnabled
|
|
85
98
|
|
|
86
99
|
persist(normalized)
|
|
87
100
|
|
|
@@ -90,7 +103,7 @@ function createBridgeController(options = {}) {
|
|
|
90
103
|
return
|
|
91
104
|
}
|
|
92
105
|
|
|
93
|
-
if (portChanged || enabledChanged || !bridge) {
|
|
106
|
+
if (portChanged || enabledChanged || inputEnabledChanged || !bridge) {
|
|
94
107
|
await stopCurrentBridge()
|
|
95
108
|
await ensureStarted()
|
|
96
109
|
}
|
|
@@ -108,6 +121,7 @@ function createBridgeController(options = {}) {
|
|
|
108
121
|
running: actualPort > 0,
|
|
109
122
|
port: settings.port,
|
|
110
123
|
actualPort,
|
|
124
|
+
inputEnabled: settings.inputEnabled,
|
|
111
125
|
}
|
|
112
126
|
},
|
|
113
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,12 @@
|
|
|
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')
|
|
9
|
+
const { createPoiScreenshotProvider } = require('./poi-screenshot')
|
|
6
10
|
|
|
7
11
|
const DEFAULT_PORT = 17777
|
|
8
12
|
const DEFAULT_PORT_FILE = path.join(os.homedir(), '.poi-mcp', 'port')
|
|
@@ -13,6 +17,7 @@ const DEFAULT_PLANNER_FILE = path.join(
|
|
|
13
17
|
)
|
|
14
18
|
const JSONRPC_VERSION = '2.0'
|
|
15
19
|
const MCP_PROTOCOL_VERSION = '2024-11-05'
|
|
20
|
+
const INPUT_BODY_LIMIT = 64 * 1024
|
|
16
21
|
|
|
17
22
|
function createPoiDataBridge(options = {}) {
|
|
18
23
|
const getStore = options.getStore || defaultGetStore
|
|
@@ -20,9 +25,21 @@ function createPoiDataBridge(options = {}) {
|
|
|
20
25
|
const portFile = options.portFile || DEFAULT_PORT_FILE
|
|
21
26
|
const plannerFile = options.plannerFile || DEFAULT_PLANNER_FILE
|
|
22
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
|
+
)
|
|
36
|
+
let captureScreenshot = options.captureScreenshot || null
|
|
37
|
+
let performInput = options.performInput || null
|
|
23
38
|
|
|
24
39
|
let server = null
|
|
25
40
|
let actualPort = 0
|
|
41
|
+
let inputPending = Promise.resolve()
|
|
42
|
+
let inputSequence = 0
|
|
26
43
|
|
|
27
44
|
function readStore() {
|
|
28
45
|
const store = getStore()
|
|
@@ -32,14 +49,92 @@ function createPoiDataBridge(options = {}) {
|
|
|
32
49
|
return store
|
|
33
50
|
}
|
|
34
51
|
|
|
35
|
-
function sendJson(res, statusCode, data) {
|
|
36
|
-
|
|
37
|
-
'Access-Control-Allow-Origin': '*',
|
|
52
|
+
function sendJson(res, statusCode, data, options = {}) {
|
|
53
|
+
const headers = {
|
|
38
54
|
'Content-Type': 'application/json',
|
|
39
|
-
}
|
|
55
|
+
}
|
|
56
|
+
if (options.allowCors !== false) {
|
|
57
|
+
headers['Access-Control-Allow-Origin'] = '*'
|
|
58
|
+
}
|
|
59
|
+
if (options.noStore) {
|
|
60
|
+
headers['Cache-Control'] = 'no-store'
|
|
61
|
+
}
|
|
62
|
+
Object.assign(headers, options.headers)
|
|
63
|
+
res.writeHead(statusCode, headers)
|
|
40
64
|
res.end(JSON.stringify(data))
|
|
41
65
|
}
|
|
42
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
|
+
|
|
43
138
|
function handleMcpRequest(req, res) {
|
|
44
139
|
if (req.method !== 'POST') {
|
|
45
140
|
sendJson(res, 405, { error: 'MCP endpoint only accepts POST requests.' })
|
|
@@ -67,7 +162,7 @@ function createPoiDataBridge(options = {}) {
|
|
|
67
162
|
})
|
|
68
163
|
}
|
|
69
164
|
|
|
70
|
-
function handleRequest(req, res) {
|
|
165
|
+
async function handleRequest(req, res) {
|
|
71
166
|
try {
|
|
72
167
|
if (req.url === '/shutdown') {
|
|
73
168
|
sendJson(res, 200, { status: 'shutting down' })
|
|
@@ -87,6 +182,66 @@ function createPoiDataBridge(options = {}) {
|
|
|
87
182
|
return
|
|
88
183
|
}
|
|
89
184
|
|
|
185
|
+
if (endpoint === '/screenshot') {
|
|
186
|
+
if (req.method !== 'GET') {
|
|
187
|
+
sendJson(
|
|
188
|
+
res,
|
|
189
|
+
405,
|
|
190
|
+
{ error: 'Screenshot endpoint only accepts GET requests.' },
|
|
191
|
+
{ allowCors: false, noStore: true },
|
|
192
|
+
)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
if (!captureScreenshot) {
|
|
197
|
+
captureScreenshot = createPoiScreenshotProvider()
|
|
198
|
+
}
|
|
199
|
+
sendJson(
|
|
200
|
+
res,
|
|
201
|
+
200,
|
|
202
|
+
await captureScreenshot(),
|
|
203
|
+
{ allowCors: false, noStore: true },
|
|
204
|
+
)
|
|
205
|
+
} catch (error) {
|
|
206
|
+
sendJson(
|
|
207
|
+
res,
|
|
208
|
+
503,
|
|
209
|
+
{ error: error.message },
|
|
210
|
+
{ allowCors: false, noStore: true },
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
return
|
|
214
|
+
}
|
|
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
|
+
|
|
90
245
|
const store = readStore()
|
|
91
246
|
const info = store.info
|
|
92
247
|
|
|
@@ -127,6 +282,12 @@ function createPoiDataBridge(options = {}) {
|
|
|
127
282
|
case '/planner':
|
|
128
283
|
sendJson(res, 200, extractPlannerData(store, plannerFile))
|
|
129
284
|
break
|
|
285
|
+
case '/battle':
|
|
286
|
+
sendJson(res, 200, combineBattleTelemetry(
|
|
287
|
+
getBattleTelemetry(),
|
|
288
|
+
extractProphetBattle(store),
|
|
289
|
+
))
|
|
290
|
+
break
|
|
130
291
|
case '/all':
|
|
131
292
|
sendJson(res, 200, {
|
|
132
293
|
basic: info.basic || {},
|
|
@@ -202,22 +363,64 @@ function createPoiDataBridge(options = {}) {
|
|
|
202
363
|
}
|
|
203
364
|
}
|
|
204
365
|
|
|
205
|
-
function readRequestBody(
|
|
366
|
+
function readRequestBody(
|
|
367
|
+
req,
|
|
368
|
+
maxBytes = 1024 * 1024,
|
|
369
|
+
tooLargeMessage = 'MCP request body is too large.',
|
|
370
|
+
) {
|
|
206
371
|
return new Promise((resolve, reject) => {
|
|
207
372
|
let body = ''
|
|
373
|
+
let bodyBytes = 0
|
|
374
|
+
let tooLarge = false
|
|
208
375
|
req.setEncoding('utf8')
|
|
209
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
|
+
}
|
|
210
384
|
body += chunk
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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)
|
|
214
393
|
}
|
|
215
394
|
})
|
|
216
|
-
req.on('end', () => resolve(body))
|
|
217
395
|
req.on('error', reject)
|
|
218
396
|
})
|
|
219
397
|
}
|
|
220
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
|
+
|
|
221
424
|
function handleMcpMessage(message, readStore, plannerFile) {
|
|
222
425
|
const { id, method, params } = message || {}
|
|
223
426
|
|
|
@@ -526,13 +729,146 @@ function jsonRpcError(id, code, message) {
|
|
|
526
729
|
return { jsonrpc: JSONRPC_VERSION, id, error: { code, message } }
|
|
527
730
|
}
|
|
528
731
|
|
|
529
|
-
function defaultGetStore() {
|
|
732
|
+
function defaultGetStore(storePath) {
|
|
530
733
|
if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
|
|
531
|
-
return window.getStore()
|
|
734
|
+
return window.getStore(storePath)
|
|
532
735
|
}
|
|
533
736
|
return null
|
|
534
737
|
}
|
|
535
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
|
+
|
|
536
872
|
function cleanupPortFile(portFile) {
|
|
537
873
|
try {
|
|
538
874
|
fs.unlinkSync(portFile)
|
|
@@ -714,6 +1050,7 @@ module.exports = {
|
|
|
714
1050
|
DEFAULT_PORT,
|
|
715
1051
|
DEFAULT_PORT_FILE,
|
|
716
1052
|
DEFAULT_PLANNER_FILE,
|
|
1053
|
+
INPUT_BODY_LIMIT,
|
|
717
1054
|
extractEventData,
|
|
718
1055
|
extractMasterData,
|
|
719
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,104 @@
|
|
|
1
|
+
const DEFAULT_MAX_BASE64_LENGTH = 16 * 1024 * 1024
|
|
2
|
+
|
|
3
|
+
function createPoiScreenshotProvider(options = {}) {
|
|
4
|
+
const getStore = options.getStore || defaultGetStore
|
|
5
|
+
const ipcRenderer = options.ipcRenderer || require('electron').ipcRenderer
|
|
6
|
+
const devicePixelRatio = options.devicePixelRatio == null
|
|
7
|
+
? defaultDevicePixelRatio()
|
|
8
|
+
: options.devicePixelRatio
|
|
9
|
+
const now = options.now || (() => new Date())
|
|
10
|
+
const maxBase64Length = options.maxBase64Length == null
|
|
11
|
+
? DEFAULT_MAX_BASE64_LENGTH
|
|
12
|
+
: options.maxBase64Length
|
|
13
|
+
|
|
14
|
+
if (!Number.isFinite(devicePixelRatio) || devicePixelRatio <= 0) {
|
|
15
|
+
throw new Error('devicePixelRatio must be a positive finite number')
|
|
16
|
+
}
|
|
17
|
+
if (!Number.isInteger(maxBase64Length) || maxBase64Length <= 0) {
|
|
18
|
+
throw new Error('maxBase64Length must be a positive integer')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return async function capturePoiScreenshot() {
|
|
22
|
+
const layout = getStore('layout.webview')
|
|
23
|
+
if (
|
|
24
|
+
!layout ||
|
|
25
|
+
!layout.ref ||
|
|
26
|
+
typeof layout.ref.getWebContentsId !== 'function'
|
|
27
|
+
) {
|
|
28
|
+
throw new Error('Poi game WebView is not ready')
|
|
29
|
+
}
|
|
30
|
+
if (
|
|
31
|
+
!Number.isInteger(layout.width) ||
|
|
32
|
+
layout.width <= 0 ||
|
|
33
|
+
!Number.isInteger(layout.height) ||
|
|
34
|
+
layout.height <= 0
|
|
35
|
+
) {
|
|
36
|
+
throw new Error('Poi game WebView dimensions must be positive integers')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const webContentsId = layout.ref.getWebContentsId()
|
|
40
|
+
if (!Number.isInteger(webContentsId) || webContentsId <= 0) {
|
|
41
|
+
throw new Error('Poi game WebContents id is invalid')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rect = {
|
|
45
|
+
x: 0,
|
|
46
|
+
y: 0,
|
|
47
|
+
width: Math.floor(layout.width * devicePixelRatio),
|
|
48
|
+
height: Math.floor(layout.height * devicePixelRatio),
|
|
49
|
+
}
|
|
50
|
+
const actualSize = {
|
|
51
|
+
width: layout.width,
|
|
52
|
+
height: layout.height,
|
|
53
|
+
}
|
|
54
|
+
const dataUrl = await ipcRenderer.invoke(
|
|
55
|
+
'screenshot::get',
|
|
56
|
+
webContentsId,
|
|
57
|
+
rect,
|
|
58
|
+
actualSize,
|
|
59
|
+
)
|
|
60
|
+
const prefix = 'data:image/png;base64,'
|
|
61
|
+
if (typeof dataUrl !== 'string' || !dataUrl.startsWith(prefix)) {
|
|
62
|
+
throw new Error('Poi screenshot did not return a PNG data URL')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const dataBase64 = dataUrl.slice(prefix.length)
|
|
66
|
+
if (
|
|
67
|
+
dataBase64.length === 0 ||
|
|
68
|
+
!/^[A-Za-z0-9+/]+={0,2}$/.test(dataBase64)
|
|
69
|
+
) {
|
|
70
|
+
throw new Error('Poi screenshot returned invalid base64 data')
|
|
71
|
+
}
|
|
72
|
+
if (dataBase64.length > maxBase64Length) {
|
|
73
|
+
throw new Error(`Poi screenshot exceeds ${maxBase64Length} base64 characters`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
capturedAt: now().toISOString(),
|
|
78
|
+
mimeType: 'image/png',
|
|
79
|
+
dataBase64,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function defaultGetStore(path) {
|
|
85
|
+
if (typeof window !== 'undefined' && typeof window.getStore === 'function') {
|
|
86
|
+
return window.getStore(path)
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function defaultDevicePixelRatio() {
|
|
92
|
+
if (
|
|
93
|
+
typeof window !== 'undefined' &&
|
|
94
|
+
Number.isFinite(window.devicePixelRatio) &&
|
|
95
|
+
window.devicePixelRatio > 0
|
|
96
|
+
) {
|
|
97
|
+
return window.devicePixelRatio
|
|
98
|
+
}
|
|
99
|
+
return 1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
createPoiScreenshotProvider,
|
|
104
|
+
}
|
|
@@ -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, pageRow: 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 data bridge for local KanColle
|
|
3
|
+
"version": "0.2.9",
|
|
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
|
|
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
|
},
|