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/docker-helper.js +2 -1
- package/docs/ARCHITECTURE.md +173 -0
- package/docs/DATA-MODEL.md +115 -0
- package/docs/FLOWS.md +125 -0
- package/docs/INFRA.md +111 -0
- package/docs/OPERATIONS.md +121 -0
- package/docs/SYSTEM.md +118 -0
- package/docs/squishjs-game-authoring.md +895 -0
- package/game-loader.js +2 -0
- package/game-session-manager.js +106 -5
- package/game-session.js +26 -1
- package/index.js +18 -1
- package/package.json +1 -1
package/game-loader.js
CHANGED
package/game-session-manager.js
CHANGED
|
@@ -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 ||
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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,
|