@myvillage/cli 2.8.0 → 2.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "2.8.0",
3
+ "version": "2.8.1",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -136,6 +136,9 @@ function requestHostContext(timeoutMs) {
136
136
  // `npm run dev` or the Studio preview — there is no platform session, so it
137
137
  // runs in DEV MODE against the public staging match server: open two browser
138
138
  // tabs and use the same code (or both press quick match) to play yourself.
139
+ // Inside a host that doesn't provide a session (mode 'unavailable'), every
140
+ // call fails with code 'host_unavailable' — real players never land on the
141
+ // dev server.
139
142
 
140
143
  const DEFAULT_DEV_SERVER = 'https://myvillage-realtime-staging.myvillage-realtime.workers.dev';
141
144
  const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
@@ -175,7 +178,7 @@ function devPlayerId() {
175
178
 
176
179
  class Multiplayer {
177
180
  constructor(ctx, options) {
178
- this.mode = ctx ? 'platform' : 'dev';
181
+ this.mode = ctx ? 'platform' : standalone ? 'dev' : 'unavailable';
179
182
  this._ctx = ctx;
180
183
  this._opts = options;
181
184
  this._net = null;
@@ -189,18 +192,21 @@ class Multiplayer {
189
192
 
190
193
  /** Host a match. visibility: 'private' (code only) or 'village' (listed + quick match). */
191
194
  async host({ visibility = 'private', maxPlayers, lobby } = {}) {
195
+ this._requireAvailable();
192
196
  if (this.mode === 'dev') return this._devMatch(randomCode(), true, lobby);
193
197
  return this._enter(await this._api('POST', `/api/v1/games/${this._game()}/matches`, { visibility, maxPlayers, lobby, platform: this._ctx.platform }));
194
198
  }
195
199
 
196
200
  /** Join a match by its code (forgiving: "abc-234" works). */
197
201
  async join(code) {
202
+ this._requireAvailable();
198
203
  if (this.mode === 'dev') return this._devMatch(normalizeCode(code), false);
199
204
  return this._enter(await this._api('POST', `/api/v1/games/${this._game()}/matches/join`, { code, platform: this._ctx.platform }));
200
205
  }
201
206
 
202
207
  /** Join the fullest open match in the player's villages, or host one. */
203
208
  async quickMatch({ lobby } = {}) {
209
+ this._requireAvailable();
204
210
  // Finished match rooms can't be reused, so dev quick match rotates rooms
205
211
  // every 10 minutes; tabs that press it within the same window meet.
206
212
  if (this.mode === 'dev') return this._devMatch('QUICK' + Math.floor(Date.now() / 600000).toString(36).toUpperCase(), false, lobby);
@@ -209,6 +215,7 @@ class Multiplayer {
209
215
 
210
216
  /** Open village matches the player can join, plus matches they're in. */
211
217
  async list() {
218
+ this._requireAvailable();
212
219
  if (this.mode === 'dev') return this.match ? [this.match] : [];
213
220
  return (await this._api('GET', `/api/v1/games/${this._game()}/matches`)).matches;
214
221
  }
@@ -285,6 +292,7 @@ class Multiplayer {
285
292
  * (src/myvillage-realtime.js). Call again after a disconnect to rejoin.
286
293
  */
287
294
  async connect() {
295
+ this._requireAvailable();
288
296
  if (!this.match) throw new MultiplayerError('no_match', 'Host, join or quick match first.');
289
297
  const ticket = await this._freshTicket();
290
298
  const { ArenaClient } = await import('./myvillage-realtime.js');
@@ -297,6 +305,12 @@ class Multiplayer {
297
305
 
298
306
  // ── internals ──
299
307
 
308
+ _requireAvailable() {
309
+ if (this.mode === 'unavailable') {
310
+ throw new MultiplayerError('host_unavailable', "Multiplayer isn't available here yet.");
311
+ }
312
+ }
313
+
300
314
  _game() {
301
315
  return encodeURIComponent(this._ctx.game);
302
316
  }
@@ -320,9 +334,29 @@ class Multiplayer {
320
334
  _enter(result) {
321
335
  this.match = result.match;
322
336
  this._ticket = result.realtime;
337
+ this._leaveOnUnload();
323
338
  return this.match;
324
339
  }
325
340
 
341
+ // Closing the page (or the host's Exit) frees the player's seat in the lobby
342
+ // straight away rather than when the lobby expires.
343
+ _leaveOnUnload() {
344
+ if (this._unloadHooked || !hasWindow) return;
345
+ this._unloadHooked = true;
346
+ window.addEventListener('pagehide', () => {
347
+ if (!this.match || this.mode !== 'platform') return;
348
+ try {
349
+ fetch(this._ctx.apiBase + '/api/v1/matches/' + this.match.id + '/leave', {
350
+ method: 'POST',
351
+ keepalive: true,
352
+ headers: { Authorization: 'Bearer ' + this._ctx.playToken },
353
+ });
354
+ } catch (_) {
355
+ // best effort
356
+ }
357
+ });
358
+ }
359
+
326
360
  async _update(body) {
327
361
  if (!this.match) throw new MultiplayerError('no_match', 'Not in a match.');
328
362
  if (this.mode === 'dev') {
@@ -421,7 +455,7 @@ export const MyVillageGame = {
421
455
  * devServer — dev-mode match server (defaults to the MyVillage staging server)
422
456
  */
423
457
  async multiplayer(options = {}) {
424
- const ctx = await requestHostContext(standalone ? 300 : 3000);
458
+ const ctx = await requestHostContext(standalone ? 300 : 10000);
425
459
  return new Multiplayer(ctx, options);
426
460
  },
427
461
  };
@@ -86,6 +86,8 @@ function friendlyError(err) {
86
86
  return 'That match has already started.';
87
87
  case 'not_in_village':
88
88
  return 'You can only play with people from your village.';
89
+ case 'host_unavailable':
90
+ return "Multiplayer isn't available in this player yet.";
89
91
  case 'no_village':
90
92
  return 'Join a village to play multiplayer — you play with your village.';
91
93
  default: