poi-plugin-mcp 0.2.26 → 0.2.27
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/lib/pixi-hit-ledger.js +259 -0
- package/lib/poi-http-bridge.js +53 -9
- package/lib/writer-tokens.js +4 -1
- package/package.json +1 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-click PIXI hit ledger (0913 admiral ruling: every dispatched click must
|
|
5
|
+
* name the control it landed on).
|
|
6
|
+
*
|
|
7
|
+
* Evidence contract — this ledger is a WITNESS, never a judge:
|
|
8
|
+
* - The snapshot is read immediately BEFORE the synthetic input event is
|
|
9
|
+
* queued, so a tree mutation inside the dispatch window can still make
|
|
10
|
+
* it stale. The authoritative "did the click take effect" signal stays
|
|
11
|
+
* the matching API event plus the final-state audit.
|
|
12
|
+
* - `eventHit` comes from the game's own hit resolution (EventSystem /
|
|
13
|
+
* InteractionManager hitTest) when available; `containment` is a
|
|
14
|
+
* world-AABB approximation whose stack makes occlusion visible.
|
|
15
|
+
* - Failure is fail-soft: an unavailable probe records why and never
|
|
16
|
+
* blocks the dispatch.
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('node:fs')
|
|
19
|
+
const path = require('node:path')
|
|
20
|
+
|
|
21
|
+
const MAX_STACK = 8
|
|
22
|
+
const MAX_VISIT = 4000
|
|
23
|
+
const MAX_DEPTH = 40
|
|
24
|
+
// Wall-clock bound for the whole snapshot. The page-side script has its own
|
|
25
|
+
// 1500ms timeout, but a throttled renderer can stall the IPC channel until
|
|
26
|
+
// the runtime's 10s inspection bound — a witness must never gate dispatch.
|
|
27
|
+
const CAPTURE_WALL_MS = 1800
|
|
28
|
+
|
|
29
|
+
function buildHitTestScript(x, y) {
|
|
30
|
+
return `(() => {
|
|
31
|
+
const px = ${JSON.stringify(x)}, py = ${JSON.stringify(y)};
|
|
32
|
+
const shared = globalThis.PIXI && globalThis.PIXI.ticker && globalThis.PIXI.ticker.shared;
|
|
33
|
+
const head = shared && shared._head;
|
|
34
|
+
const renderers = [];
|
|
35
|
+
let node = head ? head.next : null;
|
|
36
|
+
let guard = 0;
|
|
37
|
+
while (node && guard < 64) {
|
|
38
|
+
if (node.context && node.context.renderer) renderers.push(node.context.renderer);
|
|
39
|
+
node = node.next;
|
|
40
|
+
guard += 1;
|
|
41
|
+
}
|
|
42
|
+
const renderer = renderers.find(
|
|
43
|
+
(r) => r && r.view && r.view.isConnected === true && r._lastObjectRendered,
|
|
44
|
+
);
|
|
45
|
+
if (!renderer) return { available: false, reason: 'stale_renderer' };
|
|
46
|
+
const describe = (o) => {
|
|
47
|
+
const record = {};
|
|
48
|
+
try { record.cls = (o.constructor && o.constructor.name) || null; } catch (_) { record.cls = null; }
|
|
49
|
+
try { record.name = typeof o.name === 'string' && o.name ? o.name : null; } catch (_) { record.name = null; }
|
|
50
|
+
try {
|
|
51
|
+
const tex = o.texture;
|
|
52
|
+
record.texture = tex && typeof tex.url === 'string' ? tex.url : null;
|
|
53
|
+
// Atlas forensics (0913 admiral request): the sprite's frame inside its
|
|
54
|
+
// source atlas sheet + the sheet URL let a hit record be mapped back to
|
|
55
|
+
// the exact rectangle of the ORIGINAL art for offline cropping/filter.
|
|
56
|
+
try {
|
|
57
|
+
const base = tex && tex.baseTexture;
|
|
58
|
+
const frame = tex && tex.frame;
|
|
59
|
+
record.atlas = {
|
|
60
|
+
url: base && typeof base.imageUrl === 'string' ? base.imageUrl : null,
|
|
61
|
+
frame: frame
|
|
62
|
+
? [
|
|
63
|
+
Math.round(frame.x), Math.round(frame.y),
|
|
64
|
+
Math.round(frame.width), Math.round(frame.height),
|
|
65
|
+
]
|
|
66
|
+
: null,
|
|
67
|
+
rotate: tex && typeof tex.rotate === 'number' ? tex.rotate : 0,
|
|
68
|
+
};
|
|
69
|
+
} catch (_) { record.atlas = null; }
|
|
70
|
+
} catch (_) { record.texture = null; record.atlas = null; }
|
|
71
|
+
try {
|
|
72
|
+
const b = o.getBounds ? o.getBounds() : null;
|
|
73
|
+
record.bounds = b
|
|
74
|
+
? [Math.round(b.x), Math.round(b.y), Math.round(b.width), Math.round(b.height)]
|
|
75
|
+
: null;
|
|
76
|
+
} catch (_) { record.bounds = null; }
|
|
77
|
+
try {
|
|
78
|
+
record.interactive = o.eventMode
|
|
79
|
+
? o.eventMode !== 'auto' && o.eventMode !== 'none' && o.eventMode !== 'passive'
|
|
80
|
+
: o.interactive === true;
|
|
81
|
+
} catch (_) { record.interactive = null; }
|
|
82
|
+
try { record.visible = o.visible !== false; } catch (_) { record.visible = null; }
|
|
83
|
+
try { record.alpha = typeof o.alpha === 'number' ? Math.round(o.alpha * 100) / 100 : null; } catch (_) { record.alpha = null; }
|
|
84
|
+
return record;
|
|
85
|
+
};
|
|
86
|
+
const chain = (o) => {
|
|
87
|
+
const path = [];
|
|
88
|
+
let cur = o;
|
|
89
|
+
let steps = 0;
|
|
90
|
+
while (cur && steps < 6) {
|
|
91
|
+
path.push((cur.constructor && cur.constructor.name) || 'unknown');
|
|
92
|
+
cur = cur.parent;
|
|
93
|
+
steps += 1;
|
|
94
|
+
}
|
|
95
|
+
return path;
|
|
96
|
+
};
|
|
97
|
+
// 1) The game's own hit resolution — interactive targets only.
|
|
98
|
+
let eventHit = null;
|
|
99
|
+
try {
|
|
100
|
+
const point = { x: px, y: py };
|
|
101
|
+
const events = renderer.events
|
|
102
|
+
|| (renderer.plugins && renderer.plugins.event)
|
|
103
|
+
|| (renderer.plugins && renderer.plugins.interaction);
|
|
104
|
+
if (events && typeof events.hitTest === 'function') {
|
|
105
|
+
const target = events.hitTest(point);
|
|
106
|
+
if (target) {
|
|
107
|
+
eventHit = describe(target);
|
|
108
|
+
eventHit.path = chain(target);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (_) { eventHit = null; }
|
|
112
|
+
// 2) World-AABB containment stack — approximate paint order (later entry
|
|
113
|
+
// ≈ on top within its branch); makes occlusion (family pages over stale
|
|
114
|
+
// choice pairs) visible in the record itself.
|
|
115
|
+
const stack = [];
|
|
116
|
+
let visited = 0;
|
|
117
|
+
const walk = (o, depth) => {
|
|
118
|
+
if (!o || depth > ${MAX_DEPTH} || visited > ${MAX_VISIT} || stack.length >= ${MAX_STACK}) return;
|
|
119
|
+
visited += 1;
|
|
120
|
+
try {
|
|
121
|
+
if (o.visible !== false && o.renderable !== false) {
|
|
122
|
+
const b = o.getBounds ? o.getBounds() : null;
|
|
123
|
+
if (
|
|
124
|
+
b && px >= b.x && px <= b.x + b.width && py >= b.y && py <= b.y + b.height
|
|
125
|
+
) {
|
|
126
|
+
stack.push(describe(o));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (_) { /* a node that cannot be described is skipped, not fatal */ }
|
|
130
|
+
if (stack.length >= ${MAX_STACK}) return;
|
|
131
|
+
const kids = o.children;
|
|
132
|
+
if (Array.isArray(kids)) {
|
|
133
|
+
for (let i = 0; i < kids.length; i += 1) walk(kids[i], depth + 1);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
try { walk(renderer._lastObjectRendered, 0); } catch (_) { /* partial stack is still evidence */ }
|
|
137
|
+
return {
|
|
138
|
+
available: true,
|
|
139
|
+
point: { x: px, y: py },
|
|
140
|
+
eventHit,
|
|
141
|
+
containmentCount: stack.length,
|
|
142
|
+
topmost: stack.length > 0 ? stack[stack.length - 1] : null,
|
|
143
|
+
stack,
|
|
144
|
+
};
|
|
145
|
+
})()`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createPixiHitLedger(options = {}) {
|
|
149
|
+
const evaluate = options.evaluate
|
|
150
|
+
const listFrames = options.listFrames
|
|
151
|
+
const logger = options.logger || console
|
|
152
|
+
const now = options.now || (() => new Date())
|
|
153
|
+
const captureWallMs = options.captureWallMs || CAPTURE_WALL_MS
|
|
154
|
+
const appendFile = options.appendFile
|
|
155
|
+
|| ((file, line) => fs.promises.appendFile(file, line, 'utf8'))
|
|
156
|
+
const ledgerFile = options.ledgerFile || null
|
|
157
|
+
let lastFrameId = null
|
|
158
|
+
let lastFrameLookupAt = 0
|
|
159
|
+
let ledgerWrite = Promise.resolve()
|
|
160
|
+
|
|
161
|
+
if (typeof evaluate !== 'function' || typeof listFrames !== 'function') {
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
async capture() { return Object.freeze({ available: false, reason: 'runtime_unavailable' }) },
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function resolveGameFrameId() {
|
|
168
|
+
const at = Date.now()
|
|
169
|
+
if (lastFrameId && at - lastFrameLookupAt < 2000) return lastFrameId
|
|
170
|
+
const frames = await listFrames()
|
|
171
|
+
const game = frames.find((f) => /kcs2/i.test(f.url || '')) || frames[0]
|
|
172
|
+
if (!game) return null
|
|
173
|
+
lastFrameId = game.id
|
|
174
|
+
lastFrameLookupAt = at
|
|
175
|
+
return game.id
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function appendToLedger(line) {
|
|
179
|
+
if (!ledgerFile) return
|
|
180
|
+
ledgerWrite = ledgerWrite
|
|
181
|
+
.then(() => appendFile(ledgerFile, line))
|
|
182
|
+
.catch((error) => {
|
|
183
|
+
logger.error(`[poi-plugin-mcp] hit ledger append failed: ${error.message}`)
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Snapshot what the live PIXI tree shows at the operation's canonical
|
|
189
|
+
* point. Never throws: an unavailable probe is a recorded outcome.
|
|
190
|
+
*/
|
|
191
|
+
async function capture(operation, context = {}) {
|
|
192
|
+
const capturedAt = now().toISOString()
|
|
193
|
+
const isClick = operation.operation === 'click'
|
|
194
|
+
const point = isClick
|
|
195
|
+
? { x: operation.x, y: operation.y }
|
|
196
|
+
: operation.operation === 'drag'
|
|
197
|
+
? { x: operation.fromX, y: operation.fromY }
|
|
198
|
+
: null
|
|
199
|
+
if (point === null) return Object.freeze({ available: false, reason: 'not_pointer' })
|
|
200
|
+
const record = {
|
|
201
|
+
at: capturedAt,
|
|
202
|
+
operation: operation.operation,
|
|
203
|
+
point,
|
|
204
|
+
...(context.leaseId === undefined ? {} : { leaseId: context.leaseId }),
|
|
205
|
+
...(context.ownerSessionId === undefined ? {} : { ownerSessionId: context.ownerSessionId }),
|
|
206
|
+
...(context.runId === undefined ? {} : { runId: context.runId }),
|
|
207
|
+
...(context.action === undefined ? {} : { action: context.action }),
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const frameId = await resolveGameFrameId()
|
|
211
|
+
if (frameId === null) {
|
|
212
|
+
return Object.freeze({ ...record, available: false, reason: 'game_frame_not_found' })
|
|
213
|
+
}
|
|
214
|
+
const response = await Promise.race([
|
|
215
|
+
evaluate({
|
|
216
|
+
frameId,
|
|
217
|
+
script: buildHitTestScript(point.x, point.y),
|
|
218
|
+
timeoutMs: 1500,
|
|
219
|
+
}),
|
|
220
|
+
new Promise((_, reject) => {
|
|
221
|
+
setTimeout(
|
|
222
|
+
() => reject(new Error('capture_wall_timeout')),
|
|
223
|
+
captureWallMs,
|
|
224
|
+
)
|
|
225
|
+
}),
|
|
226
|
+
])
|
|
227
|
+
const value = response && response.value
|
|
228
|
+
// Every outcome is appended — a witness with gaps is worse than a
|
|
229
|
+
// witness with anomalies (a probe that ran but returned nothing
|
|
230
|
+
// non-object is itself diagnostic evidence).
|
|
231
|
+
const hit = value === null || typeof value !== 'object'
|
|
232
|
+
? { ...record, available: false, reason: 'probe_empty' }
|
|
233
|
+
: { ...record, ...value }
|
|
234
|
+
appendToLedger(`${JSON.stringify(hit)}\n`)
|
|
235
|
+
return Object.freeze(hit)
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const hit = { ...record, available: false, reason: `probe_failed:${error.message}` }
|
|
238
|
+
appendToLedger(`${JSON.stringify(hit)}\n`)
|
|
239
|
+
return Object.freeze(hit)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return Object.freeze({ capture })
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function defaultLedgerFile(portFile) {
|
|
247
|
+
if (!portFile || typeof portFile !== 'string') return null
|
|
248
|
+
try {
|
|
249
|
+
return path.join(path.dirname(portFile), 'hit-ledger.jsonl')
|
|
250
|
+
} catch (_) {
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
buildHitTestScript,
|
|
257
|
+
createPixiHitLedger,
|
|
258
|
+
defaultLedgerFile,
|
|
259
|
+
}
|
package/lib/poi-http-bridge.js
CHANGED
|
@@ -20,6 +20,10 @@ const {
|
|
|
20
20
|
speedFromRaw,
|
|
21
21
|
speedMeaning,
|
|
22
22
|
} = require('./fleet-metrics')
|
|
23
|
+
const {
|
|
24
|
+
createPixiHitLedger,
|
|
25
|
+
defaultLedgerFile,
|
|
26
|
+
} = require('./pixi-hit-ledger')
|
|
23
27
|
|
|
24
28
|
const DEFAULT_PORT = 17777
|
|
25
29
|
const DEFAULT_PORT_FILE = path.join(os.homedir(), '.poi-mcp', 'port')
|
|
@@ -148,7 +152,7 @@ function createPoiDataBridge(options = {}) {
|
|
|
148
152
|
'through the daemon (kc daemon submit) or renew the token.',
|
|
149
153
|
}
|
|
150
154
|
}
|
|
151
|
-
writerTokens.noteLegacyAuth()
|
|
155
|
+
writerTokens.noteLegacyAuth(Date.now(), `${req.method} ${req.url}`)
|
|
152
156
|
return { ok: true, legacy: true }
|
|
153
157
|
}
|
|
154
158
|
|
|
@@ -196,6 +200,19 @@ function createPoiDataBridge(options = {}) {
|
|
|
196
200
|
return dataQuery
|
|
197
201
|
}
|
|
198
202
|
|
|
203
|
+
let hitLedger = null
|
|
204
|
+
function currentHitLedger() {
|
|
205
|
+
if (!hitLedger) {
|
|
206
|
+
hitLedger = createPixiHitLedger({
|
|
207
|
+
evaluate: (request) => currentDataQuery().runtime.evaluate(request),
|
|
208
|
+
listFrames: () => currentDataQuery().runtime.listFrames(),
|
|
209
|
+
ledgerFile: defaultLedgerFile(portFile),
|
|
210
|
+
logger,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
return hitLedger
|
|
214
|
+
}
|
|
215
|
+
|
|
199
216
|
async function handleDataRequest(req, res, endpoint) {
|
|
200
217
|
if (req.method !== 'POST') {
|
|
201
218
|
drainRequest(req)
|
|
@@ -265,10 +282,28 @@ function createPoiDataBridge(options = {}) {
|
|
|
265
282
|
if (!performInput) {
|
|
266
283
|
performInput = createPoiInputProvider({ getStore })
|
|
267
284
|
}
|
|
285
|
+
// 0913 per-click hit ledger (admiral ruling): snapshot what the live
|
|
286
|
+
// PIXI tree shows at the pointer before dispatch, gated by the same
|
|
287
|
+
// debugEvalEnabled setting that owns WebView evaluation. Evidence
|
|
288
|
+
// only — the response field and ~/.poi-mcp/hit-ledger.jsonl never
|
|
289
|
+
// gate the dispatch itself (capture is fail-soft and wall-bounded).
|
|
290
|
+
let hit
|
|
291
|
+
if (
|
|
292
|
+
debugEvalEnabled === true &&
|
|
293
|
+
(operation.operation === 'click' || operation.operation === 'drag')
|
|
294
|
+
) {
|
|
295
|
+
hit = await currentHitLedger().capture(operation, {
|
|
296
|
+
leaseId: claim.leaseId,
|
|
297
|
+
ownerSessionId: claim.ownerSessionId,
|
|
298
|
+
runId: claim.runId,
|
|
299
|
+
action: claim.action,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
268
302
|
const operationName = await performInput(operation)
|
|
269
303
|
return {
|
|
270
304
|
ok: true,
|
|
271
305
|
operation: operationName,
|
|
306
|
+
...(hit === undefined ? {} : { hit }),
|
|
272
307
|
sequence: claim.sequence,
|
|
273
308
|
leaseId: claim.leaseId,
|
|
274
309
|
ownerSessionId: claim.ownerSessionId,
|
|
@@ -348,14 +383,23 @@ function createPoiDataBridge(options = {}) {
|
|
|
348
383
|
)
|
|
349
384
|
return
|
|
350
385
|
}
|
|
351
|
-
// Lease
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
386
|
+
// Lease MUTATIONS (acquire/renew/release/revoke) are input-adjacent —
|
|
387
|
+
// they gate who may click — so the writer-token applies. The plain
|
|
388
|
+
// GET /input/lease status read stays static-bearer (live 0912: the
|
|
389
|
+
// dashboard's takeover-button poll flooded the legacy counter and would
|
|
390
|
+
// 403 under enforcement; reads must not take the writer gate).
|
|
391
|
+
const leaseMutation =
|
|
392
|
+
endpoint === '/input/lease/acquire' ||
|
|
393
|
+
endpoint === '/input/lease/renew' ||
|
|
394
|
+
endpoint === '/input/lease/release' ||
|
|
395
|
+
endpoint === '/input/lease/revoke'
|
|
396
|
+
if (leaseMutation) {
|
|
397
|
+
const leaseWriter = authorizeWriterInput(req)
|
|
398
|
+
if (!leaseWriter.ok) {
|
|
399
|
+
drainRequest(req)
|
|
400
|
+
sendInputJson(res, leaseWriter.status, { error: leaseWriter.error })
|
|
401
|
+
return
|
|
402
|
+
}
|
|
359
403
|
}
|
|
360
404
|
if (endpoint === '/input/lease') {
|
|
361
405
|
if (req.method !== 'GET') {
|
package/lib/writer-tokens.js
CHANGED
|
@@ -25,6 +25,7 @@ function createWriterTokenRegistry() {
|
|
|
25
25
|
const entries = new Map() // token -> { label, registeredAt, expiresAt }
|
|
26
26
|
let legacyAuthCount = 0
|
|
27
27
|
let lastLegacyAuthAt = null
|
|
28
|
+
let lastLegacyAuthDetail = null
|
|
28
29
|
|
|
29
30
|
function sweep(now = Date.now()) {
|
|
30
31
|
for (const [token, entry] of entries) {
|
|
@@ -79,9 +80,10 @@ function createWriterTokenRegistry() {
|
|
|
79
80
|
return true
|
|
80
81
|
},
|
|
81
82
|
|
|
82
|
-
noteLegacyAuth(now = Date.now()) {
|
|
83
|
+
noteLegacyAuth(now = Date.now(), detail = null) {
|
|
83
84
|
legacyAuthCount += 1
|
|
84
85
|
lastLegacyAuthAt = new Date(now).toISOString()
|
|
86
|
+
lastLegacyAuthDetail = typeof detail === 'string' && detail !== '' ? detail.slice(0, 160) : null
|
|
85
87
|
},
|
|
86
88
|
|
|
87
89
|
status(now = Date.now()) {
|
|
@@ -95,6 +97,7 @@ function createWriterTokenRegistry() {
|
|
|
95
97
|
})),
|
|
96
98
|
legacyAuthCount,
|
|
97
99
|
lastLegacyAuthAt,
|
|
100
|
+
lastLegacyAuthDetail,
|
|
98
101
|
}
|
|
99
102
|
},
|
|
100
103
|
}
|