@maka/maka-cli 5.175.1 → 5.177.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.
@@ -1,5 +1,6 @@
1
1
  import blessed from 'blessed';
2
2
  import { capitalCase } from 'change-case';
3
+ import * as path from 'path';
3
4
  import Game from './game.js';
4
5
  import { Player, Room, } from './models/_index.js';
5
6
  import { Logger } from './utilities/logger.js';
@@ -9,7 +10,8 @@ import { attachInputHistory } from './utilities/input-history.js';
9
10
  import { TERMINAL_RESET_SEQUENCE } from './utilities/terminal-reset.js';
10
11
  import { readSave } from './utilities/persistence.js';
11
12
  import { requireCatalog, CatalogUnavailableError } from './utilities/catalog.js';
12
- import { authToken } from './utilities/cloud-saves.js';
13
+ import { requireHubSeed, HubSeedUnavailableError } from './utilities/hub-seed.js';
14
+ import { authToken, setCloudSessionId } from './utilities/cloud-saves.js';
13
15
  import { AI } from '../../../tools/ai/ai.class.js';
14
16
  import { acquireSessionLock, releaseSessionLock } from './utilities/session-lock.js';
15
17
  import { SAVE_VERSION } from './types/save-file.js';
@@ -141,6 +143,12 @@ export async function main(playerName, sceneSeed, options) {
141
143
  });
142
144
  // Initialize Logger
143
145
  const gameLog = Logger.getInstance(options.logFilePath, screen);
146
+ // The launcher's session id is the log directory's name; every push
147
+ // from this child names it (x-session-id) so a slot a browser hub
148
+ // holds open on the site refuses us (2026-09-12).
149
+ if (options.logFilePath) {
150
+ setCloudSessionId(path.basename(path.dirname(options.logFilePath)));
151
+ }
144
152
  // THE CATALOG GATE, child side (see sideQuest.sub.cmd.ts for the why).
145
153
  // The parent already refreshed and cached; this is the cheap /version
146
154
  // probe plus the insistence that SOMETHING is loadable before a Player
@@ -161,6 +169,28 @@ export async function main(playerName, sceneSeed, options) {
161
169
  }
162
170
  throw e;
163
171
  }
172
+ // THE HUB GATE, child side (2026-09-12): the launcher already fetched
173
+ // and cached; this is the cheap stamp probe plus the insistence that a
174
+ // hub is held. The seed the child PLAYS is the one in launch.json (the
175
+ // launcher's served copy) -- this only refuses a boot with no hub.
176
+ if (sceneSeed.hub === true) {
177
+ try {
178
+ const hub = await requireHubSeed({ token: authToken() });
179
+ gameLog.write(`Hub seed: ${hub.decision} -- "${hub.seed.name}" v${hub.stamp.version} (${hub.stamp.id}, ${hub.origin}).`);
180
+ }
181
+ catch (e) {
182
+ if (e instanceof HubSeedUnavailableError) {
183
+ try {
184
+ screen.destroy();
185
+ }
186
+ catch { /* the terminal is what matters */ }
187
+ process.stderr.write(`${TERMINAL_RESET_SEQUENCE}${e.message}
188
+ `);
189
+ process.exit(1);
190
+ }
191
+ throw e;
192
+ }
193
+ }
164
194
  // NOTHING prints through blessed but blessed (player report: the
165
195
  // SDK's ddp-client vomited raw "stream error ..." lines across a live
166
196
  // session while the server bounced). Once the screen owns the
@@ -223,8 +253,10 @@ export async function main(playerName, sceneSeed, options) {
223
253
  outfitArchetype(player, archetype);
224
254
  player.gender = options.gender;
225
255
  }
226
- // Create the game instance
227
- const game = await Game.getInstance(player, resumeSave?.hubSeed ?? sceneSeed, resumeSave);
256
+ // Create the game instance. A resume plays the SERVED hub seed
257
+ // (sceneSeed, from launch.json) with the save's overlay over it --
258
+ // never the copy an older save embedded (2026-09-12).
259
+ const game = await Game.getInstance(player, sceneSeed, resumeSave);
228
260
  const ready = await game.init(options, screen);
229
261
  // The quit seal (player ruling: autosave on quit). A clean exit --
230
262
  // the quit verb, double-ESC, the C-c binding below -- all funnel
@@ -1,9 +1,8 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
- import * as http from 'http';
5
- import * as https from 'https';
6
4
  import { Logger } from './logger.js';
5
+ import { siteGet as transportGet } from './site-transport.js';
7
6
  import { Item } from '../models/item.js';
8
7
  import { Category, Size, Rating } from '../types/shared/item-enum.js';
9
8
  import { isGearRow, } from './catalog-data.js';
@@ -47,9 +46,6 @@ export const ROLE_DOMAINS = {
47
46
  'Data Broker': ['Cyberdeck', 'Software', 'Commlink', 'Electronics', 'Glasses', 'FakeSIN', 'License', 'Credstick'],
48
47
  'Vendor': ['Food', 'Drink', 'Clothing', 'Boots', 'Gloves', 'Hat', 'Mask', 'Wristband'],
49
48
  };
50
- const DEV = process.env.MAKA_DEV === 'True';
51
- const HOSTNAME = DEV ? 'localhost' : 'www.maka-cli.com';
52
- const PORT = DEV ? Number(process.env.MAKA_DEV_PORT ?? 3000) : 443;
53
49
  const TIMEOUT_MS = 8000;
54
50
  /**
55
51
  * THE CACHE FILE, and an escape hatch that exists because I poisoned it.
@@ -179,22 +175,12 @@ export function applyLiveCatalog(items, version) {
179
175
  * persistence -> player -> catalog: a runtime import cycle, which in
180
176
  * this repo means 'Cannot access X before initialization' and a couple
181
177
  * of hundred red suites. The caller already holds the token.
178
+ *
179
+ * The socket itself lives in site-transport.ts since 2026-09-12, shared
180
+ * with the hub seed fetcher (hub-seed.ts) -- one host rule, one header.
182
181
  */
183
182
  async function siteGet(apiPath, token) {
184
- return new Promise((resolve, reject) => {
185
- const mod = DEV ? http : https;
186
- const req = mod.request({
187
- hostname: HOSTNAME, port: PORT, path: apiPath, method: 'GET', timeout: TIMEOUT_MS,
188
- headers: token === undefined ? {} : { 'x-auth-token': token },
189
- }, res => {
190
- let body = '';
191
- res.on('data', c => { body += c; });
192
- res.on('end', () => resolve({ statusCode: res.statusCode ?? 0, body }));
193
- });
194
- req.on('timeout', () => req.destroy(new Error('timeout')));
195
- req.on('error', reject);
196
- req.end();
197
- });
183
+ return transportGet(apiPath, token, TIMEOUT_MS);
198
184
  }
199
185
  /** Fire-and-forget refresh from the site -- call once at session start;
200
186
  * the promise resolves when the live list (or a fallback) is settled. */
@@ -66,6 +66,7 @@ function request(method, apiPath, token, body) {
66
66
  timeout: TIMEOUT_MS,
67
67
  headers: {
68
68
  'x-auth-token': token,
69
+ ...(cloudSessionId && method === 'POST' ? { 'x-session-id': cloudSessionId } : {}),
69
70
  ...(payload !== undefined
70
71
  ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
71
72
  : {}),
@@ -152,6 +153,17 @@ function noteUnauthorized() {
152
153
  logger.write('Cloud sync off: 401 from maka-cli.com (token expired or logged out).');
153
154
  }
154
155
  }
156
+ /**
157
+ * THIS PROCESS'S SESSION ID, sent as x-session-id on every push so the
158
+ * site's lease check (game-saves-v1-rest-api.ts) can tell OUR push from
159
+ * a stranger's: a slot leased to a browser hub refuses a push that names
160
+ * a different session, and a push that names none is let through for
161
+ * old clients' sake. Set once by the launcher (its timestamp session id).
162
+ */
163
+ let cloudSessionId;
164
+ export function setCloudSessionId(id) {
165
+ cloudSessionId = id;
166
+ }
155
167
  /**
156
168
  * Mirrors a freshly-written local save to the cloud. Fire-and-forget
157
169
  * from the save beats (`void pushSave(...)`): on success the local file
@@ -0,0 +1,284 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { Logger } from './logger.js';
5
+ import { siteGet } from './site-transport.js';
6
+ import { parseCatalogVersion, isCacheStale, isUnverifiedRefusal, } from './catalog.js';
7
+ /**
8
+ * THE HUB SEED'S DELIVERY CHAIN (2026-09-12, mjmcee: "migrate the hub
9
+ * seed from local to the server"). The same shape as the catalog's
10
+ * (catalog.ts), for the same reason: a rule that lives in two places
11
+ * drifts, and the shipped scene2.json was the second place.
12
+ *
13
+ * maka-cli.com's GameHubSeeds collection is the ONLY source of the hub
14
+ * (Tacoma Purple Haze). It is fetched with the account's token and
15
+ * cached at ~/.meteor/.maka/side-quest-hub.json; offline or unreachable,
16
+ * the cache serves. THERE IS NO SHIPPED COPY: a machine with no cache and
17
+ * no connection has no hub, and requireHubSeed() says so before the game
18
+ * starts rather than inventing a district.
19
+ *
20
+ * THE SEED IS STATIC AND AUTHORED, never generated, and the hub is solo
21
+ * -- each runner's world is this seed plus their own overlay from the
22
+ * save. Every save records WHICH seed its overlay was captured against
23
+ * (hubSeedRef: id, version, contentHash -- refOf below) but the game
24
+ * always plays the LATEST served seed; applyHubOverlay (persistence.ts)
25
+ * is name-keyed and tolerant, and that tolerance is the drift contract.
26
+ *
27
+ * The version/staleness discipline is catalog.ts's, reused rather than
28
+ * copied: parseCatalogVersion (an allow-list, so an unversioned payload
29
+ * never poisons a cache), isCacheStale (server ahead, or same version
30
+ * with a different fingerprint), and the decision vocabulary. The one
31
+ * addition is 'no-route': the site is mid-deploy and has no hub route
32
+ * yet -- unlike the catalog there is no older list route to fall back
33
+ * to, so it refuses (with a cache, it plays from the cache).
34
+ */
35
+ // A FUNCTION, NOT A CONST: read at call time so a harness that sets
36
+ // MAKA_HUB_CACHE after this module loads is honoured (catalog.ts learned
37
+ // this the hard way when a bench poisoned the developer's real cache).
38
+ function cachePath() {
39
+ return process.env.MAKA_HUB_CACHE
40
+ ?? path.join(os.homedir(), '.meteor', '.maka', 'side-quest-hub.json');
41
+ }
42
+ export const HUB_STAMP_PATH = '/api/v1/game-hub/version';
43
+ export const HUB_SEED_PATH = '/api/v1/game-hub/seed';
44
+ const TIMEOUT_MS = 8000;
45
+ let liveSource;
46
+ let resolved;
47
+ let diskCacheDisabled = false;
48
+ function readCache() {
49
+ if (diskCacheDisabled)
50
+ return undefined;
51
+ try {
52
+ const parsed = JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
53
+ if (parsed?.seed && typeof parsed.seed === 'object' && Array.isArray(parsed.seed.rooms))
54
+ return parsed;
55
+ }
56
+ catch { /* no cache */ }
57
+ return undefined;
58
+ }
59
+ function writeCache(seed, stamp) {
60
+ try {
61
+ fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
62
+ fs.writeFileSync(cachePath(), JSON.stringify({
63
+ id: stamp.id, version: stamp.version, contentHash: stamp.contentHash,
64
+ fetchedAt: new Date().toISOString(), seed,
65
+ }), 'utf8');
66
+ }
67
+ catch (err) {
68
+ Logger.getInstance().write(`hub seed cache write failed: ${err}`);
69
+ }
70
+ }
71
+ function hashOf(raw) {
72
+ return typeof raw === 'string' && raw.trim() !== '' ? raw : undefined;
73
+ }
74
+ function idOf(raw) {
75
+ return typeof raw === 'string' && raw.trim() !== '' ? raw : undefined;
76
+ }
77
+ /** The seed with its stamp written onto it, so buildSaveFile can derive
78
+ * the save's hubSeedRef from the seed it is playing (refOf). */
79
+ function stamped(seed, stamp) {
80
+ return { ...seed, id: stamp.id, version: stamp.version, contentHash: stamp.contentHash };
81
+ }
82
+ /**
83
+ * RECORD WHAT ARRIVED -- from the network, or from a test. Separated
84
+ * from the fetch so the wiring is reachable without a socket.
85
+ */
86
+ export function applyLiveHubSeed(seed, stamp) {
87
+ liveSource = { stamp: { ...stamp }, seed: stamped(seed, stamp), origin: 'live' };
88
+ resolved = undefined;
89
+ }
90
+ /** The whole seed from the site. Resolves when the live seed (or the
91
+ * decision to serve the cache) is settled; never throws. */
92
+ export async function refreshHubSeed(token) {
93
+ const logger = Logger.getInstance();
94
+ try {
95
+ const res = await siteGet(HUB_SEED_PATH, token, TIMEOUT_MS);
96
+ if (res.statusCode !== 200)
97
+ throw new Error(`status ${res.statusCode}`);
98
+ const parsed = JSON.parse(res.body);
99
+ const seed = parsed?.seed;
100
+ if (!seed || typeof seed !== 'object' || !Array.isArray(seed.rooms) || seed.rooms.length === 0) {
101
+ throw new Error('empty hub seed payload');
102
+ }
103
+ const version = parseCatalogVersion(parsed.version);
104
+ const id = idOf(parsed.id);
105
+ if (version === undefined || id === undefined) {
106
+ // Loud, and NOT cached: a seed that cannot be compared cannot be
107
+ // shown to be newer than the cache, so it is served this session
108
+ // and fetched again next boot.
109
+ logger.write(`Hub seed: maka-cli.com sent "${seed.name}" with no usable stamp `
110
+ + `(id ${JSON.stringify(parsed.id)}, version ${JSON.stringify(parsed.version)}) -- served, not cached.`);
111
+ applyLiveHubSeed(seed, { id: id ?? 'unknown', version: version ?? 0, contentHash: hashOf(parsed.contentHash) });
112
+ return;
113
+ }
114
+ const stamp = { id, version, contentHash: hashOf(parsed.contentHash) };
115
+ applyLiveHubSeed(seed, stamp);
116
+ writeCache(seed, stamp);
117
+ logger.write(`Hub seed: "${seed.name}" v${version} (${id}) from maka-cli.com.`);
118
+ }
119
+ catch (err) {
120
+ resolved = undefined;
121
+ const cache = readCache();
122
+ logger.write(cache
123
+ ? `Hub seed: offline -- cache holds "${cache.seed.name}" (v${cache.version}). (${err.message})`
124
+ : `Hub seed: offline and no cache on this machine -- NO hub to serve. (${err.message})`);
125
+ }
126
+ }
127
+ /** The cache's own stamp, for the staleness comparison. */
128
+ export function hubCacheStamp(readCacheFn = readCache) {
129
+ const cached = readCacheFn();
130
+ const version = parseCatalogVersion(cached?.version);
131
+ const id = idOf(cached?.id);
132
+ if (!cached || version === undefined || id === undefined)
133
+ return undefined;
134
+ return { id, version, contentHash: hashOf(cached.contentHash) };
135
+ }
136
+ /**
137
+ * WHAT `maka play` CALLS. Two gates, in order -- signed in, then not
138
+ * stale -- and both the probe and the download are injectable so a test
139
+ * can SEE that nothing was downloaded.
140
+ */
141
+ export async function ensureHubSeed(input) {
142
+ const logger = Logger.getInstance();
143
+ const probe = input.probe ?? (() => siteGet(HUB_STAMP_PATH, input.token, TIMEOUT_MS));
144
+ const download = input.download ?? (() => refreshHubSeed(input.token));
145
+ const readCacheFn = input.readCacheFn ?? readCache;
146
+ if (input.token === undefined) {
147
+ const cached = hubCacheStamp(readCacheFn);
148
+ logger.write(cached
149
+ ? `Hub seed: not signed in -- serving the cached v${cached.version} (${cached.id}); no fetch.`
150
+ : 'Hub seed: not signed in and no cache -- NO hub to serve.');
151
+ return 'logged-out';
152
+ }
153
+ let stamp;
154
+ try {
155
+ stamp = await probe();
156
+ }
157
+ catch (err) {
158
+ logger.write(`Hub seed: could not reach maka-cli.com for a version check (${err.message}).`);
159
+ return 'offline';
160
+ }
161
+ if (stamp.statusCode === 404) {
162
+ // Unlike the catalog there is no older route to fall back to: a
163
+ // 404 here is a site mid-deploy (or a hub id the box never seeded).
164
+ // The cache, if any, plays; nothing is invented.
165
+ logger.write('Hub seed: maka-cli.com has no hub route yet -- serving the cache, if any.');
166
+ return 'no-route';
167
+ }
168
+ if (isUnverifiedRefusal(stamp.statusCode, stamp.body)) {
169
+ logger.write('Hub seed: maka-cli.com refused this account (403, email not verified).');
170
+ return 'unverified';
171
+ }
172
+ if (stamp.statusCode === 401 || stamp.statusCode === 403) {
173
+ logger.write(`Hub seed: maka-cli.com refused the session (${stamp.statusCode}) -- "maka login" to receive hub updates.`);
174
+ return 'unauthorized';
175
+ }
176
+ if (stamp.statusCode !== 200) {
177
+ logger.write(`Hub seed: version check returned status ${stamp.statusCode}.`);
178
+ return 'offline';
179
+ }
180
+ let server;
181
+ try {
182
+ const parsed = JSON.parse(stamp.body);
183
+ const version = parseCatalogVersion(parsed?.version);
184
+ const id = idOf(parsed?.id);
185
+ if (version !== undefined && id !== undefined)
186
+ server = { id, version, contentHash: hashOf(parsed?.contentHash) };
187
+ }
188
+ catch { /* handled below */ }
189
+ if (!server || server.version === 0) {
190
+ // An unseeded box (version 0) or a stamp that cannot be compared.
191
+ // Same rule as 404: nothing to order with, nothing invented.
192
+ logger.write('Hub seed: the version route sent no usable stamp (unseeded box?) -- serving the cache, if any.');
193
+ return 'no-route';
194
+ }
195
+ const cached = hubCacheStamp(readCacheFn);
196
+ const verdict = cached && cached.id !== server.id
197
+ ? { stale: true, why: `the server hosts "${server.id}", the cache holds "${cached.id}"` }
198
+ : isCacheStale(cached, { version: server.version, contentHash: server.contentHash });
199
+ if (!verdict.stale) {
200
+ logger.write(`Hub seed: ${verdict.why} -- nothing to download.`);
201
+ return 'fresh';
202
+ }
203
+ logger.write(`Hub seed: refreshing -- ${verdict.why}.`);
204
+ await download();
205
+ return 'refreshed';
206
+ }
207
+ /** Highest version wins; on a tie the fresher origin (live over cache). */
208
+ export function chooseHubSeed(live, cache) {
209
+ let best;
210
+ for (const candidate of [cache, live]) {
211
+ if (candidate && (best === undefined || candidate.stamp.version >= best.stamp.version))
212
+ best = candidate;
213
+ }
214
+ return best;
215
+ }
216
+ /** The best hub seed held right now, or undefined when none is. */
217
+ export function loadHubSeed(readCacheFn = readCache) {
218
+ if (resolved)
219
+ return resolved.seed;
220
+ const cached = readCacheFn();
221
+ const cacheStamp = hubCacheStamp(() => cached);
222
+ resolved = chooseHubSeed(liveSource, cached && cacheStamp ? { stamp: cacheStamp, seed: stamped(cached.seed, cacheStamp), origin: 'cache' } : undefined);
223
+ return resolved?.seed;
224
+ }
225
+ export function hubSeedStatus(readCacheFn = readCache) {
226
+ loadHubSeed(readCacheFn);
227
+ return resolved ? { origin: resolved.origin, stamp: { ...resolved.stamp }, name: resolved.seed.name } : undefined;
228
+ }
229
+ /** Drop every memo and take the machine's cache out of the contest. */
230
+ export function resetHubSeedForTest() {
231
+ liveSource = undefined;
232
+ resolved = undefined;
233
+ diskCacheDisabled = true;
234
+ }
235
+ export class HubSeedUnavailableError extends Error {
236
+ decision;
237
+ constructor(decision, message) {
238
+ super(message);
239
+ this.decision = decision;
240
+ this.name = 'HubSeedUnavailableError';
241
+ }
242
+ }
243
+ const HUB_REFUSALS = {
244
+ 'logged-out': 'The side quest needs your home district from maka-cli.com, and there is no cached copy on this machine. '
245
+ + 'Sign in with "maka login" and connect once; the cached hub then lets you play offline.',
246
+ 'unverified': 'Your email isn\'t verified yet. Run "maka register --resend-verification", click the link in the email, then relaunch.',
247
+ 'unauthorized': 'maka-cli.com refused this session, and there is no cached hub on this machine. Run "maka login", then relaunch.',
248
+ 'offline': 'Could not reach maka-cli.com for your home district, and there is no cached copy on this machine. '
249
+ + 'Connect once while signed in; after that the cache plays offline.',
250
+ 'no-route': 'maka-cli.com does not serve the hub yet -- the site is mid-deploy. Give it a few minutes and relaunch.',
251
+ };
252
+ /**
253
+ * THE BOOT GATE: ensureHubSeed, then insist that a seed is loadable.
254
+ * Throws HubSeedUnavailableError with a player-facing message otherwise
255
+ * -- or for 'unverified' regardless of the cache, as the catalog does.
256
+ */
257
+ export async function requireHubSeed(input) {
258
+ const decision = await ensureHubSeed(input);
259
+ const status = hubSeedStatus(input.readCacheFn);
260
+ const seed = loadHubSeed(input.readCacheFn);
261
+ if (decision === 'unverified' || !status || !seed) {
262
+ const message = HUB_REFUSALS[decision]
263
+ ?? `Your home district could not be loaded (${decision}). Connect to maka-cli.com while signed in and try again.`;
264
+ throw new HubSeedUnavailableError(decision, message);
265
+ }
266
+ return { decision, seed, stamp: status.stamp, origin: status.origin };
267
+ }
268
+ /**
269
+ * WHAT A SAVE RECORDS ABOUT ITS HUB: the stamp of the seed the overlay
270
+ * was captured against. Undefined for an unstamped seed (a hand-written
271
+ * fixture, a scene handed straight to Game.getInstance) -- the caller
272
+ * decides whether that is worth a log line. While the embedded copy is
273
+ * still dual-written (engine 1.51.0) nothing is lost either way.
274
+ */
275
+ export function hubSeedRefOf(seed) {
276
+ if (!seed)
277
+ return undefined;
278
+ const id = idOf(seed.id);
279
+ const version = parseCatalogVersion(seed.version);
280
+ if (id === undefined || version === undefined)
281
+ return undefined;
282
+ return { id, version, contentHash: hashOf(seed.contentHash) ?? '' };
283
+ }
284
+ //# sourceMappingURL=hub-seed.js.map
@@ -0,0 +1,61 @@
1
+ import * as http from 'http';
2
+ import * as https from 'https';
3
+ /**
4
+ * ONE TRANSPORT TO maka-cli.com (2026-09-12).
5
+ *
6
+ * The same twenty lines -- the MAKA_DEV host rule, the x-auth-token
7
+ * header, a short timeout, a status-and-body promise -- were copied into
8
+ * cloud-saves.ts, catalog.ts, social.ts, session-telemetry.ts and
9
+ * backlog.ts, so a transport-level fix landed five times or not at all.
10
+ * The hub seed fetcher (hub-seed.ts) would have been copy #6; instead it
11
+ * and catalog.ts share this one. The other four are NOT migrated here
12
+ * (deliberately: each carries its own retry/queue semantics worth a
13
+ * separate pass), and they still work exactly as they did.
14
+ *
15
+ * THE TOKEN IS PASSED IN, never sourced here, and this module imports
16
+ * nothing from the game. Reading the token would mean importing
17
+ * cloud-saves.ts, and catalog -> cloud-saves -> persistence -> player ->
18
+ * catalog is a runtime import cycle ('Cannot access X before
19
+ * initialization', a couple of hundred red suites). Every caller already
20
+ * holds the token.
21
+ *
22
+ * STATUS IS REPORTED, NOT THROWN. A stamp probe has to tell a 404 (no
23
+ * such route) from a 401 (a stale token) from a dead socket, and those
24
+ * are three different decisions; only the socket failing rejects.
25
+ */
26
+ const DEV = process.env.MAKA_DEV === 'True';
27
+ const HOSTNAME = DEV ? 'localhost' : 'www.maka-cli.com';
28
+ const PORT = DEV ? Number(process.env.MAKA_DEV_PORT ?? 3000) : 443;
29
+ export function siteRequest(method, apiPath, token, body, timeoutMs = 8000) {
30
+ return new Promise((resolve, reject) => {
31
+ const payload = body === undefined ? undefined : JSON.stringify(body);
32
+ const mod = DEV ? http : https;
33
+ const req = mod.request({
34
+ hostname: HOSTNAME,
35
+ port: PORT,
36
+ path: apiPath,
37
+ method,
38
+ timeout: timeoutMs,
39
+ headers: {
40
+ ...(token === undefined ? {} : { 'x-auth-token': token }),
41
+ ...(payload !== undefined
42
+ ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
43
+ : {}),
44
+ },
45
+ }, res => {
46
+ let data = '';
47
+ res.on('data', chunk => { data += chunk; });
48
+ res.on('end', () => resolve({ statusCode: res.statusCode ?? 0, body: data }));
49
+ });
50
+ req.on('timeout', () => req.destroy(new Error('timeout')));
51
+ req.on('error', reject);
52
+ if (payload !== undefined)
53
+ req.write(payload);
54
+ req.end();
55
+ });
56
+ }
57
+ /** One GET, status reported rather than thrown. */
58
+ export function siteGet(apiPath, token, timeoutMs = 8000) {
59
+ return siteRequest('GET', apiPath, token, undefined, timeoutMs);
60
+ }
61
+ //# sourceMappingURL=site-transport.js.map
@@ -441,7 +441,7 @@ const ShadowrunCommand = Command.create({
441
441
  }
442
442
  // Lazy game-graph load (see the comment at the imports above). One
443
443
  // parallel block so the handler body below is otherwise untouched.
444
- const [{ SceneSeedGenerator }, { ARCHETYPES, getArchetype, outfitArchetype }, { Player }, { Room }, { Logger }, { savesDir, savePathFor, listSaves, deleteSave, readSave, writeSaveAtomic }, { isLockHeld }, { SAVE_VERSION }, { authToken, fetchSummaries, fetchSave, pushSave, deleteCloudSave, reconcile },] = await Promise.all([
444
+ const [{ SceneSeedGenerator }, { ARCHETYPES, getArchetype, outfitArchetype }, { Player }, { Room }, { Logger }, { savesDir, savePathFor, listSaves, deleteSave, readSave, writeSaveAtomic }, { isLockHeld }, { SAVE_VERSION }, { authToken, fetchSummaries, fetchSave, pushSave, deleteCloudSave, reconcile, setCloudSessionId },] = await Promise.all([
445
445
  import('./sideQuest/factories/scene-seed-generator.js'),
446
446
  import('./sideQuest/archetypes.js'),
447
447
  import('./sideQuest/models/player.js'),
@@ -453,13 +453,19 @@ const ShadowrunCommand = Command.create({
453
453
  import('./sideQuest/utilities/cloud-saves.js'),
454
454
  ]);
455
455
  const shadowrunConfig1 = require('./sideQuest/scenes/scene1.json');
456
- const shadowrunConfig2 = require('./sideQuest/scenes/scene2.json');
456
+ // THE HUB IS NOT A FILE ANY MORE (2026-09-12): scene2.json left the
457
+ // package. Tacoma Purple Haze is served by maka-cli.com and cached
458
+ // like the catalog -- see the hub-seed gate below, which sets this.
459
+ let hostedHub;
457
460
  try {
458
461
  const makaDir = this.fsi.getGlobalMakaDir();
459
462
  // Each play session gets its own directory (timestamp-named) instead of
460
463
  // appending to one ever-growing log file. Generated AI scenes are saved
461
464
  // alongside the log for that same session -- see SceneSeedGenerator.
462
465
  const sessionId = new Date().toISOString().replace(/[:.]/g, '-');
466
+ // Every push this process makes names its session, so a slot held
467
+ // open by a browser hub on the site refuses it (2026-09-12).
468
+ setCloudSessionId(sessionId);
463
469
  const sessionDir = path.join(makaDir, 'side-quest-sessions', sessionId);
464
470
  const logFilePath = path.join(sessionDir, 'game.log');
465
471
  // PLAY-SESSION RECORDING CONSENT (opt-in ruling 2026-08-24): the
@@ -582,6 +588,27 @@ const ShadowrunCommand = Command.create({
582
588
  throw e;
583
589
  }
584
590
  }
591
+ // THE HUB GATE (2026-09-12), the catalog's twin: the home district
592
+ // is served by maka-cli.com and cached on this machine; with neither
593
+ // there is no hub to start in, and a game that invented one would be
594
+ // the second source of truth the migration removed. A resume plays
595
+ // the LATEST served seed too -- the save's overlay is laid over it
596
+ // by name -- so this runs before the Continue menu as well.
597
+ {
598
+ const { requireHubSeed, HubSeedUnavailableError } = await import('./sideQuest/utilities/hub-seed.js');
599
+ try {
600
+ const hub = await requireHubSeed({ token: authToken() });
601
+ hostedHub = hub.seed;
602
+ Logger.getInstance().write(`Hub seed: ${hub.decision} -- serving "${hub.seed.name}" v${hub.stamp.version} (${hub.stamp.id}, ${hub.origin}).`);
603
+ }
604
+ catch (e) {
605
+ if (e instanceof HubSeedUnavailableError) {
606
+ Log.error(e.message);
607
+ process.exit(1);
608
+ }
609
+ throw e;
610
+ }
611
+ }
585
612
  // WHO GETS OFFERED THE SMOKE-TEST SCENE (playtest 2026-08-27:
586
613
  // "let's remove Seattle Underground for non playtesters"). Fired
587
614
  // HERE, as early as the login is known, and awaited much later at
@@ -597,6 +624,9 @@ const ShadowrunCommand = Command.create({
597
624
  // only people who are supposed to see it.
598
625
  const playtesterProbe = (await import('./sideQuest/utilities/playtester.js'))
599
626
  .probePlaytesterStanding({ anonymous: !authToken() });
627
+ // Slots the site says are LIVE somewhere else (a browser hub holds
628
+ // the lease) -- filled from the summaries below, read at the menu.
629
+ const leasedSlugs = new Map();
600
630
  if (authToken()) {
601
631
  const fsMod = await import('fs');
602
632
  // Tombstones first: a runner who died offline still owes the
@@ -613,6 +643,9 @@ const ShadowrunCommand = Command.create({
613
643
  const deadSlugs = new Set(tombstones.map(t => t.replace(/\.dead$/, '')));
614
644
  const cloud = await fetchSummaries();
615
645
  if (cloud.ok) {
646
+ for (const c of cloud.saves)
647
+ if (c.leasedBy)
648
+ leasedSlugs.set(c.slug, c.leasedBy);
616
649
  const cloudSlugs = new Set(cloud.saves.map(c => c.slug));
617
650
  for (const c of cloud.saves) {
618
651
  if (c.version !== SAVE_VERSION || deadSlugs.has(c.slug))
@@ -756,6 +789,17 @@ const ShadowrunCommand = Command.create({
756
789
  }
757
790
  resumeSavePath = pick;
758
791
  resumeSave = liveSlots.find(r => r.filePath === resumeSavePath).save;
792
+ // LIVE SOMEWHERE ELSE (2026-09-12): the site leases a slot to a
793
+ // browser hub session; a terminal must not play the same life at
794
+ // the same time. Listed, so the player sees the runner exists;
795
+ // refused, so they leave the hub there first.
796
+ const heldElsewhere = leasedSlugs.get(path.basename(resumeSavePath, '.json'));
797
+ if (heldElsewhere) {
798
+ Log.error(`${resumeSave.playerName} is live on maka-cli.com right now (a browser session holds the slot). Leave the hub there first, then continue here.`);
799
+ resumeSave = undefined;
800
+ resumeSavePath = undefined;
801
+ continue menu;
802
+ }
759
803
  // One terminal per runner (see utilities/session-lock.ts): a
760
804
  // slot with a live session refuses here, at the menu, instead
761
805
  // of after the screen has already flashed up. The game process
@@ -1057,7 +1101,7 @@ const ShadowrunCommand = Command.create({
1057
1101
  // joins the list only for play-testers (below), and remains the
1058
1102
  // generation-failure fallback for everyone.
1059
1103
  const choices = [
1060
- { name: shadowrunConfig2.name, value: shadowrunConfig2 },
1104
+ { name: hostedHub.name, value: hostedHub },
1061
1105
  ];
1062
1106
  // SEATTLE UNDERGROUND IS A SMOKE TEST, not content, and it sat in
1063
1107
  // this menu next to Tacoma Purple Haze as though it were a job you
@@ -1083,10 +1127,13 @@ const ShadowrunCommand = Command.create({
1083
1127
  if (gameOptions.useAi && AI.canGenerate()) {
1084
1128
  choices.push({ name: 'Generate a new Run (AI)', value: GENERATE_NEW_RUN });
1085
1129
  }
1086
- // Resuming skips the scene pick: the save EMBEDS its hub seed, so an
1087
- // old runner's world never depends on today's scene2.json.
1130
+ // Resuming skips the scene pick: the runner's home is the hub
1131
+ // maka-cli.com serves TODAY (the gate above), never the copy an
1132
+ // older save embedded -- the save's overlay is laid over the served
1133
+ // seed by name (persistence.ts applyHubOverlay), which is how a hub
1134
+ // edit reaches a returning runner without a CLI release.
1088
1135
  let sideQuestAnswer = resumeSave
1089
- ? resumeSave.hubSeed
1136
+ ? hostedHub
1090
1137
  : await Actions.select({
1091
1138
  message: 'Which Run would you like to play?',
1092
1139
  name: 'sideQuestAnswer',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.175.1",
3
+ "version": "5.177.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",