@myvillage/cli 2.8.0 → 2.8.2

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.2",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -136,9 +136,13 @@ 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
- const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
144
+ // No vowels (or look-alikes), so random codes can't spell words.
145
+ const CODE_ALPHABET = 'BCDFGHJKMNPQRSTVWXZ23456789';
142
146
 
143
147
  export class MultiplayerError extends Error {
144
148
  constructor(code, message, status) {
@@ -175,40 +179,51 @@ function devPlayerId() {
175
179
 
176
180
  class Multiplayer {
177
181
  constructor(ctx, options) {
178
- this.mode = ctx ? 'platform' : 'dev';
182
+ this.mode = ctx ? 'platform' : standalone ? 'dev' : 'unavailable';
179
183
  this._ctx = ctx;
180
184
  this._opts = options;
181
185
  this._net = null;
182
186
  this._ticket = null;
183
187
  /** The current match (see MatchView in the platform docs), or null. */
184
188
  this.match = null;
185
- this.me = { displayName: ctx ? ctx.player.displayName : options.name || 'Player ' + randomCode().slice(0, 3) };
189
+ // Digits only: random letters can spell words.
190
+ this.me = { displayName: ctx ? ctx.player.displayName : options.name || 'Player ' + (100 + Math.floor(Math.random() * 900)) };
186
191
  }
187
192
 
188
193
  // ── lobby plumbing ──
189
194
 
190
195
  /** Host a match. visibility: 'private' (code only) or 'village' (listed + quick match). */
191
196
  async host({ visibility = 'private', maxPlayers, lobby } = {}) {
197
+ this._requireAvailable();
192
198
  if (this.mode === 'dev') return this._devMatch(randomCode(), true, lobby);
193
199
  return this._enter(await this._api('POST', `/api/v1/games/${this._game()}/matches`, { visibility, maxPlayers, lobby, platform: this._ctx.platform }));
194
200
  }
195
201
 
196
202
  /** Join a match by its code (forgiving: "abc-234" works). */
197
203
  async join(code) {
204
+ this._requireAvailable();
198
205
  if (this.mode === 'dev') return this._devMatch(normalizeCode(code), false);
199
206
  return this._enter(await this._api('POST', `/api/v1/games/${this._game()}/matches/join`, { code, platform: this._ctx.platform }));
200
207
  }
201
208
 
202
209
  /** Join the fullest open match in the player's villages, or host one. */
203
210
  async quickMatch({ lobby } = {}) {
211
+ this._requireAvailable();
204
212
  // Finished match rooms can't be reused, so dev quick match rotates rooms
205
- // every 10 minutes; tabs that press it within the same window meet.
206
- if (this.mode === 'dev') return this._devMatch('QUICK' + Math.floor(Date.now() / 600000).toString(36).toUpperCase(), false, lobby);
213
+ // every 10 minutes, and connect() steps through numbered rooms in that
214
+ // window past any that are finished or full. Tabs follow the same
215
+ // sequence, so they still meet.
216
+ if (this.mode === 'dev') {
217
+ this._devQuickBase = 'QUICK' + Math.floor(Date.now() / 600000).toString(36).toUpperCase();
218
+ this._devQuickTry = 0;
219
+ return this._devMatch(this._devQuickBase, false, lobby);
220
+ }
207
221
  return this._enter(await this._api('POST', `/api/v1/games/${this._game()}/matches/quick`, { platform: this._ctx.platform, lobby }));
208
222
  }
209
223
 
210
224
  /** Open village matches the player can join, plus matches they're in. */
211
225
  async list() {
226
+ this._requireAvailable();
212
227
  if (this.mode === 'dev') return this.match ? [this.match] : [];
213
228
  return (await this._api('GET', `/api/v1/games/${this._game()}/matches`)).matches;
214
229
  }
@@ -285,11 +300,25 @@ class Multiplayer {
285
300
  * (src/myvillage-realtime.js). Call again after a disconnect to rejoin.
286
301
  */
287
302
  async connect() {
303
+ this._requireAvailable();
288
304
  if (!this.match) throw new MultiplayerError('no_match', 'Host, join or quick match first.');
289
- const ticket = await this._freshTicket();
290
305
  const { ArenaClient } = await import('./myvillage-realtime.js');
291
- const net = new ArenaClient({ wsUrl: ticket.wsUrl, ticket: ticket.ticket });
292
- await net.connect();
306
+ let net;
307
+ for (;;) {
308
+ const ticket = await this._freshTicket();
309
+ net = new ArenaClient({ wsUrl: ticket.wsUrl, ticket: ticket.ticket });
310
+ try {
311
+ await net.connect();
312
+ break;
313
+ } catch (err) {
314
+ // Dev quick match: that room is finished or full — try the next one.
315
+ const devQuick = this.mode === 'dev' && this._devQuickBase && this.match.code.startsWith(this._devQuickBase);
316
+ if (!devQuick || this._devQuickTry >= 20 || !/finished|full/i.test(String(err && err.message))) throw err;
317
+ this._devQuickTry++;
318
+ this._ticket = null;
319
+ this._devMatch(this._devQuickBase + '-' + this._devQuickTry, false, this.match.lobby);
320
+ }
321
+ }
293
322
  this._ticket = null; // single use: a reconnect fetches a fresh one
294
323
  this._net = net;
295
324
  return net;
@@ -297,6 +326,12 @@ class Multiplayer {
297
326
 
298
327
  // ── internals ──
299
328
 
329
+ _requireAvailable() {
330
+ if (this.mode === 'unavailable') {
331
+ throw new MultiplayerError('host_unavailable', "Multiplayer isn't available here yet.");
332
+ }
333
+ }
334
+
300
335
  _game() {
301
336
  return encodeURIComponent(this._ctx.game);
302
337
  }
@@ -320,9 +355,29 @@ class Multiplayer {
320
355
  _enter(result) {
321
356
  this.match = result.match;
322
357
  this._ticket = result.realtime;
358
+ this._leaveOnUnload();
323
359
  return this.match;
324
360
  }
325
361
 
362
+ // Closing the page (or the host's Exit) frees the player's seat in the lobby
363
+ // straight away rather than when the lobby expires.
364
+ _leaveOnUnload() {
365
+ if (this._unloadHooked || !hasWindow) return;
366
+ this._unloadHooked = true;
367
+ window.addEventListener('pagehide', () => {
368
+ if (!this.match || this.mode !== 'platform') return;
369
+ try {
370
+ fetch(this._ctx.apiBase + '/api/v1/matches/' + this.match.id + '/leave', {
371
+ method: 'POST',
372
+ keepalive: true,
373
+ headers: { Authorization: 'Bearer ' + this._ctx.playToken },
374
+ });
375
+ } catch (_) {
376
+ // best effort
377
+ }
378
+ });
379
+ }
380
+
326
381
  async _update(body) {
327
382
  if (!this.match) throw new MultiplayerError('no_match', 'Not in a match.');
328
383
  if (this.mode === 'dev') {
@@ -421,7 +476,7 @@ export const MyVillageGame = {
421
476
  * devServer — dev-mode match server (defaults to the MyVillage staging server)
422
477
  */
423
478
  async multiplayer(options = {}) {
424
- const ctx = await requestHostContext(standalone ? 300 : 3000);
479
+ const ctx = await requestHostContext(standalone ? 300 : 10000);
425
480
  return new Multiplayer(ctx, options);
426
481
  },
427
482
  };
@@ -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: