@rithien/comfy_adapter 0.1.0

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 ADDED
@@ -0,0 +1,49 @@
1
+ # `@rithien/comfy_adapter` — clusterio plugin
2
+
3
+ Adapter między tag-stream stdout scenariusza `factorio-polska` (`../scenario/`) a clusterio bus + Discord (przez współistniejący `../discord-bridge/`).
4
+
5
+ ## Stan
6
+
7
+ **Faza 2.5 ukończona (statyczna walidacja)** — `v0.1.0` zaimplementowane, nieopublikowane. Publish + runtime test na zdalnym clusterio → Faza 2.7.
8
+
9
+ ## Zakres
10
+
11
+ | Tag z scenariusza | Akcja w plugin'ie |
12
+ | --- | --- |
13
+ | `[DISCORD]<msg>` / `[-RAW]` / `[-BOLD]` | `channel.send({content})` na kanał instancji (przez `discord_bridge.getChannelForInstance`) |
14
+ | `[DISCORD-ADMIN]<msg>` / `[-RAW]` | jak wyżej, z prefiksem `[ADMIN] ` (configurable) |
15
+ | `[DISCORD-EMBED]<msg>` / `[-RAW]` | embed z `description=msg` |
16
+ | `[DISCORD-EMBED-PARSED]<json>` | JSON.parse → embed z `{title, description, color?, fields?}` |
17
+ | `[DISCORD-ADMIN-EMBED]<msg>` / `[-RAW]` | embed z `description=admin_prefix+msg` |
18
+ | `[ANTIGRIEF-LOG]{category, action}` | embed z color-coded category (lub plain `[ANTIGRIEF: cat] action` jeśli `antigrief_log_as_embed=false`) |
19
+ | `[DATA-SET]{data_set, key, value?}` | UPSERT do JSON storage (debounce fsync). `value=nil` → DELETE |
20
+ | `[DATA-GET]<token>{data_set, key}` | Read JSON, RCON callback `/cc Token.get(<token>)(d); Server.raise_data_set(d)` (dual-path) |
21
+ | `[DATA-GET-AND-PRINT]<token>{...,to_print}` | jak wyżej + `to_print` w callback data |
22
+ | `[DATA-GET-ALL]<token>{data_set}` | Read whole dataset, RCON callback z `entries` table |
23
+
24
+ Pozostałe tagi (`[BAN-SYNC]`, `[PING]`, `[START-SCENARIO]`) — silent skip (bany robi clusterio natywnie, lifecycle nieużywany).
25
+
26
+ ## Architektura
27
+
28
+ - **`info.js`** — manifest + schema 4 eventów instance→controller (`ScenarioDiscordEvent`, `AntigriefLogEvent`, `DataSetEvent`, `DataGetRequest`) + controller config fields.
29
+ - **`instance.js`** — hook `onOutput`, regex tag-stream parser, mini-parser pseudo-Lua-JSON (`parseComfyPayload`), message queue dla disconnected controller.
30
+ - **`controller.js`** — 4 handlery + Discord client sharing (delegacja do `discord_bridge`) + JSON storage manager (atomic write, debounce, load on init).
31
+ - **Storage:** pojedynczy plik JSON (`comfy_adapter_storage.json` configurable), schema `{ "<dataset>": { "<key>": <value> } }`.
32
+
33
+ ## Config (controller-side)
34
+
35
+ | Klucz | Default | Opis |
36
+ |---|---|---|
37
+ | `comfy_adapter.storage_path` | `comfy_adapter_storage.json` | Plik JSON datastore (relative do CWD kontrolera) |
38
+ | `comfy_adapter.write_debounce_ms` | `1000` | Coalesce multiple [DATA-SET] writes |
39
+ | `comfy_adapter.antigrief_log_as_embed` | `true` | Embed vs plain message dla anti-grief log |
40
+ | `comfy_adapter.admin_prefix` | `[ADMIN] ` | Prefix dla [DISCORD-ADMIN*] tagów |
41
+
42
+ ## Zależności runtime
43
+
44
+ - **`@rithien/discord_bridge`** (Faza 1, `^0.3.1`) — channel routing per instancja + shared Discord client. Bez niego: Discord eventy są no-op (log verbose), datastore nadal działa.
45
+ - **`clusterio` alpha 14+** — API hooks `onOutput`, `controller.handle`, `controller.plugins.get`, `InstanceSendRconRequest`.
46
+
47
+ ## Pełna spec
48
+
49
+ Szczegóły w [`../PLAN.md`](../PLAN.md) sekcja "Faza 2.5". Architektoniczne decyzje w [`../DECISIONS.md`](../DECISIONS.md) (search "Faza 2.5").
package/controller.js ADDED
@@ -0,0 +1,453 @@
1
+ "use strict";
2
+ // factorio-polska / comfy_adapter — controller plugin (Faza 2.5).
3
+ //
4
+ // 4 handlery eventów (wszystkie instance→controller):
5
+ // ScenarioDiscordEvent → Discord channel instancji (przez discord_bridge.getChannelForInstance)
6
+ // AntigriefLogEvent → Discord embed (lub plain) na tym samym kanale
7
+ // DataSetEvent → upsert do JSON storage (debounced fs flush)
8
+ // DataGetRequest → read JSON + RCON callback `/cc local d={...}; require(...).get(<token>)(d); ...`
9
+ //
10
+ // Discord client sharing (decyzja [PLAN.md Faza 2.5 punkt 5, opcja B]):
11
+ // Reuse client z `@rithien/discord_bridge` (kontroler trzyma jeden Discord login).
12
+ // Dostęp: `this.controller.plugins.get("discord_bridge")?.client`.
13
+ // Fallback: jeśli discord_bridge nie zainstalowany / nie zalogowany → log warn,
14
+ // Discord eventy są no-op (datastore nadal działa).
15
+ //
16
+ // Storage:
17
+ // Schema: `{ "<dataset>": { "<key>": <value>, ... }, ... }`.
18
+ // Atomic write: tmp file + rename. Debounce: kombinuje multiple SET w jednym fsync.
19
+ // Read on init (load existing or {}).
20
+ //
21
+ // Callback resolver (decyzja [PLAN.md Faza 2.6 punkt 3]):
22
+ // Każdy DATA-GET response wywołuje BOTH `Token.get(<token>)(data)` ORAZ
23
+ // `Server.raise_data_set(data)`. Powód: scenariusz (sessions.lua) używa obu paths
24
+ // — Token-based per-request closures (logika seed/upload), oraz registered
25
+ // on_data_set_changed handlers (broadcast subscribers). Redundancja jest bezpieczna
26
+ // (handlers są idempotentne).
27
+
28
+ const fs = require("fs/promises");
29
+ const path = require("path");
30
+ const { BaseControllerPlugin } = require("@clusterio/controller");
31
+
32
+ const {
33
+ ScenarioDiscordEvent,
34
+ AntigriefLogEvent,
35
+ DataSetEvent,
36
+ DataGetRequest,
37
+ } = require("./info.js");
38
+
39
+
40
+ // Mapa kategorii anti-grief → kolor embed Discord (decimal RGB).
41
+ const ANTIGRIEF_COLORS = {
42
+ landfill: 0x3b88c3, // niebieski (water tile)
43
+ capsule: 0xffcc00, // żółty (capsule warning)
44
+ friendly_fire: 0xff6b00, // pomarańczowy
45
+ mining: 0xff3b30, // czerwony (anti-grief mining)
46
+ whitelist_mining: 0xff9500, // jasno-pomarańczowy
47
+ corpse: 0x8e44ad, // fioletowy (loot)
48
+ whisper: 0x95a5a6, // szary (whisper log)
49
+ cancel_crafting: 0xff2d55, // różowy (exploit cancel-craft)
50
+ deconstruct: 0xc0392b, // ciemno-czerwony (mass decon)
51
+ scenario: 0x2c3e50, // grafit (scenario events)
52
+ default: 0x7f8c8d, // jasny szary fallback
53
+ };
54
+
55
+
56
+ /**
57
+ * Escapuje Lua-string dla wstawki w `/cc` RCON command:
58
+ * "abc" → 'abc' (no special chars)
59
+ * `it's` → 'it\'s'
60
+ * `a\nb` → 'a\nb' (preserve, but no actual newline in RCON line)
61
+ */
62
+ function luaString(s) {
63
+ if (s === null || s === undefined) return "nil";
64
+ const escaped = String(s)
65
+ .replace(/\\/g, "\\\\")
66
+ .replace(/'/g, "\\'")
67
+ .replace(/\n/g, "\\n")
68
+ .replace(/\r/g, "\\r");
69
+ return `'${escaped}'`;
70
+ }
71
+
72
+ /**
73
+ * Konwertuje wartość JS na literalną reprezentację Lua (do wstawki w `/cc`).
74
+ */
75
+ function luaLiteral(v) {
76
+ if (v === null || v === undefined) return "nil";
77
+ if (typeof v === "boolean") return v ? "true" : "false";
78
+ if (typeof v === "number") return Number.isFinite(v) ? String(v) : "nil";
79
+ if (typeof v === "string") return luaString(v);
80
+ if (Array.isArray(v) || typeof v === "object") {
81
+ const parts = [];
82
+ if (Array.isArray(v)) {
83
+ for (const item of v) parts.push(luaLiteral(item));
84
+ } else {
85
+ for (const [k, val] of Object.entries(v)) {
86
+ const keyExpr = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(k) ? k : `[${luaString(k)}]`;
87
+ parts.push(`${keyExpr}=${luaLiteral(val)}`);
88
+ }
89
+ }
90
+ return `{${parts.join(",")}}`;
91
+ }
92
+ return "nil"; // fallback (np. function, symbol)
93
+ }
94
+
95
+
96
+ class ControllerPlugin extends BaseControllerPlugin {
97
+ async init() {
98
+ this.storage = {};
99
+ this.writePending = false;
100
+ this.writeTimer = null;
101
+ this.writeInflight = null;
102
+
103
+ this.controller.handle(ScenarioDiscordEvent, this.handleScenarioDiscord.bind(this));
104
+ this.controller.handle(AntigriefLogEvent, this.handleAntigriefLog.bind(this));
105
+ this.controller.handle(DataSetEvent, this.handleDataSet.bind(this));
106
+ this.controller.handle(DataGetRequest, this.handleDataGetRequest.bind(this));
107
+
108
+ await this.loadStorage();
109
+ }
110
+
111
+ async onShutdown() {
112
+ if (this.writeTimer) {
113
+ clearTimeout(this.writeTimer);
114
+ this.writeTimer = null;
115
+ }
116
+ if (this.writePending) {
117
+ await this.flushStorage();
118
+ }
119
+ if (this.writeInflight) {
120
+ await this.writeInflight.catch(() => {}); // best-effort
121
+ }
122
+ }
123
+
124
+ // =========================================================================
125
+ // Discord client access (delegated to discord_bridge)
126
+ // =========================================================================
127
+
128
+ getDiscordBridge() {
129
+ const dbPlugin = this.controller.plugins?.get("discord_bridge");
130
+ if (!dbPlugin) return null;
131
+ if (!dbPlugin.client) return null;
132
+ return dbPlugin;
133
+ }
134
+
135
+ getChannelForInstance(instanceId) {
136
+ const dbPlugin = this.getDiscordBridge();
137
+ if (!dbPlugin) return null;
138
+ const instance = this.controller.instances.get(instanceId);
139
+ if (!instance) return null;
140
+ // discord_bridge controller eksportuje getChannelForInstance(instance).
141
+ if (typeof dbPlugin.getChannelForInstance === "function") {
142
+ return dbPlugin.getChannelForInstance(instance);
143
+ }
144
+ return dbPlugin.fallbackChannel ?? null;
145
+ }
146
+
147
+ // =========================================================================
148
+ // Discord event handlers
149
+ // =========================================================================
150
+
151
+ async handleScenarioDiscord(event, src) {
152
+ const channel = this.getChannelForInstance(src.id);
153
+ if (!channel) {
154
+ this.logger.verbose(`comfy_adapter: no Discord channel for instance ${src.id}, dropping ${event.variant}`);
155
+ return;
156
+ }
157
+
158
+ const adminPrefix = this.controller.config.get("comfy_adapter.admin_prefix") || "";
159
+ const variant = event.variant;
160
+ const content = event.content ?? "";
161
+
162
+ try {
163
+ if (variant === "DISCORD-EMBED-PARSED") {
164
+ let embedData;
165
+ try {
166
+ embedData = JSON.parse(content);
167
+ } catch (err) {
168
+ this.logger.warn(`comfy_adapter: DISCORD-EMBED-PARSED has invalid JSON: ${err.message}`);
169
+ return;
170
+ }
171
+ await channel.send({ embeds: [this.buildEmbedFromParsed(embedData)] });
172
+ } else if (variant === "DISCORD-EMBED" || variant === "DISCORD-EMBED-RAW") {
173
+ await channel.send({ embeds: [{ description: content }] });
174
+ } else if (variant === "DISCORD-ADMIN-EMBED" || variant === "DISCORD-ADMIN-EMBED-RAW") {
175
+ await channel.send({ embeds: [{ description: adminPrefix + content }] });
176
+ } else if (variant === "DISCORD-ADMIN" || variant === "DISCORD-ADMIN-RAW") {
177
+ await channel.send({ content: adminPrefix + content, allowedMentions: { parse: [] } });
178
+ } else if (variant === "DISCORD-BOLD") {
179
+ await channel.send({ content: `**${content}**`, allowedMentions: { parse: [] } });
180
+ } else {
181
+ // DISCORD, DISCORD-RAW
182
+ await channel.send({ content, allowedMentions: { parse: [] } });
183
+ }
184
+ } catch (err) {
185
+ this.logger.error(`comfy_adapter: Discord send failed for ${variant}: ${err.message}`);
186
+ }
187
+ }
188
+
189
+ buildEmbedFromParsed(data) {
190
+ const embed = {};
191
+ if (data.title) embed.title = String(data.title);
192
+ if (data.description) embed.description = String(data.description);
193
+ if (data.color !== undefined) embed.color = Number(data.color);
194
+ if (Array.isArray(data.fields)) {
195
+ embed.fields = data.fields.map(f => ({
196
+ name: String(f.name ?? "—"),
197
+ value: String(f.value ?? "—"),
198
+ inline: !!f.inline,
199
+ }));
200
+ }
201
+ return embed;
202
+ }
203
+
204
+ async handleAntigriefLog(event, src) {
205
+ const channel = this.getChannelForInstance(src.id);
206
+ if (!channel) return;
207
+
208
+ const asEmbed = this.controller.config.get("comfy_adapter.antigrief_log_as_embed");
209
+ try {
210
+ if (asEmbed) {
211
+ const color = ANTIGRIEF_COLORS[event.category] ?? ANTIGRIEF_COLORS.default;
212
+ await channel.send({
213
+ embeds: [{
214
+ title: `Antigrief: ${event.category}`,
215
+ description: event.action || "—",
216
+ color,
217
+ }],
218
+ });
219
+ } else {
220
+ await channel.send({
221
+ content: `[ANTIGRIEF: ${event.category}] ${event.action}`,
222
+ allowedMentions: { parse: [] },
223
+ });
224
+ }
225
+ } catch (err) {
226
+ this.logger.error(`comfy_adapter: Discord send failed for antigrief ${event.category}: ${err.message}`);
227
+ }
228
+ }
229
+
230
+ // =========================================================================
231
+ // Datastore: JSON file persistence
232
+ // =========================================================================
233
+
234
+ getStoragePath() {
235
+ const configured = this.controller.config.get("comfy_adapter.storage_path") || "comfy_adapter_storage.json";
236
+ return path.isAbsolute(configured) ? configured : path.resolve(process.cwd(), configured);
237
+ }
238
+
239
+ async loadStorage() {
240
+ const file = this.getStoragePath();
241
+ try {
242
+ const raw = await fs.readFile(file, "utf8");
243
+ this.storage = JSON.parse(raw);
244
+ if (typeof this.storage !== "object" || this.storage === null || Array.isArray(this.storage)) {
245
+ this.logger.warn(`comfy_adapter: storage at ${file} is not an object, resetting to {}`);
246
+ this.storage = {};
247
+ }
248
+ this.logger.info(`comfy_adapter: loaded storage from ${file} (${Object.keys(this.storage).length} datasets)`);
249
+ } catch (err) {
250
+ if (err.code === "ENOENT") {
251
+ this.logger.info(`comfy_adapter: no storage file at ${file}, starting empty`);
252
+ this.storage = {};
253
+ } else {
254
+ this.logger.error(`comfy_adapter: failed to load storage at ${file}: ${err.message}; starting empty`);
255
+ this.storage = {};
256
+ }
257
+ }
258
+ }
259
+
260
+ scheduleFlush() {
261
+ if (this.writePending) return;
262
+ this.writePending = true;
263
+ const debounce = Number(this.controller.config.get("comfy_adapter.write_debounce_ms")) || 1000;
264
+ this.writeTimer = setTimeout(() => {
265
+ this.writeTimer = null;
266
+ this.flushStorage().catch(err => {
267
+ this.logger.error(`comfy_adapter: storage flush failed: ${err.message}`);
268
+ });
269
+ }, debounce);
270
+ }
271
+
272
+ async flushStorage() {
273
+ if (!this.writePending) return;
274
+ this.writePending = false;
275
+
276
+ // Czekaj na poprzedni in-flight write (atomicity z perspektywy callera).
277
+ if (this.writeInflight) {
278
+ try { await this.writeInflight; } catch (_) { /* swallow, fresh try */ }
279
+ }
280
+
281
+ const file = this.getStoragePath();
282
+ const tmp = `${file}.tmp`;
283
+ const snapshot = JSON.stringify(this.storage, null, 2);
284
+
285
+ this.writeInflight = (async () => {
286
+ await fs.mkdir(path.dirname(file), { recursive: true });
287
+ await fs.writeFile(tmp, snapshot, "utf8");
288
+ await fs.rename(tmp, file);
289
+ })();
290
+ try {
291
+ await this.writeInflight;
292
+ } finally {
293
+ this.writeInflight = null;
294
+ }
295
+ }
296
+
297
+ async handleDataSet(event, src) {
298
+ const ds = event.dataSet;
299
+ const key = event.key;
300
+ if (!ds || !key) return;
301
+
302
+ if (!this.storage[ds]) this.storage[ds] = {};
303
+ if (event.value === null || event.value === undefined) {
304
+ delete this.storage[ds][key];
305
+ } else {
306
+ this.storage[ds][key] = event.value;
307
+ }
308
+ this.scheduleFlush();
309
+
310
+ // Cross-instance broadcast (Faza 2.6): propaguj zmianę do innych instancji.
311
+ // Powód: scenariusz instance A właśnie zaktualizował sessions[player] — instance B
312
+ // (z tym samym scenariuszem) nie wie o tym aż do następnego DATA-GET przy on_player_joined.
313
+ // Broadcast tworzy eventually-consistent storage.sessions w obrębie wszystkich instancji,
314
+ // co umożliwia natychmiastową propagację trust (player trusted na A → trusted na B teraz).
315
+ if (this.shouldBroadcast(ds)) {
316
+ this.broadcastDataSet(src.id, ds, key, event.value).catch(err => {
317
+ this.logger.error(`comfy_adapter: broadcast failed for ${ds}/${key}: ${err.message}`);
318
+ });
319
+ }
320
+ }
321
+
322
+ shouldBroadcast(dataSet) {
323
+ const raw = this.controller.config.get("comfy_adapter.broadcast_data_sets") || "";
324
+ if (!raw.trim()) return false;
325
+ const allowed = raw.split(",").map(s => s.trim()).filter(Boolean);
326
+ return allowed.includes(dataSet);
327
+ }
328
+
329
+ async broadcastDataSet(sourceInstanceId, dataSet, key, value) {
330
+ // Buduje pojedyncze /cc per instancja docelowa. Tylko raise_data_set
331
+ // (nie Token.get — żaden token nie został wygenerowany dla tego data set u odbiorcy).
332
+ const fields = [`data_set=${luaString(dataSet)}`, `key=${luaString(key)}`];
333
+ if (value !== null && value !== undefined) {
334
+ fields.push(`value=${luaLiteral(value)}`);
335
+ }
336
+ const data = `{${fields.join(",")}}`;
337
+ const cmd =
338
+ `/cc local d=${data} ` +
339
+ `local ok,err=pcall(function() require('lib.server').raise_data_set(d) end) ` +
340
+ `if not ok then log('[comfy_adapter broadcast] '..tostring(err)) end`;
341
+
342
+ const targets = [];
343
+ for (const instance of this.controller.instances.values()) {
344
+ if (instance.id === sourceInstanceId) continue;
345
+ if (instance.status !== "running") continue;
346
+ targets.push(instance.id);
347
+ }
348
+ if (targets.length === 0) return;
349
+
350
+ await Promise.allSettled(targets.map(id => this.sendRcon(id, cmd)));
351
+ }
352
+
353
+ async handleDataGetRequest(event, src) {
354
+ const ds = event.dataSet;
355
+ const dataset = this.storage[ds] || {};
356
+
357
+ let responseLua;
358
+ if (event.mode === "get") {
359
+ const value = dataset[event.key] ?? null;
360
+ responseLua = this.buildSingleCallback(event.token, ds, event.key, value);
361
+ } else if (event.mode === "get_and_print") {
362
+ const value = dataset[event.key] ?? null;
363
+ // Print do gracza realizowany w callback Lua-side (try_get_data_and_print_token) —
364
+ // my dostarczamy tylko data; ten sam wzór co `get`.
365
+ responseLua = this.buildSingleCallback(event.token, ds, event.key, value, event.toPrint);
366
+ } else if (event.mode === "get_all") {
367
+ responseLua = this.buildAllCallback(event.token, ds, dataset);
368
+ } else {
369
+ this.logger.warn(`comfy_adapter: unknown DATA-GET mode "${event.mode}"`);
370
+ return;
371
+ }
372
+
373
+ await this.sendRcon(event.instanceId, responseLua);
374
+ }
375
+
376
+ /**
377
+ * Buduje `/cc local d={...}; require('lib.token').get(<token>)(d); require('lib.server').raise_data_set(d)`.
378
+ * Oba paths są wywoływane: per-request closure (Token) i broadcast (raise_data_set).
379
+ */
380
+ buildSingleCallback(token, dataSet, key, value, toPrint = null) {
381
+ const fields = [
382
+ `data_set=${luaString(dataSet)}`,
383
+ `key=${luaString(key)}`,
384
+ ];
385
+ if (value !== null && value !== undefined) {
386
+ fields.push(`value=${luaLiteral(value)}`);
387
+ }
388
+ if (toPrint) {
389
+ fields.push(`to_print=${luaString(toPrint)}`);
390
+ }
391
+ const data = `{${fields.join(",")}}`;
392
+ return (
393
+ `/cc local d=${data} ` +
394
+ `local ok1,err1=pcall(function() require('lib.token').get(${token})(d) end) ` +
395
+ `local ok2,err2=pcall(function() require('lib.server').raise_data_set(d) end) ` +
396
+ `if not ok1 then log('[comfy_adapter Token callback] '..tostring(err1)) end ` +
397
+ `if not ok2 then log('[comfy_adapter raise_data_set] '..tostring(err2)) end`
398
+ );
399
+ }
400
+
401
+ /**
402
+ * DATA-GET-ALL response: pojedyncze callback z `entries` jako tabela {key=value,...}.
403
+ * Format kompatybilny z Comfy try_get_all_data callback signature.
404
+ */
405
+ buildAllCallback(token, dataSet, dataset) {
406
+ const data = `{data_set=${luaString(dataSet)},entries=${luaLiteral(dataset)}}`;
407
+ return (
408
+ `/cc local d=${data} ` +
409
+ `local ok,err=pcall(function() require('lib.token').get(${token})(d) end) ` +
410
+ `if not ok then log('[comfy_adapter Token all callback] '..tostring(err)) end`
411
+ );
412
+ }
413
+
414
+ async sendRcon(instanceId, command) {
415
+ const instance = this.controller.instances.get(instanceId);
416
+ if (!instance) {
417
+ this.logger.warn(`comfy_adapter: instance ${instanceId} not found for RCON`);
418
+ return;
419
+ }
420
+ const hostId = instance.config.get("instance.assigned_host");
421
+ if (hostId === null || hostId === undefined) {
422
+ this.logger.warn(`comfy_adapter: instance ${instanceId} has no assigned host`);
423
+ return;
424
+ }
425
+ try {
426
+ // Clusterio API: controller wysyła RCON przez host (sendRconCommandRequest lub przez plugin API).
427
+ // W alpha 14 jest helper this.controller.sendTo({instanceId}, new RawRconCommandRequest(command)).
428
+ // Tu trzymamy minimalne API zewnętrzne — jeśli interfejs się zmieni, ten miejsce do aktualizacji.
429
+ const hostConn = this.controller.wsServer?.hostConnections?.get(hostId);
430
+ if (!hostConn) {
431
+ this.logger.warn(`comfy_adapter: host ${hostId} not connected for instance ${instanceId}`);
432
+ return;
433
+ }
434
+ // Lazy require — RawRconCommandRequest jest standardowym lib API.
435
+ const lib = require("@clusterio/lib");
436
+ const Req = lib.InstanceSendRconRequest ?? lib.SendRconRequest ?? null;
437
+ if (!Req) {
438
+ this.logger.error("comfy_adapter: clusterio lib does not export RCON request class — RCON disabled");
439
+ return;
440
+ }
441
+ await this.controller.sendTo({ instanceId }, new Req(command));
442
+ } catch (err) {
443
+ this.logger.error(`comfy_adapter: RCON send failed for instance ${instanceId}: ${err.message}`);
444
+ }
445
+ }
446
+ }
447
+
448
+ module.exports = {
449
+ ControllerPlugin,
450
+ // Helpers exported dla unit testów (przyszłość).
451
+ _luaLiteral: luaLiteral,
452
+ _luaString: luaString,
453
+ };
@@ -0,0 +1,6 @@
1
+ {
2
+ "main.js": "static/main.761df12d5815fb8e02fa.js",
3
+ "comfy_adapter.js": "static/comfy_adapter.1ca70d5b170a77bb6963.js",
4
+ "static/info_js.js": "static/info_js.9ad5f016be3454c1a255.js",
5
+ "static/package_json.js": "static/package_json.b88ad27d58c1784bb20f.js"
6
+ }