homegames-common 1.5.2 → 1.5.4

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/game-loader.js CHANGED
@@ -33,6 +33,8 @@ const squishMap = {
33
33
  '135': 'squish-135',
34
34
  '136': 'squish-136',
35
35
  '138': 'squish-138',
36
+ // 1.3.9 — adds per-tick coalescing of squish/broadcast + Squisher.flush()
37
+ '139': 'squish-139',
36
38
  };
37
39
 
38
40
  const DEFAULT_SQUISH_VERSION = '135';
@@ -18,7 +18,7 @@ const path = require('path');
18
18
  const fs = require('fs');
19
19
  const os = require('os');
20
20
 
21
- const { isDockerAvailable, isImageBuilt, ensureImage, runGameContainer, stopContainer, isContainerRunning } = require('./docker-helper');
21
+ const { isDockerAvailable, isImageBuilt, ensureImage, runGameContainer, stopContainer, isContainerRunning, parseMemoryString } = require('./docker-helper');
22
22
  const { squishMap, DEFAULT_SQUISH_VERSION, fetchGameFromForgejo, loadGameClassFromPath, detectSquishVersion, parseSquishVersion } = require('./game-loader');
23
23
 
24
24
  // ---------------------------------------------------------------------------
@@ -74,6 +74,7 @@ class GameSessionManager {
74
74
  * @param {function} [opts.log] — logging function ({ info, error })
75
75
  * @param {number} [opts.bezelX] — bezel size X for init message (default 0)
76
76
  * @param {number} [opts.bezelY] — bezel size Y for init message (default 0)
77
+ * @param {string} [opts.memoryLimit] — Docker-style memory limit for child sessions (e.g. '128m'); overrides CHILD_SESSION_MEMORY_LIMIT config
77
78
  */
78
79
  constructor(opts = {}) {
79
80
  this.portPool = new PortPool(
@@ -84,7 +85,7 @@ class GameSessionManager {
84
85
  this.dockerImageName = opts.dockerImageName || 'homegames-runner';
85
86
  this.childServerPath = opts.childServerPath || null;
86
87
  this.gracePeriodMs = opts.gracePeriodMs || 30000;
87
- this.lifecycleCheckMs = opts.lifecycleCheckMs || 10000;
88
+ this.lifecycleCheckMs = opts.lifecycleCheckMs || 3000;
88
89
  this.forgejo = opts.forgejo || {};
89
90
  this.saveDataRoot = opts.saveDataRoot || path.join(os.tmpdir(), 'hg-save-data');
90
91
  this.assetCachePath = opts.assetCachePath || null;
@@ -93,6 +94,16 @@ class GameSessionManager {
93
94
  this.certPath = opts.certPath || null;
94
95
  this.log = opts.log || { info: console.log, error: console.error };
95
96
 
97
+ // Memory limit for child game sessions. A single Docker-style string
98
+ // (e.g. '128m', '1g') configurable via config.json / env (getConfigValue),
99
+ // with an opts override. Docker consumes it directly; the fork path
100
+ // converts it to integer megabytes for V8's --max-old-space-size.
101
+ // require lazily: index.js requires this module, so it isn't fully
102
+ // populated at module-load time, but the constructor runs at runtime.
103
+ const { getConfigValue } = require('./index');
104
+ this.memoryLimit = opts.memoryLimit
105
+ || getConfigValue('CHILD_SESSION_MEMORY_LIMIT', '196m');
106
+
96
107
  this.sessions = {};
97
108
  this._dockerChecked = false;
98
109
  this._dockerOk = false;
@@ -250,6 +261,7 @@ class GameSessionManager {
250
261
  imageName: this.dockerImageName,
251
262
  gameEntryRelative,
252
263
  noFrame: input.noFrame || false,
264
+ memoryLimit: this.memoryLimit,
253
265
  extraEnv,
254
266
  });
255
267
 
@@ -351,7 +363,17 @@ class GameSessionManager {
351
363
  }
352
364
  }
353
365
  // Apply required overrides (these take precedence)
354
- env.NODE_PATH = `${process.cwd()}${path.sep}node_modules`;
366
+ // Resolve the game's `require('squish-NNN')` against the core
367
+ // install's node_modules (where the squish packages actually live),
368
+ // NOT process.cwd() — the core process is forked by Electron with an
369
+ // inherited cwd that has no node_modules, so a cwd-relative NODE_PATH
370
+ // would (intermittently) fail to find the requested squish version.
371
+ // childServerPath is <core>/src/child_game_server.js, so its
372
+ // grandparent dir is the core install root.
373
+ const coreModulesPath = this.childServerPath
374
+ ? path.join(path.dirname(path.dirname(this.childServerPath)), 'node_modules')
375
+ : `${process.cwd()}${path.sep}node_modules`;
376
+ env.NODE_PATH = coreModulesPath;
355
377
  env.SQUISH_PATH = squishPkg;
356
378
  // opts.env is filtered through the same allowlist — no arbitrary injection
357
379
  if (opts.env) {
@@ -362,12 +384,17 @@ class GameSessionManager {
362
384
  }
363
385
  }
364
386
 
387
+ // V8's --max-old-space-size is in megabytes; convert the Docker-style
388
+ // memory limit string (bytes) to MB.
389
+ const maxOldSpaceMb = Math.max(1, Math.floor(parseMemoryString(this.memoryLimit) / (1024 * 1024)));
365
390
  const child = fork(this.childServerPath, [], {
366
391
  env,
367
392
  stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
368
- execArgv: ['--max-old-space-size=64'],
393
+ execArgv: [`--max-old-space-size=${maxOldSpaceMb}`],
369
394
  });
370
395
 
396
+ this._attachForkLogForwarding(child);
397
+
371
398
  child.send(JSON.stringify({
372
399
  key: input.gameKey || path.basename(gamePath, '.js'),
373
400
  squishVersion,
@@ -433,6 +460,38 @@ class GameSessionManager {
433
460
  });
434
461
  }
435
462
 
463
+ // -----------------------------------------------------------------------
464
+ // Forward forked child stdout/stderr to the parent terminal.
465
+ // Docker sessions are excluded — use GET /sessions/:id/logs or docker logs.
466
+ // -----------------------------------------------------------------------
467
+ _attachForkLogForwarding(child) {
468
+ const emitLine = (line) => {
469
+ if (!line) return;
470
+ console.log(`session log: ${line}`);
471
+ };
472
+
473
+ const attachStream = (stream) => {
474
+ if (!stream) return;
475
+ let buffer = '';
476
+ stream.on('data', (chunk) => {
477
+ buffer += chunk.toString();
478
+ const lines = buffer.split('\n');
479
+ buffer = lines.pop() || '';
480
+ for (const line of lines) {
481
+ emitLine(line);
482
+ }
483
+ });
484
+ stream.on('end', () => {
485
+ if (buffer.length > 0) {
486
+ emitLine(buffer);
487
+ }
488
+ });
489
+ };
490
+
491
+ attachStream(child.stdout);
492
+ attachStream(child.stderr);
493
+ }
494
+
436
495
  // -----------------------------------------------------------------------
437
496
  // Find the project root (nearest ancestor with node_modules or package.json)
438
497
  // so we mount enough of the tree for relative requires to work.
@@ -525,10 +584,30 @@ class GameSessionManager {
525
584
 
526
585
  if (s.type === 'docker') {
527
586
  const running = await isContainerRunning(s.containerId);
528
- this.log.info(`Session ${sessionId} lifecycle check: container running = ${running}`);
529
587
  if (!running) {
530
588
  this.log.info(`Session ${sessionId} container exited`);
531
589
  this._cleanupSession(sessionId);
590
+ return;
591
+ }
592
+
593
+ // Check player count — stop container if empty past grace period
594
+ try {
595
+ const healthData = await this._querySessionHealth(s.port);
596
+ const playerCount = healthData && healthData.playerCount !== undefined ? healthData.playerCount : -1;
597
+ if (playerCount === 0) {
598
+ s._emptyTicks++;
599
+ const emptyMs = s._emptyTicks * this.lifecycleCheckMs;
600
+ if (emptyMs >= this.gracePeriodMs) {
601
+ this.log.info(`Session ${sessionId} empty for ${emptyMs}ms, stopping`);
602
+ await stopContainer(s.containerId);
603
+ this._cleanupSession(sessionId);
604
+ return;
605
+ }
606
+ } else if (playerCount > 0) {
607
+ s._emptyTicks = 0;
608
+ }
609
+ } catch (e) {
610
+ // Health check failed — container might be starting up, ignore
532
611
  }
533
612
  } else if (s.type === 'fork') {
534
613
  // For fork sessions, send heartbeat. The child_game_server.js
@@ -559,6 +638,28 @@ class GameSessionManager {
559
638
  this._cleanupSession(sessionId);
560
639
  }
561
640
 
641
+ _querySessionHealth(port) {
642
+ return new Promise((resolve) => {
643
+ const req = http.request({
644
+ hostname: 'localhost',
645
+ port,
646
+ path: '/health',
647
+ method: 'GET',
648
+ timeout: 2000,
649
+ }, (res) => {
650
+ let data = '';
651
+ res.on('data', (chunk) => { data += chunk; });
652
+ res.on('end', () => {
653
+ try { resolve(JSON.parse(data)); }
654
+ catch (e) { resolve(null); }
655
+ });
656
+ });
657
+ req.on('error', () => resolve(null));
658
+ req.on('timeout', () => { req.destroy(); resolve(null); });
659
+ req.end();
660
+ });
661
+ }
662
+
562
663
  _cleanupSession(sessionId) {
563
664
  const session = this.sessions[sessionId];
564
665
  if (!session) return;
package/game-session.js CHANGED
@@ -88,7 +88,15 @@ class GameSession {
88
88
  }
89
89
 
90
90
  this.squisher = new Squisher(squisherOpts);
91
- this.squisher.addListener(() => this._broadcastState());
91
+ // Coalesce broadcasts: a single game tick often mutates many nodes, each
92
+ // firing onStateChange -> a listener call. Without coalescing that's one
93
+ // full per-player send (frame.flat + Buffer.from + ws.send) per mutation.
94
+ // We defer to the end of the current event-loop turn so a burst of
95
+ // mutations produces a single broadcast carrying the final state.
96
+ // (The squisher still recomputes this.state synchronously per mutation,
97
+ // so getPlayerFrame/state stay fresh for the direct-send paths.)
98
+ this._broadcastScheduled = false;
99
+ this.squisher.addListener(() => this._scheduleBroadcast());
92
100
 
93
101
  this.gameMetadata = (typeof game.constructor.metadata === 'function') ? game.constructor.metadata() : {};
94
102
  this.maxPlayers = this.gameMetadata.maxPlayers || 64;
@@ -448,6 +456,18 @@ class GameSession {
448
456
  // Private: broadcasting
449
457
  // -----------------------------------------------------------------------
450
458
 
459
+ _scheduleBroadcast() {
460
+ if (this._broadcastScheduled) return;
461
+ this._broadcastScheduled = true;
462
+ const schedule = (typeof setImmediate === 'function')
463
+ ? setImmediate
464
+ : (fn) => setTimeout(fn, 0);
465
+ schedule(() => {
466
+ this._broadcastScheduled = false;
467
+ this._broadcastState();
468
+ });
469
+ }
470
+
451
471
  _broadcastState() {
452
472
  for (const pid in this.players) {
453
473
  try {
@@ -466,6 +486,11 @@ class GameSession {
466
486
  }
467
487
 
468
488
  _sendPlayerFrame(playerId, ws) {
489
+ // Newer squishers defer squish/broadcast and coalesce per tick. The
490
+ // direct-send paths (a player/spectator just joined) need the current
491
+ // state, so flush any pending changes first. No-op when nothing pending
492
+ // and on older squishers that don't implement flush().
493
+ if (typeof this.squisher.flush === 'function') this.squisher.flush();
469
494
  let frame = this.squisher.getPlayerFrame(playerId);
470
495
  if (!frame) frame = this.squisher.state;
471
496
  if (frame) {
package/index.js CHANGED
@@ -33,7 +33,8 @@ const DEFAULT_CONFIG = {
33
33
  "API_URL": "https://api.homegames.io",
34
34
  "LINK_PROXY_URL": "wss://public.homegames.link:81",
35
35
  "LINK_URL": "wss://homegames.link",
36
- "MAP_ENABLED": true
36
+ "MAP_ENABLED": true,
37
+ "CHILD_SESSION_MEMORY_LIMIT": "196m"
37
38
  };
38
39
 
39
40
  // ---------------------------------------------------------------------------
@@ -192,10 +193,21 @@ const dockerHelper = require('./docker-helper');
192
193
  const GameSession = require('./game-session');
193
194
  const GameSessionManager = require('./game-session-manager');
194
195
 
196
+ const getHash = (input) => {
197
+ return crypto.createHash('md5').update(input).digest('hex');
198
+ };
199
+
200
+
195
201
  // ---------------------------------------------------------------------------
196
202
  // Exports
197
203
  // ---------------------------------------------------------------------------
198
204
 
205
+ // Canonical squish.js authoring guide — the single source of truth for every
206
+ // consumer (the LLM worker, the studio "copy LLM context" feature, repo docs).
207
+ // Read the text with getAuthoringDoc(), or hand a child process authoringDocPath.
208
+ const AUTHORING_DOC_PATH = path.join(__dirname, 'docs', 'squishjs-game-authoring.md');
209
+ const getAuthoringDoc = () => fs.readFileSync(AUTHORING_DOC_PATH, 'utf-8');
210
+
199
211
  module.exports = {
200
212
  // Utilities
201
213
  guaranteeDir,
@@ -203,6 +215,11 @@ module.exports = {
203
215
  getConfigValue,
204
216
  log,
205
217
  getAppDataPath,
218
+ getHash,
219
+
220
+ // Authoring guide (single source of truth)
221
+ authoringDocPath: AUTHORING_DOC_PATH,
222
+ getAuthoringDoc,
206
223
 
207
224
  // Game loading
208
225
  gameLoader,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homegames-common",
3
- "version": "1.5.2",
3
+ "version": "1.5.4",
4
4
  "description": "Homegames common tools",
5
5
  "main": "index.js",
6
6
  "scripts": {