poi-plugin-mcp 0.2.12 → 0.2.14

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 CHANGED
@@ -1,144 +1,201 @@
1
1
  # poi-plugin-mcp
2
2
 
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.
3
+ `poi-plugin-mcp` 是运行在 Poi 内的本地数据桥。它把当前舰队、舰娘、装备、
4
+ 资源、任务、陆航和相关主数据通过本机 HTTP MCP 提供给本机工具。
6
5
 
7
- ## Install
6
+ 默认监听:
8
7
 
9
- Install it from Poi's plugin manager as:
8
+ ```none
9
+ http://127.0.0.1:17777
10
+ ```
11
+
12
+ 服务只绑定本机回环地址 `127.0.0.1`,不对局域网或公网监听。本文介绍用于
13
+ 读取 Poi 状态的 MCP 工具、MCP 资源和 HTTP 数据端点。
14
+
15
+ ## 安装与重启
16
+
17
+ 在 Poi 插件管理器中安装 npm 包:
10
18
 
11
- ```text
19
+ ```none
12
20
  poi-plugin-mcp
13
21
  ```
14
22
 
15
- Or install it into Poi's plugin directory with npm:
23
+ 也可以完全退出 Poi 后,在 Poi 插件目录中手动安装:
16
24
 
17
25
  ```powershell
18
26
  cd "$env:APPDATA\poi\plugins"
19
27
  npm install poi-plugin-mcp
20
28
  ```
21
29
 
22
- Then restart Poi and enable `MCP Data Bridge` if needed.
30
+ 安装或更新后完整退出并重新启动 Poi。在插件设置中确认“`MCP 数据桥`”已启用,
31
+ 然后进入游戏;需要读取账号数据的端点要等 Poi store 初始化完成。
32
+
33
+ 如果 MCP 客户端要使用 stdio 方式,可另外全局安装同一个 npm 包,使
34
+ `poi-mcp` 命令进入 `PATH`:
35
+
36
+ ```powershell
37
+ npm install -g poi-plugin-mcp
38
+ Get-Command poi-mcp
39
+ ```
23
40
 
24
- ## Development Install
41
+ ## 健康检查与端口发现
25
42
 
26
- When working from this repository, install from the repo root:
43
+ 默认端口的检查命令:
27
44
 
28
45
  ```powershell
29
- npm install
30
- npm run install:poi
46
+ Invoke-RestMethod http://127.0.0.1:17777/health
31
47
  ```
32
48
 
33
- Then restart Poi and enable `MCP Data Bridge` if needed.
49
+ 正常响应:
50
+
51
+ ```json
52
+ {"status":"ok"}
53
+ ```
34
54
 
35
- The installer creates:
55
+ 插件启动后会把实际端口写入:
36
56
 
37
- ```text
38
- %APPDATA%\poi\plugins\node_modules\poi-plugin-mcp -> <repo>\packages\poi-plugin-mcp
57
+ ```none
58
+ %USERPROFILE%\.poi-mcp\port
39
59
  ```
40
60
 
41
- ## Settings
61
+ 修改过端口时,应以该文件为准:
42
62
 
43
- The Poi plugin settings panel supports:
63
+ ```powershell
64
+ $port = (Get-Content "$HOME\.poi-mcp\port" -Raw).Trim()
65
+ Invoke-RestMethod "http://127.0.0.1:$port/health"
66
+ ```
44
67
 
45
- - changing the HTTP port, saved in `~/.poi-mcp/settings.json`
46
- - manually starting and stopping the local bridge
47
- - enabling WebView input, which defaults to off
68
+ 根路径 `/` 返回 404 不代表服务故障,请使用 `/health`。
48
69
 
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.
70
+ ## MCP 接入
52
71
 
53
- ## HTTP Endpoints
72
+ 插件提供两种 MCP 连接方式:
54
73
 
55
- | Endpoint | Data |
56
- |---|---|
57
- | `/health` | Bridge status |
58
- | `/basic` | Admiral profile |
59
- | `/fleets` | Raw Poi fleet data |
60
- | `/ships` | Raw owned ship instance data |
61
- | `/equipment` | Raw owned equipment instance data |
62
- | `/resources` | Resource array |
63
- | `/quests` | Active quests and quest records |
64
- | `/quest-list` | Latest complete quest-page telemetry |
65
- | `/quest-action` | Latest successful quest start or stop request |
66
- | `/equipment-action` | Latest successful equipment mutation request |
67
- | `/airbase` | Land base air squadron data |
68
- | `/names` | Ship, equipment, and mission name maps |
69
- | `/master` | Master ship, equipment, ship type, equipment type, mission, and bounded equipment compatibility data |
70
- | `/event` | Event ship tag definitions plus owned ships' current sally area |
71
- | `/planner` | Ship Info deck planner areas and ship assignments |
72
- | `/battle` | Observed battle packets, official settlement, and compact Prophet state |
73
- | `/screenshot` | In-memory PNG capture of the game WebView |
74
- | `/input/status` | Whether authenticated WebView input is enabled |
75
- | `/input` | Authenticated, serialized WebView input |
76
- | `/all` | Combined basic runtime data |
77
-
78
- `/screenshot` accepts `GET` only. It uses Poi's existing
79
- `screenshot::get` WebContents capture path and returns PNG base64 in JSON. It
80
- does not save a file, write the clipboard, capture the desktop, or appear in
81
- MCP resources and tools. Its response disables CORS and uses
82
- `Cache-Control: no-store`.
83
-
84
- ### WebView Input
85
-
86
- `GET /input/status` returns only `{"enabled":true|false}`. `POST /input`
87
- requires `Authorization: Bearer <token>`, accepts at most 64 KiB of JSON, and
88
- works only while WebView input is enabled in the plugin settings. Both routes
89
- disable CORS and return `Cache-Control: no-store`.
90
-
91
- Each POST accepts exactly one operation:
74
+ - **HTTP MCP**:客户端直接连接 Poi 内的 `POST /mcp`。
75
+ - **stdio MCP**:客户端启动 npm 安装的 `poi-mcp` 命令;该命令读取端口文件,
76
+ 再访问 Poi 的本地数据 API。
92
77
 
93
- ```json
94
- {"operation":"click","x":600,"y":360,"button":"left"}
78
+ 两种方式都要求 Poi 正在运行且插件已加载。
79
+
80
+ ### HTTP MCP
81
+
82
+ MCP URL:
83
+
84
+ ```none
85
+ http://127.0.0.1:17777/mcp
95
86
  ```
96
87
 
97
- Click coordinates use a canonical 1200x720 layout and are scaled to Poi's
98
- current game WebView. Supported buttons are `left`, `middle`, and `right`.
88
+ 这是 JSON-RPC over HTTP 端点,请由 MCP 客户端向它发送 `POST` 请求。Codex
89
+ 配置示例:
99
90
 
100
- ```json
101
- {"operation":"key","event":"keyDown","key":"Enter"}
91
+ ```toml
92
+ [mcp_servers.poi]
93
+ url = "http://127.0.0.1:17777/mcp"
102
94
  ```
103
95
 
104
- Key events are `keyDown` or `keyUp`. Supported keys are `Backspace`, `Delete`,
105
- `End`, `Enter`, `Escape`, `Home`, `PageDown`, `PageUp`, `Space`, `Tab`, and the
106
- four arrow keys.
96
+ 使用 JSON 配置的 MCP 客户端可采用:
107
97
 
108
98
  ```json
109
- {"operation":"text","text":"literal text"}
99
+ {
100
+ "mcpServers": {
101
+ "poi": {
102
+ "url": "http://127.0.0.1:17777/mcp"
103
+ }
104
+ }
105
+ }
110
106
  ```
111
107
 
112
- Literal text must contain 1-256 printable characters. Successful requests
113
- return a bounded response such as
114
- `{"ok":true,"operation":"text","sequence":1}`. Complete operations execute in
115
- sequence, so concurrent requests cannot interleave their WebView events.
108
+ 若修改过端口,请把 URL 中的 `17777` 替换为端口文件中的值。
116
109
 
117
- ## MCP Endpoint
110
+ ### stdio CLI
118
111
 
119
- The same local server also exposes a JSON-RPC MCP endpoint:
112
+ `poi-mcp` MCP stdio 服务命令,不是交互式终端程序。MCP 客户端应负责启动
113
+ 并通过标准输入输出与它通信。
120
114
 
121
- ```text
122
- POST http://127.0.0.1:17777/mcp
115
+ Codex 配置示例:
116
+
117
+ ```toml
118
+ [mcp_servers.poi]
119
+ command = "poi-mcp"
120
+ args = []
123
121
  ```
124
122
 
125
- It supports `initialize`, `ping`, `resources/list`, `resources/read`,
126
- `tools/list`, and `tools/call`. Resource URIs mirror the HTTP endpoints,
127
- including `poi://ships`, `poi://equipment`, `poi://resources`, `poi://master`,
128
- `poi://event`, and `poi://planner`.
123
+ 通用 JSON 配置示例:
129
124
 
130
- Available tools:
125
+ ```json
126
+ {
127
+ "mcpServers": {
128
+ "poi": {
129
+ "command": "poi-mcp",
130
+ "args": []
131
+ }
132
+ }
133
+ }
134
+ ```
131
135
 
132
- - `get_fleet_status`
133
- - `search_ships`
134
- - `search_equipment`
135
- - `get_resources`
136
- - `get_all`
136
+ 如果桌面客户端找不到 npm 的全局命令,请先运行 `Get-Command poi-mcp`,再把
137
+ 配置中的 `command` 改为返回的完整可执行文件路径。
137
138
 
138
- ## Verification
139
+ ## MCP 工具
139
140
 
140
- From the repo root:
141
+ | 工具 | 用途 |
142
+ |---|---|
143
+ | `get_fleet_status` | 按 `fleetId`(1-4)读取一支舰队及舰娘、装备实例 |
144
+ | `search_ships` | 按可选的 `minLevel`、`maxLevel`、`minMorale` 筛选持有舰娘 |
145
+ | `search_equipment` | 按可选的 `minLevel` 筛选持有装备 |
146
+ | `get_resources` | 读取当前资源数组 |
147
+ | `get_all` | 读取基础汇总;`include` 可选 `master`、`event`、`planner` |
148
+
149
+ ## MCP 资源
150
+
151
+ 可通过 `resources/list` 和 `resources/read` 读取:
152
+
153
+ ```none
154
+ poi://basic
155
+ poi://fleets
156
+ poi://ships
157
+ poi://equipment
158
+ poi://resources
159
+ poi://quests
160
+ poi://airbase
161
+ poi://names
162
+ poi://master
163
+ poi://event
164
+ poi://planner
165
+ poi://all
166
+ ```
167
+
168
+ ## 数据端点
169
+
170
+ 主要的本地 HTTP 数据查询端点:
171
+
172
+ | 端点 | 内容 |
173
+ |---|---|
174
+ | `GET /health` | bridge 健康状态 |
175
+ | `GET /basic` | 提督基础信息 |
176
+ | `GET /fleets` | 舰队数据 |
177
+ | `GET /ships` | 持有舰娘实例 |
178
+ | `GET /equipment` | 持有装备实例 |
179
+ | `GET /resources` | 当前资源 |
180
+ | `GET /quests` | 当前任务与任务记录 |
181
+ | `GET /airbase` | 基地航空队 |
182
+ | `GET /names` | 舰娘、装备和远征名称映射 |
183
+ | `GET /master` | 舰娘、装备、类型、远征等主数据 |
184
+ | `GET /event` | 活动标签定义与持有舰娘出击标签 |
185
+ | `GET /planner` | Ship Info 配装规划数据 |
186
+ | `GET /battle` | 已观测战斗数据、结算与简化战斗状态 |
187
+ | `GET /all` | 基础运行数据汇总 |
188
+ | `POST /mcp` | HTTP MCP JSON-RPC 入口 |
189
+
190
+ ## 开发验证
191
+
192
+ 在源码目录执行:
141
193
 
142
194
  ```powershell
143
- npm run test:plugin
195
+ npm install
196
+ npm test
197
+ npm pack --dry-run --ignore-scripts
144
198
  ```
199
+
200
+ `npm test` 应运行 `node --test test/*.test.js`;打包预览应包含
201
+ `index.js`、`lib`、`mcp-server.js` 和本 README。
package/index.js CHANGED
@@ -7,7 +7,9 @@ const controller = createBridgeController({
7
7
  getQuestList: telemetry.getQuestList,
8
8
  getQuestAction: telemetry.getQuestAction,
9
9
  getEquipmentAction: telemetry.getEquipmentAction,
10
+ getUnsetSlot: telemetry.getUnsetSlot,
10
11
  getFleetAction: telemetry.getFleetAction,
12
+ getActionEvents: telemetry.getActionEvents,
11
13
  getBattleTelemetry: telemetry.getBattleTelemetry,
12
14
  })
13
15
  const settingsClass = createSettingsClass(controller)
@@ -33,7 +33,9 @@ function createBridgeController(options = {}) {
33
33
  getQuestList: options.getQuestList,
34
34
  getQuestAction: options.getQuestAction,
35
35
  getEquipmentAction: options.getEquipmentAction,
36
+ getUnsetSlot: options.getUnsetSlot,
36
37
  getFleetAction: options.getFleetAction,
38
+ getActionEvents: options.getActionEvents,
37
39
  getBattleTelemetry: options.getBattleTelemetry,
38
40
  inputEnabled: settings.inputEnabled,
39
41
  inputToken,
@@ -0,0 +1,290 @@
1
+ const crypto = require('node:crypto')
2
+
3
+ const DEFAULT_CAPACITY = 256
4
+ const DEFAULT_LIMIT = 64
5
+ const MAX_CAPACITY = 256
6
+ const MAX_OBJECT_KEYS = 32
7
+ const MAX_ARRAY_ITEMS = 16
8
+ const MAX_STRING_LENGTH = 256
9
+ const MAX_RESPONSE_DEPTH = 4
10
+
11
+ const EXACT_PATHS = new Set([
12
+ '/kcsapi/api_start2/getData',
13
+ '/kcsapi/api_port/port',
14
+ '/kcsapi/api_get_member/base_air_corps',
15
+ '/kcsapi/api_get_member/deck',
16
+ '/kcsapi/api_get_member/kdock',
17
+ '/kcsapi/api_get_member/mapinfo',
18
+ '/kcsapi/api_get_member/material',
19
+ '/kcsapi/api_get_member/ndock',
20
+ '/kcsapi/api_get_member/practice',
21
+ '/kcsapi/api_get_member/questlist',
22
+ '/kcsapi/api_get_member/ship2',
23
+ '/kcsapi/api_get_member/ship3',
24
+ '/kcsapi/api_get_member/slot_item',
25
+ '/kcsapi/api_get_member/unsetslot',
26
+ '/kcsapi/api_get_member/useitem',
27
+ '/kcsapi/api_req_air_corps/set_action',
28
+ '/kcsapi/api_req_air_corps/set_plane',
29
+ '/kcsapi/api_req_air_corps/supply',
30
+ '/kcsapi/api_req_battle_midnight/battle',
31
+ '/kcsapi/api_req_battle_midnight/sp_midnight',
32
+ '/kcsapi/api_req_hensei/change',
33
+ '/kcsapi/api_req_hensei/combined',
34
+ '/kcsapi/api_req_hensei/preset_select',
35
+ '/kcsapi/api_req_hokyu/charge',
36
+ '/kcsapi/api_req_kaisou/powerup',
37
+ '/kcsapi/api_req_kaisou/slot_deprive',
38
+ '/kcsapi/api_req_kaisou/slotset',
39
+ '/kcsapi/api_req_kaisou/slotset_ex',
40
+ '/kcsapi/api_req_kaisou/unsetslot_all',
41
+ '/kcsapi/api_req_kousyou/createitem',
42
+ '/kcsapi/api_req_kousyou/createship',
43
+ '/kcsapi/api_req_kousyou/createship_speedchange',
44
+ '/kcsapi/api_req_kousyou/destroyitem2',
45
+ '/kcsapi/api_req_kousyou/destroyship',
46
+ '/kcsapi/api_req_kousyou/getship',
47
+ '/kcsapi/api_req_kousyou/remodel_slot',
48
+ '/kcsapi/api_req_map/next',
49
+ '/kcsapi/api_req_map/select_eventmap_rank',
50
+ '/kcsapi/api_req_map/start',
51
+ '/kcsapi/api_req_member/get_practice_enemyinfo',
52
+ '/kcsapi/api_req_mission/result',
53
+ '/kcsapi/api_req_mission/start',
54
+ '/kcsapi/api_req_nyukyo/speedchange',
55
+ '/kcsapi/api_req_nyukyo/start',
56
+ '/kcsapi/api_req_practice/battle',
57
+ '/kcsapi/api_req_practice/battle_result',
58
+ '/kcsapi/api_req_practice/midnight_battle',
59
+ '/kcsapi/api_req_quest/clearitemget',
60
+ '/kcsapi/api_req_quest/start',
61
+ '/kcsapi/api_req_quest/stop',
62
+ ])
63
+
64
+ const ALLOWED_PREFIXES = [
65
+ '/kcsapi/api_req_sortie/',
66
+ '/kcsapi/api_req_combined_battle/',
67
+ ]
68
+
69
+ const SHARED_TELEMETRY_TIMESTAMP_PATHS = new Set([
70
+ '/kcsapi/api_get_member/questlist',
71
+ '/kcsapi/api_get_member/unsetslot',
72
+ '/kcsapi/api_req_quest/start',
73
+ '/kcsapi/api_req_quest/stop',
74
+ '/kcsapi/api_req_kaisou/slotset',
75
+ '/kcsapi/api_req_kaisou/slotset_ex',
76
+ '/kcsapi/api_req_kaisou/slot_deprive',
77
+ '/kcsapi/api_req_kaisou/unsetslot_all',
78
+ '/kcsapi/api_req_hensei/change',
79
+ ])
80
+
81
+ function createPoiActionEvents(options = {}) {
82
+ const now = options.now || (() => new Date())
83
+ const inferredNow = options.inferredNow || (() => new Date())
84
+ const sessionId = boundedId(options.sessionId || crypto.randomUUID(), 'sessionId')
85
+ const capacity = boundedInteger(
86
+ options.capacity == null ? DEFAULT_CAPACITY : options.capacity,
87
+ 1,
88
+ MAX_CAPACITY,
89
+ 'capacity',
90
+ )
91
+ const events = []
92
+ let latestGeneration = 0
93
+
94
+ function capture(detail) {
95
+ if (!isCapturableDetail(detail)) return null
96
+ const explicitApiResult = readApiResult(detail)
97
+ const apiResult = explicitApiResult == null && hasUnwrappedResponseBody(detail)
98
+ ? 1
99
+ : explicitApiResult
100
+ if (apiResult !== 1) return null
101
+
102
+ latestGeneration += 1
103
+ const eventNow = explicitApiResult != null ||
104
+ SHARED_TELEMETRY_TIMESTAMP_PATHS.has(detail.path)
105
+ ? now
106
+ : inferredNow
107
+ const event = Object.freeze({
108
+ generation: latestGeneration,
109
+ capturedAt: timestamp(eventNow()),
110
+ path: detail.path,
111
+ apiResult,
112
+ postBody: sanitizePostBody(detail.postBody),
113
+ responseSummary: sanitizeResponse(detail.body),
114
+ })
115
+ events.push(event)
116
+ if (events.length > capacity) events.splice(0, events.length - capacity)
117
+ return event
118
+ }
119
+
120
+ function read(options = {}) {
121
+ const after = boundedInteger(
122
+ options.after == null ? 0 : options.after,
123
+ 0,
124
+ Number.MAX_SAFE_INTEGER,
125
+ 'after',
126
+ )
127
+ const limit = boundedInteger(
128
+ options.limit == null ? DEFAULT_LIMIT : options.limit,
129
+ 1,
130
+ MAX_CAPACITY,
131
+ 'limit',
132
+ )
133
+ return {
134
+ available: true,
135
+ sessionId,
136
+ latestGeneration,
137
+ events: events
138
+ .filter((event) => event.generation > after)
139
+ .slice(0, limit),
140
+ }
141
+ }
142
+
143
+ return Object.freeze({ capture, read })
144
+ }
145
+
146
+ function isCapturableDetail(detail) {
147
+ return Boolean(
148
+ detail &&
149
+ typeof detail === 'object' &&
150
+ !Array.isArray(detail) &&
151
+ typeof detail.path === 'string' &&
152
+ (
153
+ EXACT_PATHS.has(detail.path) ||
154
+ ALLOWED_PREFIXES.some((prefix) => detail.path.startsWith(prefix))
155
+ ),
156
+ )
157
+ }
158
+
159
+ function readApiResult(detail) {
160
+ const candidates = [
161
+ detail.apiResult,
162
+ detail.api_result,
163
+ detail.result,
164
+ detail.body && detail.body.api_result,
165
+ ]
166
+ for (const candidate of candidates) {
167
+ if (candidate == null || candidate === '') continue
168
+ const parsed = Number(candidate)
169
+ return Number.isInteger(parsed) ? parsed : null
170
+ }
171
+ return null
172
+ }
173
+
174
+ function hasUnwrappedResponseBody(detail) {
175
+ if (!Object.prototype.hasOwnProperty.call(detail, 'body')) return false
176
+ const body = detail.body
177
+ if (!isPlainObject(body) && !Array.isArray(body)) return false
178
+ return !(
179
+ isPlainObject(body) &&
180
+ (
181
+ Object.prototype.hasOwnProperty.call(body, 'api_result') ||
182
+ Object.prototype.hasOwnProperty.call(body, 'api_data')
183
+ )
184
+ )
185
+ }
186
+
187
+ function sanitizePostBody(value) {
188
+ if (!isPlainObject(value)) return {}
189
+ const output = {}
190
+ for (const [key, item] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
191
+ if (!safeKey(key)) continue
192
+ const scalar = sanitizeScalar(item)
193
+ if (scalar !== undefined) {
194
+ output[key] = scalar
195
+ continue
196
+ }
197
+ if (
198
+ Array.isArray(item) &&
199
+ item.length <= MAX_ARRAY_ITEMS
200
+ ) {
201
+ const array = item.map(sanitizeScalar)
202
+ if (array.every((entry) => entry !== undefined)) output[key] = array
203
+ }
204
+ }
205
+ return output
206
+ }
207
+
208
+ function sanitizeResponse(value) {
209
+ const sanitized = sanitizeNested(value, 0)
210
+ return isPlainObject(sanitized) ? sanitized : {}
211
+ }
212
+
213
+ function sanitizeNested(value, depth) {
214
+ const scalar = sanitizeScalar(value)
215
+ if (scalar !== undefined) return scalar
216
+ if (depth >= MAX_RESPONSE_DEPTH) return undefined
217
+ if (Array.isArray(value)) {
218
+ return value
219
+ .slice(0, MAX_ARRAY_ITEMS)
220
+ .map((item) => sanitizeNested(item, depth + 1))
221
+ .filter((item) => item !== undefined)
222
+ }
223
+ if (!isPlainObject(value)) return undefined
224
+ const output = {}
225
+ for (const [key, item] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
226
+ if (!safeKey(key)) continue
227
+ const sanitized = sanitizeNested(item, depth + 1)
228
+ if (sanitized !== undefined) output[key] = sanitized
229
+ }
230
+ return output
231
+ }
232
+
233
+ function sanitizeScalar(value) {
234
+ if (
235
+ value === null ||
236
+ typeof value === 'boolean' ||
237
+ (typeof value === 'number' && Number.isFinite(value))
238
+ ) {
239
+ return value
240
+ }
241
+ if (typeof value === 'string') return value.slice(0, MAX_STRING_LENGTH)
242
+ return undefined
243
+ }
244
+
245
+ function safeKey(key) {
246
+ return (
247
+ typeof key === 'string' &&
248
+ key.length > 0 &&
249
+ key.length <= 64 &&
250
+ !/(authorization|cookie|header|password|secret|token)/i.test(key)
251
+ )
252
+ }
253
+
254
+ function timestamp(value) {
255
+ const date = value instanceof Date ? value : new Date(value)
256
+ if (!Number.isFinite(date.getTime())) throw new Error('now must return a valid date')
257
+ return date.toISOString()
258
+ }
259
+
260
+ function boundedId(value, name) {
261
+ if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
262
+ throw new Error(`${name} must be a non-empty bounded string`)
263
+ }
264
+ return value
265
+ }
266
+
267
+ function boundedInteger(value, minimum, maximum, name) {
268
+ if (
269
+ !Number.isSafeInteger(value) ||
270
+ value < minimum ||
271
+ value > maximum
272
+ ) {
273
+ throw new Error(
274
+ `${name} must be an integer from ${minimum} through ${maximum}`,
275
+ )
276
+ }
277
+ return value
278
+ }
279
+
280
+ function isPlainObject(value) {
281
+ return Boolean(
282
+ value &&
283
+ typeof value === 'object' &&
284
+ !Array.isArray(value),
285
+ )
286
+ }
287
+
288
+ module.exports = {
289
+ createPoiActionEvents,
290
+ }