@xgamesfun/xcore 0.1.0 → 0.2.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.
Files changed (72) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +183 -84
  3. package/bin/xcore.mjs +13 -8
  4. package/commands/add.mjs +11 -8
  5. package/commands/adopt.mjs +9 -7
  6. package/commands/backend.mjs +2 -2
  7. package/commands/check.mjs +4 -4
  8. package/commands/db.mjs +218 -0
  9. package/commands/dev.mjs +8 -6
  10. package/commands/domain.mjs +8 -8
  11. package/commands/embed.mjs +3 -3
  12. package/commands/files.mjs +106 -0
  13. package/commands/login.mjs +9 -17
  14. package/commands/logout.mjs +15 -4
  15. package/commands/loot.mjs +2 -2
  16. package/commands/plan.mjs +22 -19
  17. package/commands/publish.mjs +54 -21
  18. package/commands/secret.mjs +6 -6
  19. package/docs/SERVER.md +218 -18
  20. package/lib/add.mjs +265 -40
  21. package/lib/browser.mjs +11 -2
  22. package/lib/check.mjs +10 -9
  23. package/lib/cli.mjs +24 -9
  24. package/lib/client/gamesfun.js +242 -122
  25. package/lib/client/godot/gamesfun.gd +17 -11
  26. package/lib/client/godot/gamesfun_peer.gd +2 -2
  27. package/lib/client/server/0001_init.sql +10 -0
  28. package/lib/client/server/database.js +271 -109
  29. package/lib/client/server/gf-db.js +55 -0
  30. package/lib/client/server/gf-docs.js +56 -0
  31. package/lib/client/server/rooms.js +16 -9
  32. package/lib/client/server/rpc.js +7 -7
  33. package/lib/client/server/stock.js +37 -0
  34. package/lib/client/server/vouchers.js +127 -46
  35. package/lib/client/unity/GamesFun.cs +15 -12
  36. package/lib/client/unity/GamesFun.jslib +1 -1
  37. package/lib/config-dir.mjs +1 -1
  38. package/lib/credentials.mjs +10 -5
  39. package/lib/db.mjs +355 -0
  40. package/lib/dev/d1.mjs +486 -0
  41. package/lib/dev/files.mjs +247 -0
  42. package/lib/dev/gf-local.js +69 -3
  43. package/lib/dev/index.mjs +68 -20
  44. package/lib/dev/limits.mjs +2 -2
  45. package/lib/dev/platform.mjs +111 -12
  46. package/lib/dev/server.mjs +54 -5
  47. package/lib/dev/tools.mjs +71 -18
  48. package/lib/files.mjs +208 -0
  49. package/lib/open.mjs +1 -1
  50. package/lib/play-policy.mjs +14 -5
  51. package/lib/publish/adopt.mjs +73 -33
  52. package/lib/publish/client.mjs +130 -20
  53. package/lib/publish/gate.mjs +4 -2
  54. package/lib/publish/hosting.mjs +5 -5
  55. package/lib/publish/index.mjs +162 -40
  56. package/lib/publish/oidc.mjs +26 -5
  57. package/lib/publish/page.mjs +1 -1
  58. package/lib/publish/plans.mjs +19 -12
  59. package/lib/publish/scan.mjs +78 -102
  60. package/lib/publish/secrets.mjs +4 -5
  61. package/lib/publish/site.mjs +9 -10
  62. package/lib/publish/tree.mjs +8 -8
  63. package/lib/secret-patterns.mjs +53 -16
  64. package/lib/secret-store.mjs +2 -2
  65. package/lib/server/bundle.mjs +113 -13
  66. package/lib/server/contract.mjs +440 -16
  67. package/lib/server/data-calls.mjs +136 -0
  68. package/lib/server/secrets.mjs +9 -7
  69. package/lib/solana-rpc.mjs +5 -5
  70. package/lib/state-folder.mjs +23 -0
  71. package/package.json +4 -4
  72. package/skills/bring-your-own-game/SKILL.md +10 -9
@@ -1,15 +1,15 @@
1
1
  // gamesfun.js: sign-in, saves, leaderboards, the game's own database and multiplayer rooms for a game published on
2
- // games.fun, from any engine. One file with no dependencies: a <script src="gamesfun.js"> in the page (or an import of it), and Godot's and
2
+ // xgames.fun, from any engine. One file with no dependencies: a <script src="gamesfun.js"> in the page (or an import of it), and Godot's and
3
3
  // Unity's glue (xcore add signin) load the same file. It sets globalThis.GamesFun; every call answers
4
- // {data, error, status} and none throws, so a game plays on, as a guest, wherever games.fun is not there. A refusal from
5
- // games.fun also carries code, its word for why (for example 'server-only': a board only the game's server writes).
4
+ // {data, error, status} and none throws, so a game plays on, as a guest, wherever xgames.fun is not there. A refusal from
5
+ // xgames.fun also carries code, its word for why (for example 'server-only': a board only the game's server writes).
6
6
  //
7
7
  // const gf = GamesFun;
8
- // await gf.ready; // what the page already knows (a sign-in the games.fun page has)
8
+ // await gf.ready; // what the page already knows (a sign-in the xgames.fun page has)
9
9
  // gf.player; // null, or {id, name, level, wallet, x}
10
10
  // gf.local; // true on this machine (localhost, xcore check, xcore dev)
11
11
  // gf.dev; // true under xcore dev, where the game's server, saves and
12
- // // sign-in answer as games.fun's will: "no server here" is
12
+ // // sign-in answer as xgames.fun's will: "no server here" is
13
13
  // // gf.local && !gf.dev, never gf.local alone
14
14
  // await gf.signIn(); // from a click: the player signs in → player | null
15
15
  // gf.onChange(player => …); // sign-in and sign-out
@@ -17,26 +17,27 @@
17
17
  // // when true, only as a quiet line: X's login hides the window's state
18
18
  // await gf.saves.put('main', {level: 3}); // this player's saves: JSON, 64 KB each, 16 a player
19
19
  // await gf.scores.submit('best', 1200); // leaderboards: a player's best is kept
20
- // await gf.db.add('levels', {name: 'Moon'}); // the game's own database (xcore add database)
20
+ // await gf.db.add('levels', {name: 'Moon'}); // the game's own database (xcore add database): each player
21
+ // // their own documents; gf.db.list pages with {after}
21
22
  // await gf.api('scores', {method: 'POST', body: {}}); // the game's own server (server/), signed in
22
23
  // const {data: room} = await gf.rooms.join(gf.rooms.fromLink() ?? gf.rooms.code()); // multiplayer (xcore add multiplayer)
23
24
  // room.on('message', (from, data) => …); room.send({x: 1}); share gf.rooms.link(room.code)
24
25
  //
25
- // Where sign-in comes from (games.fun's player sign-in: X first, a wallet as well):
26
- // - inside games.fun's play page, the page above signs the player in and hands this page a token (gf:host / gf:auth);
27
- // only games.fun's own page may: framed by any other site (one embedding the game), the game signs in by itself;
28
- // - on the game's own link or inside a post on X, a small window on games.fun signs in (or a code the player types at
26
+ // Where sign-in comes from (xgames.fun's player sign-in: X first, a wallet as well):
27
+ // - inside xgames.fun's play page, the page above signs the player in and hands this page a token (gf:host / gf:auth);
28
+ // only xgames.fun's own page may: framed by any other site (one embedding the game), the game signs in by itself;
29
+ // - on the game's own link or inside a post on X, a small window on xgames.fun signs in (or a code the player types at
29
30
  // <site>/go on any device), and this page asks the play host until it is answered (/player/v1/request);
30
31
  // - on this machine (localhost, 127.0.0.1, xcore check), a local player and this browser's storage stand in, so the
31
32
  // game can be made and tested before it is published;
32
- // - in `xcore dev` (its pages set globalThis.GAMESFUN_DEV), the dev host answers as games.fun would: a local sign-in
33
+ // - in `xcore dev` (its pages set globalThis.GAMESFUN_DEV), the dev host answers as xgames.fun would: a local sign-in
33
34
  // (each tab its own player), saves and scores on its platform, the game's own server for the database and rooms.
34
35
  // The token lasts an hour and is kept in memory only. A server checks it with env.GF.verifyPlayer (server/), or any
35
36
  // JWT library against <play host>/player/v1/key (a server of your own).
36
37
  (function () {
37
38
  'use strict';
38
39
  if (globalThis.GamesFun) return;
39
- var VERSION = '1.3.0';
40
+ var VERSION = '1.4.0';
40
41
  var PLATFORM = '/platform/v1/';
41
42
  var SEG = /^[A-Za-z0-9_-]{1,64}$/;
42
43
  var POLL_MS = 2000;
@@ -48,11 +49,11 @@
48
49
  var edge = pathSlug ? null : /^([a-z0-9]+(?:-[a-z0-9]+)*)-play\.([a-z0-9.-]+(?::\d+)?)$/.exec(loc.host);
49
50
  var slug = pathSlug || (edge ? edge[1] : null);
50
51
  var local = loc.protocol === 'file:' || /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])$/.test(loc.hostname);
51
- // xcore dev: everything goes to the dev host, as it would to games.fun; only the sign-in is its own
52
+ // xcore dev: everything goes to the dev host, as it would to xgames.fun; only the sign-in is its own
52
53
  var dev = local && !!globalThis.GAMESFUN_DEV;
53
54
  if (dev) local = false;
54
- // on the game's own domain (play.mygame.com, xcore domain): games.fun's play edge says which game this is and where
55
- // games.fun is (GET /platform/v1/site, asked once at load; gf.ready waits for it)
55
+ // on the game's own domain (play.mygame.com, xcore domain): xgames.fun's play edge says which game this is and where
56
+ // xgames.fun is (GET /platform/v1/site, asked once at load; gf.ready waits for it)
56
57
  var site = null;
57
58
  function siteOrigin(v) {
58
59
  try {
@@ -60,7 +61,7 @@
60
61
  if (u.origin !== v) return null;
61
62
  if (u.protocol === 'https:' || (u.protocol === 'http:' && /^(localhost|127\.\d+\.\d+\.\d+)$/.test(u.hostname)))
62
63
  return u.origin;
63
- // a stand-in games.fun in tests (http on a name this machine resolves)
64
+ // a stand-in xgames.fun in tests (http on a name this machine resolves)
64
65
  return u.protocol === 'http:' && /\.test$/.test(u.hostname) ? u.origin : null;
65
66
  } catch {
66
67
  return null;
@@ -182,7 +183,7 @@
182
183
  function signedOut() {
183
184
  return Promise.resolve({ data: null, error: 'Sign in first.', status: 401 });
184
185
  }
185
- /** games.fun's word for a refusal (r.code), kept on the answer made from r. */
186
+ /** xgames.fun's word for a refusal (r.code), kept on the answer made from r. */
186
187
  function withCode(answer, r) {
187
188
  if (r.code) answer.code = r.code;
188
189
  return answer;
@@ -242,7 +243,7 @@
242
243
  });
243
244
  }
244
245
 
245
- // ---- the page above (games.fun's play page) ----
246
+ // ---- the page above (xgames.fun's play page) ----
246
247
  var above = null; // the parent window, once it answered gf:host
247
248
  var aboveAuth = false;
248
249
  var framed;
@@ -252,7 +253,7 @@
252
253
  framed = true;
253
254
  }
254
255
  var waiters = [];
255
- // a token from games.fun: the player as the platform knows them (the token's subject), said once
256
+ // a token from xgames.fun: the player as the platform knows them (the token's subject), said once
256
257
  function takeToken(token, guess, expires) {
257
258
  gf.token = token;
258
259
  gf.expires = expires;
@@ -281,7 +282,7 @@
281
282
  gf.ready = new Promise(function (resolve) {
282
283
  readyResolve = resolve;
283
284
  });
284
- // A message from the page above counts only from games.fun's own page (its origin, or www. of it): a site that
285
+ // A message from the page above counts only from xgames.fun's own page (its origin, or www. of it): a site that
285
286
  // embeds the game can't hand it a sign-in or take its sign-in over.
286
287
  function fromSite(origin) {
287
288
  var s = studio();
@@ -351,63 +352,79 @@
351
352
  } else readyResolve(gf.player);
352
353
  }
353
354
 
354
- // ---- sign-in by itself: a request answered on games.fun (its own link, a post on X) ----
355
- var req = null,
356
- pollTimer = null,
357
- popup = null;
355
+ // ---- sign-in by itself: a request answered on xgames.fun (its own link, a post on X) ----
356
+ // One sign-in at a time: {promise, resolve, timer, popup, req}. A second click shares it, a sign-out ends it with
357
+ // null, and an answer from xgames.fun that comes after it ended (a poll already on its way) is dropped.
358
+ var pending = null;
358
359
  function post(path, body) {
359
360
  return call(
360
361
  path,
361
362
  { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
362
- 'games.fun sign-in',
363
+ 'xgames.fun sign-in',
363
364
  );
364
365
  }
365
- function poll(done) {
366
- clearTimeout(pollTimer);
367
- if (!req) return done(null);
368
- if (req.expires <= Date.now()) {
369
- req = null;
370
- gf.code = null;
371
- return done(null);
366
+ function closePopup(p) {
367
+ try {
368
+ if (p.popup) p.popup.close();
369
+ } catch {
370
+ /* closed */
372
371
  }
373
- pollTimer = setTimeout(function () {
374
- post('/player/v1/request/status', { id: req.id, secret: req.secret }).then(function (r) {
372
+ p.popup = null;
373
+ }
374
+ // the sign-in `p` is over: answered with `player` (once; nothing if it ended already)
375
+ function endSignIn(p, player) {
376
+ if (pending !== p) return;
377
+ pending = null;
378
+ clearTimeout(p.timer);
379
+ p.req = null;
380
+ gf.code = null;
381
+ closePopup(p);
382
+ p.resolve(player);
383
+ }
384
+ function poll(p) {
385
+ if (pending !== p) return;
386
+ clearTimeout(p.timer);
387
+ if (p.req.expires <= Date.now()) return endSignIn(p, null);
388
+ p.timer = setTimeout(function () {
389
+ if (pending !== p || !p.req) return;
390
+ post('/player/v1/request/status', { id: p.req.id, secret: p.req.secret }).then(function (r) {
391
+ if (pending !== p || !p.req) return; // signed out (or ended) while this was on its way
375
392
  var s = r.data || {};
376
393
  if (s.state === 'signed-in') {
377
- req = null;
394
+ p.req = null;
378
395
  gf.code = null;
379
- try {
380
- if (popup) popup.close();
381
- } catch {
382
- /* closed */
383
- }
384
- popup = null;
385
- var p = s.player || {};
386
- var x = p.x && p.x.handle ? p.x : null;
396
+ closePopup(p);
397
+ var pl = s.player || {};
398
+ var x = pl.x && pl.x.handle ? pl.x : null;
387
399
  var guess = {
388
400
  id: '',
389
- name: x ? '@' + x.handle : p.wallet || '',
390
- level: p.level,
391
- wallet: p.wallet || '',
401
+ name: x ? '@' + x.handle : pl.wallet || '',
402
+ level: pl.level,
403
+ wallet: pl.wallet || '',
392
404
  x: x,
393
405
  };
394
- takeToken(s.token, guess, s.expires).then(done);
406
+ // still this sign-in's until the token is taken: a click meanwhile waits for the same player
407
+ takeToken(s.token, guess, s.expires).then(function (player) {
408
+ endSignIn(p, player);
409
+ });
395
410
  return;
396
411
  }
397
- if (s.state === 'expired') {
398
- req = null;
399
- gf.code = null;
400
- return done(null);
401
- }
402
- poll(done);
412
+ if (s.state === 'expired') return endSignIn(p, null);
413
+ poll(p);
403
414
  });
404
415
  }, POLL_MS);
405
416
  }
406
417
  function requestSignIn(how) {
407
418
  if (!slug) return Promise.resolve(null);
419
+ if (pending) return pending.promise;
420
+ var p = { promise: null, resolve: null, timer: null, popup: null, req: null };
421
+ p.promise = new Promise(function (resolve) {
422
+ p.resolve = resolve;
423
+ });
424
+ pending = p;
408
425
  // the window opens at the click (one opened after an await is blocked), then goes to the code's page
409
426
  try {
410
- popup = globalThis.open(
427
+ p.popup = globalThis.open(
411
428
  studio() + '/go',
412
429
  'gamesfun-signin',
413
430
  'popup,width=440,height=600,left=' +
@@ -416,33 +433,25 @@
416
433
  Math.max(0, Math.round((screen.availHeight || 800) / 2 - 300)),
417
434
  );
418
435
  } catch {
419
- popup = null;
436
+ p.popup = null;
420
437
  }
421
- return post('/player/v1/request', { slug: slug, with: how }).then(function (r) {
422
- if (!r.data || !r.data.id) {
423
- try {
424
- if (popup) popup.close();
425
- } catch {
426
- /* closed */
427
- }
428
- popup = null;
429
- return null;
430
- }
431
- req = { id: r.data.id, secret: r.data.secret, expires: r.data.expires };
432
- gf.code = { code: r.data.code, site: r.data.site, url: r.data.url, window: !!popup };
433
- if (popup && !popup.closed) {
438
+ post('/player/v1/request', { slug: slug, with: how }).then(function (r) {
439
+ if (pending !== p) return; // signed out before xgames.fun answered
440
+ if (!r.data || !r.data.id) return endSignIn(p, null);
441
+ p.req = { id: r.data.id, secret: r.data.secret, expires: r.data.expires };
442
+ gf.code = { code: r.data.code, site: r.data.site, url: r.data.url, window: !!p.popup };
443
+ if (p.popup && !p.popup.closed) {
434
444
  try {
435
- popup.location.href = r.data.url + (r.data.url.indexOf('?') < 0 ? '?' : '&') + 'with=' + how;
445
+ p.popup.location.href = r.data.url + (r.data.url.indexOf('?') < 0 ? '?' : '&') + 'with=' + how;
436
446
  } catch {
437
- popup = null;
447
+ p.popup = null;
438
448
  }
439
449
  }
440
450
  // always said: the game may show the code, and must when no window opened (a phone, a blocked window)
441
451
  emit(codeFns, gf.code);
442
- return new Promise(function (resolve) {
443
- poll(resolve);
444
- });
452
+ poll(p);
445
453
  });
454
+ return p.promise;
446
455
  }
447
456
 
448
457
  /**
@@ -452,6 +461,8 @@
452
461
  gf.signIn = function (opts) {
453
462
  opts = opts || {};
454
463
  var how = opts.how === 'wallet' ? 'wallet' : 'x';
464
+ // a sign-in on its way (to its last step, the player's token being taken): this click waits for the same one
465
+ if (pending && opts.prompt !== false) return pending.promise;
455
466
  if (gf.token && gf.expires > Date.now() + 60e3) return Promise.resolve(gf.player);
456
467
  if (dev) return opts.prompt === false ? Promise.resolve(gf.player) : devSignIn(opts.name);
457
468
  if (local) {
@@ -482,8 +493,7 @@
482
493
  return requestSignIn(how);
483
494
  };
484
495
  gf.signOut = function () {
485
- clearTimeout(pollTimer);
486
- req = null;
496
+ if (pending) endSignIn(pending, null);
487
497
  gf.code = null;
488
498
  if (local) store('signedIn', null);
489
499
  if (dev) devTab(Object.assign(devTab() || {}, { signedIn: false }));
@@ -701,16 +711,27 @@
701
711
  );
702
712
  },
703
713
  );
704
- var spec = fetch(at('xsolana.json'), { cache: 'no-store' }).then(function (r) {
705
- if (!r.ok) throw new Error('This game has no economy here (no xsolana.json beside it).');
706
- return r.text().then(function (t) {
707
- try {
708
- return JSON.parse(t);
709
- } catch {
710
- throw new Error('xsolana.json is not JSON: check it with xcore loot check.');
711
- }
714
+ // xsolana.json; a game made before it was named so keeps loot.json (read only when there is no xsolana.json)
715
+ var specFile = function (name) {
716
+ return fetch(at(name), { cache: 'no-store' }).then(function (r) {
717
+ return { name: name, r: r };
718
+ });
719
+ };
720
+ var spec = specFile('xsolana.json')
721
+ .then(function (f) {
722
+ return f.r.status === 404 ? specFile('loot.json') : f;
723
+ })
724
+ .then(function (f) {
725
+ if (f.r.status === 404) throw new Error('This game has no economy here (no xsolana.json beside it).');
726
+ if (!f.r.ok) throw new Error(f.name + ' could not be read (' + f.r.status + ').');
727
+ return f.r.text().then(function (t) {
728
+ try {
729
+ return JSON.parse(t);
730
+ } catch {
731
+ throw new Error(f.name + ' is not JSON: check it with xcore loot check.');
732
+ }
733
+ });
712
734
  });
713
- });
714
735
  // the game's own RPC (xcore add rpc: server/rpc.js at api/rpc, its URL a secret of the server) when it has one;
715
736
  // else the play host's shared relay (PAGE_RPC). Asked once a page load; any other answer is the shared relay.
716
737
  var own = fetch(gf.apiUrl('rpc'), { cache: 'no-store' })
@@ -843,11 +864,22 @@
843
864
  },
844
865
  };
845
866
 
846
- // ---- the game's own database (xcore add database: server/ with a SQLite database on games.fun) ----
847
- // Documents in collections: {id, owner, value, updatedAt}. Anyone reads; a signed-in player writes their own (the
848
- // rules at the top of server/database.js say otherwise per collection).
867
+ // ---- the game's own database (xcore add database: server/ with a SQLite database on xgames.fun) ----
868
+ // Documents in collections: {id, owner, value, updatedAt}. Each player has a space of their own in a collection (the
869
+ // same id in two players' spaces is two documents); a signed-in player reads and writes their own (the rules at the
870
+ // top of server/database.js, or gamesfun.json → database.collections, say otherwise per collection). opts.owner names
871
+ // another space to read: 'server' (what the game's server wrote) or a player's id.
872
+ // On this machine only the local player's space exists, in this browser's storage.
849
873
  function localDocs(c) {
850
- return store('db:' + c) || {};
874
+ var docs = store('db2:' + c);
875
+ if (docs) return docs;
876
+ // an older gamesfun.js kept one id per collection here: each document moves to its owner's space
877
+ var old = store('db:' + c) || {};
878
+ docs = {};
879
+ Object.keys(old).forEach(function (id) {
880
+ docs[old[id].owner + '/' + id] = old[id];
881
+ });
882
+ return docs;
851
883
  }
852
884
  function newId() {
853
885
  var a = new Uint8Array(9);
@@ -865,53 +897,131 @@
865
897
  })
866
898
  .join('');
867
899
  }
900
+ function localOwner(opts) {
901
+ var o = opts && opts.owner;
902
+ return !o || o === 'me' ? LOCAL_PLAYER.id : String(o);
903
+ }
904
+ // a list's place: the last document of a page ([updatedAt, owner, id]), handed back as it came
905
+ function localCursor(d) {
906
+ return btoa(JSON.stringify([d.updatedAt, d.owner, d.id]));
907
+ }
908
+ function localAfter(text) {
909
+ try {
910
+ var c = JSON.parse(atob(String(text)));
911
+ return Array.isArray(c) && c.length === 3 ? c : null;
912
+ } catch {
913
+ return null;
914
+ }
915
+ }
916
+ function order3(a, b) {
917
+ var x = [a.updatedAt, a.owner, a.id],
918
+ y = [b.updatedAt, b.owner, b.id];
919
+ for (var i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
920
+ return 0;
921
+ }
922
+ function notYours() {
923
+ return Promise.resolve({ data: null, error: 'A player writes only their own documents.', status: 403 });
924
+ }
868
925
  var localDb = {
869
- get: function (c, id) {
870
- return ok(localDocs(c)[id] || null);
926
+ get: function (c, id, opts) {
927
+ return ok(localDocs(c)[localOwner(opts) + '/' + id] || null);
871
928
  },
872
- put: function (c, id, value) {
929
+ put: function (c, id, value, opts) {
930
+ if (localOwner(opts) !== LOCAL_PLAYER.id) return notYours();
873
931
  var docs = localDocs(c);
874
- if (docs[id] && docs[id].owner !== LOCAL_PLAYER.id)
875
- return Promise.resolve({ data: null, error: 'Another player owns this document.', status: 403 });
876
- docs[id] = { id: id, owner: LOCAL_PLAYER.id, value: value, updatedAt: Date.now() };
877
- store('db:' + c, docs);
878
- return ok(docs[id]);
932
+ var key = LOCAL_PLAYER.id + '/' + id;
933
+ docs[key] = { id: id, owner: LOCAL_PLAYER.id, value: value, updatedAt: Date.now() };
934
+ store('db2:' + c, docs);
935
+ return ok(docs[key]);
879
936
  },
880
937
  add: function (c, value) {
881
938
  return localDb.put(c, newId(), value);
882
939
  },
883
940
  list: function (c, opts) {
884
941
  opts = opts || {};
885
- var all = Object.keys(localDocs(c)).map(function (k) {
886
- return localDocs(c)[k];
942
+ if (noOffset(opts)) return noOffset(opts);
943
+ var docs = localDocs(c);
944
+ var all = Object.keys(docs).map(function (k) {
945
+ return docs[k];
887
946
  });
888
947
  if (opts.owner)
889
948
  all = all.filter(function (d) {
890
- return d.owner === (opts.owner === 'me' ? LOCAL_PLAYER.id : opts.owner);
949
+ return d.owner === localOwner(opts);
891
950
  });
951
+ var old = opts.order === 'old';
892
952
  all.sort(function (a, b) {
893
- return opts.order === 'old' ? a.updatedAt - b.updatedAt : b.updatedAt - a.updatedAt;
953
+ return old ? order3(a, b) : order3(b, a);
954
+ });
955
+ if (opts.after) {
956
+ var at = localAfter(opts.after);
957
+ if (!at) return Promise.resolve({ data: null, error: "after is the last answer's after.", status: 400 });
958
+ var mark = { updatedAt: at[0], owner: at[1], id: at[2] };
959
+ all = all.filter(function (d) {
960
+ return old ? order3(d, mark) > 0 : order3(d, mark) < 0;
961
+ });
962
+ }
963
+ var n = Math.max(1, Math.min(100, opts.limit | 0 || 50));
964
+ var page = all.slice(0, n);
965
+ var more = all.length > n;
966
+ return Promise.resolve({
967
+ data: page,
968
+ after: more ? localCursor(page[page.length - 1]) : null,
969
+ more: more,
970
+ error: null,
971
+ status: 200,
894
972
  });
895
- var offset = opts.offset | 0;
896
- return ok(all.slice(offset, offset + (opts.limit | 0 || 50)));
897
973
  },
898
- delete: function (c, id) {
974
+ delete: function (c, id, opts) {
975
+ if (localOwner(opts) !== LOCAL_PLAYER.id) return notYours();
899
976
  var docs = localDocs(c);
900
- var had = !!docs[id];
901
- delete docs[id];
902
- store('db:' + c, docs);
977
+ var key = LOCAL_PLAYER.id + '/' + id;
978
+ var had = !!docs[key];
979
+ delete docs[key];
980
+ store('db2:' + c, docs);
903
981
  return ok({ deleted: had });
904
982
  },
905
983
  };
906
984
  function useLocalDb() {
907
985
  return local;
908
986
  }
987
+ var ownerSaid = {};
988
+ // offset paging is gone (each player has a space of their own, and a page follows the last one's place): a real
989
+ // offset is refused, so code that pages with it stops at once instead of reading its first page again and again;
990
+ // 0 (or none) is the first page, which is what it always was
991
+ function noOffset(opts) {
992
+ var o = opts && opts.offset;
993
+ return o !== undefined && o !== null && o !== '' && Number(o) !== 0
994
+ ? Promise.resolve({
995
+ data: null,
996
+ error:
997
+ "gf.db.list takes no offset any more: pass the last answer's after for the next page (gf.db.list(c, {after}))",
998
+ status: 0,
999
+ code: 'offset',
1000
+ })
1001
+ : null;
1002
+ }
1003
+ function ownerQuery(opts) {
1004
+ return opts && opts.owner !== undefined && opts.owner !== null && opts.owner !== ''
1005
+ ? '?owner=' + encodeURIComponent(opts.owner)
1006
+ : '';
1007
+ }
909
1008
  gf.db = {
910
- /** → {data: {id, owner, value, updatedAt} | null} */
911
- get: function (c, id) {
1009
+ /** opts {owner: 'me' | 'server' | a player's id}; without it, the player's own (in a collection only the server
1010
+ * writes: the server's, then the player's own). Another player's document is read by its owner: gf.db.get(c, id,
1011
+ * {owner: doc.owner}). → {data: {id, owner, value, updatedAt} | null} */
1012
+ get: function (c, id, opts) {
912
1013
  if (!seg(c) || !seg(id)) return bad('collection or id');
913
- if (useLocalDb()) return localDb.get(c, id);
914
- return gf.api('db/' + seg(c) + '/' + seg(id)).then(function (r) {
1014
+ if (useLocalDb()) return localDb.get(c, id, opts);
1015
+ return gf.api('db/' + seg(c) + '/' + seg(id) + ownerQuery(opts)).then(function (r) {
1016
+ // not in this player's space, and another player has a document with this id: null, as any document that is
1017
+ // not there, and said once, since an id alone named any player's before (gamesfun.js 1.3)
1018
+ if (r.code === 'owner') {
1019
+ if (!ownerSaid[c]) {
1020
+ ownerSaid[c] = true;
1021
+ if (globalThis.console) console.warn('gf.db.get: ' + r.error);
1022
+ }
1023
+ return { data: null, error: null, status: 404, code: 'owner', why: r.error };
1024
+ }
915
1025
  return withCode(
916
1026
  { data: r.data ? r.data.item : null, error: r.status === 404 ? null : r.error, status: r.status },
917
1027
  r.status === 404 ? {} : r,
@@ -928,7 +1038,7 @@
928
1038
  });
929
1039
  });
930
1040
  },
931
- /** A new document with an id made for it. → {data: the document} */
1041
+ /** A new document of the player's own, with an id made for it. → {data: the document} */
932
1042
  add: function (c, value) {
933
1043
  if (!seg(c)) return bad('collection');
934
1044
  if (useLocalDb()) return localDb.add(c, value);
@@ -938,21 +1048,30 @@
938
1048
  });
939
1049
  });
940
1050
  },
941
- /** → {data: [document]}; opts {owner: 'me' | a player id, limit (≤ 100), offset, order: 'new' | 'old'} */
1051
+ /** → {data: [document], after, more}: newest first, a page at a time; pass `after` back for the next page.
1052
+ * opts {owner: 'me' | 'server' | a player's id, limit (≤ 100), after, order: 'new' | 'old'}; an offset but 0 is
1053
+ * refused.
1054
+ * The order is by when each document was last written: one written between two pages moves to the front, so a
1055
+ * listing taken while players write can show it twice or miss it. */
942
1056
  list: function (c, opts) {
943
1057
  opts = opts || {};
944
1058
  if (!seg(c)) return bad('collection');
1059
+ if (noOffset(opts)) return noOffset(opts);
945
1060
  if (useLocalDb()) return localDb.list(c, opts);
946
1061
  var q = [];
947
- ['owner', 'limit', 'offset', 'order'].forEach(function (k) {
948
- if (opts[k] !== undefined) q.push(k + '=' + encodeURIComponent(opts[k]));
1062
+ ['owner', 'limit', 'after', 'order'].forEach(function (k) {
1063
+ if (opts[k] !== undefined && opts[k] !== null) q.push(k + '=' + encodeURIComponent(opts[k]));
949
1064
  });
950
1065
  return gf.api('db/' + seg(c) + (q.length ? '?' + q.join('&') : '')).then(function (r) {
951
- return pick(r, function (d) {
1066
+ var a = pick(r, function (d) {
952
1067
  return d.items;
953
1068
  });
1069
+ a.after = (r.data && r.data.after) || null;
1070
+ a.more = !!(r.data && r.data.more);
1071
+ return a;
954
1072
  });
955
1073
  },
1074
+ /** Deletes the player's own document. → {data: {deleted}} */
956
1075
  delete: function (c, id) {
957
1076
  if (!seg(c) || !seg(id)) return bad('collection or id');
958
1077
  if (useLocalDb()) return localDb.delete(c, id);
@@ -960,7 +1079,7 @@
960
1079
  },
961
1080
  };
962
1081
 
963
- // ---- multiplayer rooms (xcore add multiplayer: server/rooms.js on games.fun) ----
1082
+ // ---- multiplayer rooms (xcore add multiplayer: server/rooms.js on xgames.fun) ----
964
1083
  // A room is a relay with a host: the first player in is the host (id 1), the others get ids of their own; a message
965
1084
  // goes to one player (to: id), to everyone else (0), or to everyone but one (-id). On this machine, tabs of the same
966
1085
  // browser share a room instead (BroadcastChannel), so two tabs play together before the game is published.
@@ -1228,16 +1347,17 @@
1228
1347
  ch.postMessage({ t: 'who', nonce: nonce });
1229
1348
  return new Promise(function (resolve) {
1230
1349
  setTimeout(function () {
1350
+ // refused: answered (the channel closes below), as a refusal online is
1351
+ if (opts.host === true && replies.length)
1352
+ return resolve({ data: null, error: 'Someone is in this room already: choose another code.', status: 4009 });
1353
+ if (opts.host === false && !replies.length)
1354
+ return resolve({ data: null, error: 'No one is in this room.', status: 4004 });
1231
1355
  var taken = {};
1232
1356
  for (var i = 0; i < replies.length; i++) {
1233
1357
  room.peers[replies[i].peer.id] = replies[i].peer;
1234
1358
  times[replies[i].peer.id] = replies[i].joined;
1235
1359
  taken[replies[i].peer.id] = true;
1236
1360
  }
1237
- if (opts.host === true && replies.length)
1238
- return { data: null, error: 'Someone is in this room already: choose another code.', status: 4009 };
1239
- if (opts.host === false && !replies.length)
1240
- return { data: null, error: 'No one is in this room.', status: 4004 };
1241
1361
  room.id = replies.length ? 0 : 1;
1242
1362
  while (!room.id || taken[room.id]) room.id = 2 + Math.floor(Math.random() * 0x7ffffffd);
1243
1363
  times[room.id] = joined;
@@ -1285,7 +1405,7 @@
1285
1405
  for (var i = 0; i < a.length; i++) s += CODE_CHARS[a[i] % CODE_CHARS.length];
1286
1406
  return s;
1287
1407
  },
1288
- /** The room this page was opened for (?room=CODE, as games.fun's room links and their X cards pass it), or null. */
1408
+ /** The room this page was opened for (?room=CODE, as xgames.fun's room links and their X cards pass it), or null. */
1289
1409
  fromLink: function () {
1290
1410
  try {
1291
1411
  return roomCode(new URLSearchParams(loc.search || '').get('room'));
@@ -1293,7 +1413,7 @@
1293
1413
  return null;
1294
1414
  }
1295
1415
  },
1296
- /** The link that opens the game in this room: games.fun's play page once published (its X card shows the room). */
1416
+ /** The link that opens the game in this room: xgames.fun's play page once published (its X card shows the room). */
1297
1417
  link: function (code) {
1298
1418
  var c = roomCode(code);
1299
1419
  if (!c) return null;