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.
@@ -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
+ }
@@ -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 take/renew are input-adjacent (they gate who may click); the
352
- // writer-token applies. Release is also gated for symmetry — leases
353
- // expire via TTL anyway, so a stale cleanup tool failing here is safe.
354
- const leaseWriter = authorizeWriterInput(req)
355
- if (!leaseWriter.ok) {
356
- drainRequest(req)
357
- sendInputJson(res, leaseWriter.status, { error: leaseWriter.error })
358
- return
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') {
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poi-plugin-mcp",
3
- "version": "0.2.26",
3
+ "version": "0.2.27",
4
4
  "description": "Poi data, WebView capture, and opt-in authenticated input bridge for local KanColle tools.",
5
5
  "main": "index.js",
6
6
  "keywords": [