@nano133/nanoterra 0.5.0 → 0.6.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 (70) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +133 -8
  3. package/ai/SPEC.md +25 -3
  4. package/dist/admin/adapters/d1.d.ts +1 -0
  5. package/dist/admin/adapters/d1.js +6 -4
  6. package/dist/admin/adapters/firestore.d.ts +1 -0
  7. package/dist/admin/adapters/firestore.js +7 -5
  8. package/dist/admin/conformance.d.ts +1 -1
  9. package/dist/admin/conformance.js +13 -5
  10. package/dist/admin/handler.d.ts +1 -1
  11. package/dist/admin/handler.js +3 -3
  12. package/dist/admin/index.d.ts +2 -2
  13. package/dist/admin/index.js +2 -2
  14. package/dist/admin/memory.js +3 -1
  15. package/dist/admin/panel/gameCard.d.ts +1 -1
  16. package/dist/admin/panel/gameCard.js +56 -6
  17. package/dist/admin/panel/mount.js +1 -1
  18. package/dist/admin/panel/overview.d.ts +1 -1
  19. package/dist/admin/panel/overview.js +4 -2
  20. package/dist/admin/store.d.ts +2 -1
  21. package/dist/admin/switches.d.ts +23 -1
  22. package/dist/admin/switches.js +26 -5
  23. package/dist/friends/adapters/d1.d.ts +4 -0
  24. package/dist/friends/adapters/d1.js +131 -0
  25. package/dist/friends/adapters/firestore.d.ts +49 -0
  26. package/dist/friends/adapters/firestore.js +163 -0
  27. package/dist/friends/conformance.d.ts +11 -0
  28. package/dist/friends/conformance.js +229 -0
  29. package/dist/friends/handler.d.ts +31 -0
  30. package/dist/friends/handler.js +139 -0
  31. package/dist/friends/index.d.ts +8 -0
  32. package/dist/friends/index.js +12 -0
  33. package/dist/friends/memory.d.ts +9 -0
  34. package/dist/friends/memory.js +79 -0
  35. package/dist/friends/panel/index.d.ts +4 -0
  36. package/dist/friends/panel/index.js +10 -0
  37. package/dist/friends/panel/mount.d.ts +38 -0
  38. package/dist/friends/panel/mount.js +321 -0
  39. package/dist/friends/panel/window.d.ts +52 -0
  40. package/dist/friends/panel/window.js +72 -0
  41. package/dist/friends/rules.d.ts +92 -0
  42. package/dist/friends/rules.js +180 -0
  43. package/dist/friends/types.d.ts +58 -0
  44. package/dist/friends/types.js +24 -0
  45. package/package.json +11 -4
  46. package/src/admin/adapters/d1.ts +5 -4
  47. package/src/admin/adapters/firestore.ts +6 -5
  48. package/src/admin/conformance.ts +15 -6
  49. package/src/admin/handler.ts +3 -3
  50. package/src/admin/index.ts +3 -3
  51. package/src/admin/memory.ts +2 -1
  52. package/src/admin/panel/gameCard.ts +52 -6
  53. package/src/admin/panel/mount.ts +1 -1
  54. package/src/admin/panel/overview.ts +4 -3
  55. package/src/admin/panel/style.css +10 -1
  56. package/src/admin/store.ts +2 -2
  57. package/src/admin/switches.ts +35 -9
  58. package/src/friends/adapters/d1.sql +33 -0
  59. package/src/friends/adapters/d1.ts +146 -0
  60. package/src/friends/adapters/firestore.ts +197 -0
  61. package/src/friends/conformance.ts +260 -0
  62. package/src/friends/handler.ts +144 -0
  63. package/src/friends/index.ts +17 -0
  64. package/src/friends/memory.ts +83 -0
  65. package/src/friends/panel/index.ts +13 -0
  66. package/src/friends/panel/mount.ts +346 -0
  67. package/src/friends/panel/style.css +303 -0
  68. package/src/friends/panel/window.ts +79 -0
  69. package/src/friends/rules.ts +196 -0
  70. package/src/friends/types.ts +57 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1 (2026-10-30)
4
+
5
+ - **Friends: a name may be 80 characters** (it was 64), so a whole Nano address (65 with `nano_`) reaches the game's `PlayerDirectory`: the handler's
6
+ `FRIENDS_LIMITS.nameMax` and the panel's name field.
7
+
8
+ ## 0.6.0 (2026-10-30)
9
+
10
+ - **A friends list for the game's players** (`@nano133/nanoterra/friends`, `/friends/d1`, `/friends/firestore`, `/friends/conformance`, `/friends/panel` and
11
+ `/friends/style.css`). The admin's Game card has a third choice, **"Friends list: Nanoterra / The game's own / Off"** (Nanoterra by default):
12
+ - **Nanoterra:** `mountFriends(el, { mode: "nanoterra", game })` puts a Friends key in the game and holds nano133's friends window
13
+ (`https://nano133.com/embed/friends?game=<id>&v=1`) in its panel. The friends stay on nano133; the game gets none of them, only the window's height, two
14
+ counts for the badge (requests, else friends online) and a friend's https address to join (the kit asks the player first). The window
15
+ is loaded at once and kept while the panel is closed, because it holds the player's live connection ("Playing <game>" for their friends). The messages
16
+ are contract v1 with the site, `{ source: "nanoterra-friends", v: 1, type }` (README, "The nano133 window").
17
+ - **The game's own:** a full friends list of the game's own players on the game's own database: requests (by name, through the game's `PlayerDirectory`),
18
+ accept, decline, remove, block and unblock, invites ("come to Level 2"), and online with a place. `createFriendsHandler` gives the routes (GET the view,
19
+ POST an op; same origin, 30 calls a minute for each player; no answer holds a player id). The stores: `createMemoryFriends`, `createD1Friends` (with the
20
+ migration `@nano133/nanoterra/friends/d1.sql`) and `createFirestoreFriends` (a small interface the Admin SDK fits). `friendsStoreConformance` proves the
21
+ rules on any store; the package runs it on all three (and on the Firestore emulator through the Admin SDK). The panel polls the view every 20 s while open
22
+ and pulses every 45 s with a lease for each tab.
23
+ - **Off:** nothing is drawn.
24
+ - **The admin's settings have a third word, `friends`** (`"nanoterra" | "own" | "off"`): `GameState` has `friends` and `fromAdmin.friends`, a press may send
25
+ `{ friends }`, the stores keep `setting:friends` (D1 and Firestore beside the two settings), and `GameSettings.friends` is optional (Nanoterra when left out).
26
+ A game that compares the whole `GameState` (a test with `deepEqual`) adds the two new fields.
27
+
3
28
  ## 0.5.0 (2026-10-30)
4
29
 
5
30
  - **Wear slots** (`@nano133/nanoterra/looks`, and the Shelf of `/admin/panel`): a look fills one wear slot, and a player wears one look in each slot at once. An outfit (no
package/README.md CHANGED
@@ -10,6 +10,7 @@ themselves. This package gives your game four things:
10
10
  - **The player's character** (`/character`): the same toon body that the player has on the island, drawn with Three.js. Six bases, faces, hair, clothes, sizes and colours.
11
11
  - **The looks ladder** (`/looks`): the rules of `looks.json`, the price steps, and what a change does to the players who own a look.
12
12
  - **Held input that does not stick** (`/input`, since 0.3.1): lets go of the keys and the stick when the player leaves the page, so the character never walks on by itself, and pauses a free round.
13
+ - **A friends list** (`/friends`, unreleased): a Friends key that holds the player's Nanoterra friends (a nano133 window), or a full friends list of the game's own players on the game's own database, as the admin's Game card chooses.
13
14
 
14
15
  It replaces `@nano133/character` 0.1.0, `@nano133/ticket` 0.1.0 and `@nano133/looks` 0.1.0
15
16
  (see "Moving from the three old packages"). Sign-in, checkout and late returns stay in their own packages
@@ -68,8 +69,12 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
68
69
  | `@nano133/nanoterra/admin/d1`, `/admin/firestore` | the admin store for D1 (the starter's tables) or Firestore (a small interface, no Firebase dependency): `createD1Store`, `createFirestoreStore` (since 0.2.0) | no | 1.5 kB each |
69
70
  | `@nano133/nanoterra/admin/panel` | the admin page, drawn in the browser into one element, and its stylesheet `@nano133/nanoterra/admin/style.css` (since 0.2.0) | no | 18.9 kB with the tabbed layout (10.6 kB on 0.3.0, 8.5 kB on 0.2.0) + 5.9 kB of CSS (minified) |
70
71
  | `@nano133/nanoterra/admin/conformance` | test cases that a game runs against its own admin (a dev dependency in practice; since 0.2.0) | no | n/a |
72
+ | `@nano133/nanoterra/friends` | the game's own friends list: the handler, the rules, a memory store (server code; unreleased) | no | 3.7 kB |
73
+ | `@nano133/nanoterra/friends/d1`, `/friends/firestore` | the friends store for D1 (with the migration `/friends/d1.sql`) or Firestore (a small interface, no Firebase dependency): `createD1Friends`, `createFirestoreFriends` (unreleased) | no | 3.0 kB each |
74
+ | `@nano133/nanoterra/friends/panel` | the Friends key and panel: the nano133 window, or the game's own list, and its stylesheet `@nano133/nanoterra/friends/style.css` (unreleased) | no | 5.0 kB + 4.8 kB of CSS (minified) |
75
+ | `@nano133/nanoterra/friends/conformance` | the friends rules as test cases, for a store a game writes itself (unreleased) | no | n/a |
71
76
 
72
- Measured 2026-10-26: `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
77
+ Measured 2026-10-26 (the friends paths 2026-10-30): `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
73
78
  All four paths together: 23.6 kB. A test of the package proves that the record, the ticket, the looks and the small part import no package at all.
74
79
 
75
80
  ## The character
@@ -480,7 +485,7 @@ import { createAdminHandler } from "@nano133/nanoterra/admin";
480
485
 
481
486
  const admin = createAdminHandler({
482
487
  store, // your database: readSettings(), saveSettings(patch, now), summary(now) (a memory store is in the package, for tests)
483
- defaults: { character: "nanoterra", signin: "exchange" }, // what the two switches are until /admin sets them (your game.config.json)
488
+ defaults: { character: "nanoterra", signin: "exchange", friends: "nanoterra" }, // what the switches are until /admin sets them (your game.config.json; friends may be left out)
484
489
  owner: ownerAddress, // "nano_..." lowercase, its checksum checked by you; null when none is set
485
490
  session: (req, now) => readAdminSession(req, now), // YOUR admin login: its own cookie; return { address, createdAt, via: "wallet" } or null
486
491
  status: (now, summary) => ({ address: ownerAddress, testMode, warnings }), // what only your game knows (the store's numbers come too, for a warning like "2 payments came late")
@@ -506,7 +511,7 @@ neither header (curl) and the browser's own address bar (`Sec-Fetch-Site: none`)
506
511
  **The handler** sends every request under the base path (`/api/admin` by default) through the guard first, before the route is looked up: a visitor who is not the owner
507
512
  gets 401 or 403 for any path, so nothing about the routes leaks. A POST must come from the game's own page (`Origin`, else `Sec-Fetch-Site: same-origin`). It sets no CORS
508
513
  header, always sends `Cache-Control: no-store`, reads at most 512 bytes of a press, and limits presses to 30 a minute. A fault in your store gives a plain 500 that shows
509
- nothing of the fault (`onError` hears it). The only writes are the two settings (and, when the game turns on the sign-in list, the deletion of the admin sessions), **never a look, a price or an order**: a change of looks stays a change of your repository.
514
+ nothing of the fault (`onError` hears it). The only writes are the three settings (and, when the game turns on the sign-in list, the deletion of the admin sessions), **never a look, a price or an order**: a change of looks stays a change of your repository.
510
515
 
511
516
  **Your own routes read the switches** with `loadGameSettings(store, defaults)` (for the listing file's `signin` word and for the character): the stored word, else your default,
512
517
  and your defaults when the store fails.
@@ -554,8 +559,8 @@ admin may make an admin login, and never from another sign-in. A security read f
554
559
 
555
560
  ### The store (an adapter for your database)
556
561
 
557
- `createAdminHandler` needs a store: three calls, `readSettings()`, `saveSettings(patch, now)` and `summary(now)`. Two adapters come with the package, and **both write only the two settings** (the D1 store also deletes the admin sessions, but only when the game turns on the sign-in list: see below)
558
- (`setting:character` and `setting:signin`); every other call they make is a read. Both must pass the same **store contract**, `adminStoreConformance(make)` in
562
+ `createAdminHandler` needs a store: three calls, `readSettings()`, `saveSettings(patch, now)` and `summary(now)`. Two adapters come with the package, and **both write only the three settings** (the D1 store also deletes the admin sessions, but only when the game turns on the sign-in list: see below)
563
+ (`setting:character`, `setting:signin` and `setting:friends`); every other call they make is a read. Both must pass the same **store contract**, `adminStoreConformance(make)` in
559
564
  `@nano133/nanoterra/admin/conformance`: the settings are kept and accumulate, saving changes no order, no owned look and no other row, an open order past its expiry counts as expired,
560
565
  the money is totalled right, the newest 500 orders come first, and a bad rate row is ignored. A game with another database writes its own store and runs the same contract.
561
566
 
@@ -614,10 +619,10 @@ mountAdmin(document.getElementById("admin")!, {
614
619
 
615
620
  The panel draws, from the top: a bar with who is signed in (the short address and a copy key), Live or Test mode, the bot check and Sign out; what is still off (the
616
621
  status warnings, one banner); four numbers (Earned, Open now, To send back, Players: a press opens the list behind each); then five tabs. **Overview**: the best-selling
617
- looks with their share of the money, the last five orders, the two switches as On or Off and the shelf in numbers (a game with no order yet gets three first steps).
622
+ looks with their share of the money, the last five orders, the switches as On or Off (and the friends list's choice) and the shelf in numbers (a game with no order yet gets three first steps).
618
623
  **Cosmetics**: the **Shelf** (below). **Orders**: a filter by status with its count, a search (a look, an address or a block), "Check payments now" when the game gave a
619
624
  check, "Paid late" with the address to send the money back to, 20 orders at a time; a card each on a phone, a table on a wide panel. **Players**: each player who owns a
620
- look, with their looks. **Settings**: the **Game** card (the two switches), the Sign-ins card and the facts (the address, the mode, the nodes, the last price). The tabs are
625
+ look, with their looks. **Settings**: the **Game** card (the two switches and the friends list's three-way choice), the Sign-ins card and the facts (the address, the mode, the nodes, the last price). The tabs are
621
626
  real tabs (the arrow keys, Home and End work); every tab is made once, so a draft, a search and a switch keep their state, and the panel never writes the page's address.
622
627
  A visitor who is signed out gets the game's sign-in; a wrong wallet, a Nanoterra sign-in and a missing owner address each get their own screen and see no data (a sign-out
623
628
  takes the drawn data off the page). Options: `api` (the handler's base path), `noAddressHelp`, `lookPicture(look)` (your own picture of a look for the Shelf), `repoHelp`,
@@ -652,7 +657,7 @@ flat picture stays. `createLookPreview({ size?, body?, record?, background?, key
652
657
 
653
658
  **The Shelf never writes.** It edits a draft in the owner's browser and gives text to copy (`looks.json`, or a message for an AI assistant): there is no call in the panel that
654
659
  changes a look, a price or an order, and a test proves that editing the Shelf sends no request. A change of looks is a change of your repository. The **Game** card writes only the
655
- two settings. The stylesheet is scoped under `.n133-admin`, so it cannot leak into your page; to theme it set `--n133-ink`, `--n133-ink-soft`, `--n133-paper`, `--n133-line`,
660
+ three settings. The stylesheet is scoped under `.n133-admin`, so it cannot leak into your page; to theme it set `--n133-ink`, `--n133-ink-soft`, `--n133-paper`, `--n133-line`,
656
661
  `--n133-gem`, `--n133-gem-dark`, `--n133-sea`, `--n133-radius`, `--n133-font`, `--n133-ground` (the wells inside a card), `--n133-warn`, `--n133-bad` or `--n133-shadow` on the
657
662
  element that holds it. The keys, inputs, switches, swatches and price steps are 44 px or more; the tabs, filter keys and copy keys 40 px. The layout follows the panel's own
658
663
  width (a container query), not the window's: on a phone two cards a row and the five tabs as icons over their words; three cards a row and the orders as a table from
@@ -660,6 +665,126 @@ width (a container query), not the window's: on a phone two cards a row and the
660
665
 
661
666
  The wallet sign-in stays yours (`signIn`), because only your game can start a request in the ADMIN scope with its own cookie. The panel never signs anyone in.
662
667
 
668
+ ## The friends list (`/friends`; unreleased)
669
+
670
+ A Friends key for the game, and a list behind it. The admin's **Game** card chooses which list (`friends` in the settings; Nanoterra by default):
671
+
672
+ | Choice | The word | What the player gets |
673
+ |---|---|---|
674
+ | Nanoterra | `"nanoterra"` | their **Nanoterra friends**, in a small nano133 window in the game. Friends on Nano Island see "Playing <game>" with Join. The friends stay on nano133: the game gets none of them. |
675
+ | The game's own | `"own"` | a friends list of **the game's own players**, on the game's own database (this package's handler and stores). Nanoterra friends do not show. |
676
+ | Off | `"off"` | nothing. |
677
+
678
+ ```ts
679
+ import { mountFriends } from "@nano133/nanoterra/friends/panel";
680
+ import "@nano133/nanoterra/friends/style.css";
681
+
682
+ const settings = await (await fetch("/api/settings")).json(); // your route: loadGameSettings(store, defaults) from "@nano133/nanoterra/admin"
683
+ const friends = mountFriends(document.getElementById("friends")!, {
684
+ mode: settings.friends, // "nanoterra" | "own" | "off"
685
+ game: "my-game", // your game's id on Nanoterra (its listing's id: Echo Island's is "gem-island")
686
+ where: "Lobby", // where the player is, in your words: friends see it
687
+ onJoin: (target) => go(target), // optional: "nanoterra" gives a friend's https address, "own" an invite's place (and the friend's name)
688
+ });
689
+ friends.setWhere("Level 2"); // when the player moves; null for no place
690
+ // friends.open(), friends.close(), friends.destroy()
691
+ ```
692
+
693
+ The key is 44 px, with a badge (the requests, or else the friends online). The panel is a sheet from the bottom on a phone held upright and from the right on a wide or sideways screen;
694
+ Escape and its close key close it. Theme it with the same `--n133-*` properties as the admin panel, and `--n133-friends-z` for its z-index (default 1000).
695
+
696
+ ### The nano133 window (contract v1 with the site)
697
+
698
+ In `"nanoterra"` mode the panel holds an `<iframe>` of `https://nano133.com/embed/friends?game=<gameId>&v=1` (`origin` changes the host, for a test). The site lets
699
+ only a **listed** game's own address frame it. This is the contract between the kit and the site, version 1 (accepted by the site on 2026-10-30):
700
+
701
+ - **Loaded at once, kept while closed.** The window is made when `mountFriends` runs (no lazy loading) and stays while the panel is closed: it holds the player's
702
+ live connection to nano133's room server, which shows "Playing <game>" to their friends and keeps the badge true. Closed, it is hidden (`visibility: hidden`, moved
703
+ off the screen, `aria-hidden="true"` and `tabindex="-1"`), never `display: none` and never removed. `open` and `closed` change only the window's own view.
704
+ `destroy()` removes it.
705
+ - **The frame:** `title="Friends"`, `referrerpolicy="origin"`, `allow="storage-access"`, and
706
+ `sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation"`: no top navigation,
707
+ so the window can never move the game's page.
708
+ - **Messages** are objects `{ source: "nanoterra-friends", v: 1, type, ... }`, both ways. The window sends with `parent.postMessage(msg, <the game's origin>)`; the kit
709
+ sends with `iframe.contentWindow.postMessage(msg, <nano133's origin>)`. Both sides check `event.origin` and `event.source`. A message with another `source`, another
710
+ `v`, another type or a bad field is ignored. **No message carries an address, a pass or a player id.**
711
+
712
+ | Direction | `type` | Fields | What the other side does |
713
+ |---|---|---|---|
714
+ | window → game | `ready` | | the window has loaded. The kit answers at once with `where`, then `open` or `closed` (it sends nothing before `ready`). |
715
+ | window → game | `size` | `height` (CSS pixels its content needs) | the kit sets the frame's height to it, at most the panel's own height |
716
+ | window → game | `badge` | `online`, `requests` (each 0 to 99) | the key's badge shows the requests (the thing to do) when there are any, else the friends online in the gem colour; "9+" from 10. The key's name says both ("Friends, 2 requests, 5 online"). |
717
+ | window → game | `join` | `url` | the player chose Join on a friend. The kit asks "Leave this game to join your friend?" (the window sends no name), then calls `onJoin(url)`, or goes to the address when there is no `onJoin`. |
718
+ | window → game | `signin` | | the window needs a tap for Storage Access, inside itself. The panel stays as it is; the kit does nothing else. |
719
+ | game → window | `where` | `where` (text or null) | after `ready`, and again on each `setWhere` |
720
+ | game → window | `open`, `closed` | | after `ready` (after `where`), and when the panel opens or closes |
721
+
722
+ - **`where` is plain text, and friends see it.** The kit removes control characters, trims it and cuts it to 64 characters before it sends it; it may send null (no
723
+ place). The site does the same again and shows it as text only.
724
+ - **Join:** `url` is **https only** (no user name or password in it): an island friend gives `https://nano133.com/nanoterra?room=<code>`, a friend in a game gives that
725
+ game's listed address.
726
+ - **Safari, and any browser that blocks third-party cookies:** the window needs one "Show my friends" tap (the Storage Access API) on each page load of the game
727
+ before the player shows online there (the window says `signin` when it shows that tap). Chrome with third-party cookies on needs none.
728
+
729
+ ### The game's own list
730
+
731
+ The server part, `@nano133/nanoterra/friends`, has no database and reads no cookie: **the game gives a store, a directory of its players and its signed-in player**.
732
+
733
+ ```ts
734
+ import { createFriendsHandler } from "@nano133/nanoterra/friends";
735
+ import { createD1Friends } from "@nano133/nanoterra/friends/d1";
736
+
737
+ const friends = createFriendsHandler({
738
+ store: createD1Friends(env.DB), // or createFirestoreFriends(getFirestore()), or createMemoryFriends() for a test
739
+ directory: {
740
+ byName: (name) => findPlayerIdByName(name), // your players: an id for a name, or null
741
+ names: (ids) => namesOf(ids), // a Map of id -> the name to show
742
+ },
743
+ player: (req) => signedInPlayerId(req), // YOUR player's sign-in (not the admin's): an id, or null
744
+ base: "/friends", // the default
745
+ });
746
+ const answer = await friends.handle(request); // null when the request is not for /friends
747
+ if (answer) return answer;
748
+ ```
749
+
750
+ - `GET /friends` gives `{ showOnline, people }`: one row a person, `{ edge, name, state, online, where, invite }`, `state` one of `"friend"`, `"incoming"`,
751
+ `"outgoing"`, `"blocked"`. **No answer holds a player id:** a row names a player by the directory's name and an opaque `edge`.
752
+ - `POST /friends` takes one `{ op, ... }`: `request { name }`, `accept`, `decline`, `remove`, `block`, `unblock { edge }`, `invite { edge, where }`, `pulse { where, lease }`
753
+ (`where` null: this tab leaves) and `visibility { showOnline }`. It answers the new view (a pulse answers `{ ok: true }`), or `{ error }` with words a player can read.
754
+ - A visitor who is not signed in gets 401. A POST must come from the game's own page and a read must not come from another page (403). Each player has 30 calls a
755
+ minute (429); a body is at most 1 KB. A fault in the store is a plain 500 that shows nothing of it (`onError` hears it).
756
+
757
+ **The rules** (every store keeps them; `friendsStoreConformance` proves it):
758
+
759
+ - A pair of players has at most one edge: a request (from one of them), a friendship or a block. A request to a player who already asked you is an accept.
760
+ - **A block wins** over a friendship or a request, and hides the pair from the player who is blocked, both ways. The player who blocked sees a "blocked" row, to
761
+ unblock it; unblock leaves no edge (not friends again). Neither can ask while a block stands.
762
+ - `FRIEND_LIMIT` (100) live rows a player (friends, requests both ways, blocks): a full list refuses a new request, on either side.
763
+ - A request nobody answers goes after `FRIEND_REQUEST_MS` (30 days): not shown, not counted.
764
+ - An invite is only between friends, and the friend sees it for `FRIEND_INVITE_MS` (10 minutes). Its place is plain text, cut to 64 characters.
765
+ - A friend is online only for `FRIEND_LIVE_MS` (2 minutes) after a pulse, and only while they show online. A pulse carries a lease for each tab, so an old tab that
766
+ leaves cannot clear a newer tab's place.
767
+ - `forget(player)` (for "Delete my data") leaves no row of the player.
768
+
769
+ **The stores.** Each writes ONLY its friend rows, presence rows and the show-online choice:
770
+
771
+ - **D1:** add `@nano133/nanoterra/friends/d1.sql` to your migrations (the tables `friend_edges`, `friend_presence`, `friend_prefs`). D1 has no transaction that reads and
772
+ then writes, so each write is one statement with its own check: a change says which `rev` it read (a row that changed in between is refused, 409, "Try again"), and a
773
+ new edge counts both players' rows in the same INSERT.
774
+ - **Firestore:** `createFirestoreFriends(db, { edges, presence, prefs })` (the default names `friend_edges`, `friend_presence`, `friend_prefs`). A pair is one document named
775
+ by a SHA-256 digest, so two requests at the same moment meet in their transactions; the indexes Firestore makes by itself are enough.
776
+ - **Your own database:** write a `FriendsStore` and run the rules on it:
777
+
778
+ ```ts
779
+ import { friendsStoreConformance } from "@nano133/nanoterra/friends/conformance";
780
+ for (const c of friendsStoreConformance(async () => ({ store: newEmptyStore(), dump: () => allRowsAsText() }))) it(c.name, c.run);
781
+ ```
782
+
783
+ **The panel in `"own"` mode** reads the view at once, every 20 s while open and every 60 s while closed (for the badge), pulses every 45 s while it has a `where`, and
784
+ sends a leaving pulse when the page goes away. It draws Requests (Accept, Decline), Friends (online and the place, Invite to your `where`, Join an invite when you
785
+ give `onJoin`, Remove, Block), Sent (Cancel) and Blocked (Unblock), an add-by-name field and the "show friends when I am online" choice. The game's own friends have
786
+ no live connection: only Nanoterra's do.
787
+
663
788
  ## Moving from the three old packages
664
789
 
665
790
  | Before | Now |
package/ai/SPEC.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Nanoterra for a game: implementation spec
2
2
 
3
3
  Package: `@nano133/nanoterra` (0.1.0 or later). Follow these steps in order. Each step ends with a check. Ask the owner when a step says
4
- "ask". The package has four parts, on four paths: the sign-in ticket (`/ticket`), the player's character (`/character`, Three.js), the
5
- looks ladder (`/looks`) and held input that does not stick (`/input`). Use only the parts the game needs. It has no network, wallet or Nano code. Read the package README first.
4
+ "ask". The package has five parts: the sign-in ticket (`/ticket`), the player's character (`/character`, Three.js), the
5
+ looks ladder (`/looks`), held input that does not stick (`/input`) and the friends list (`/friends`, with its panel). Use only the parts the game needs. It has no network, wallet or Nano code. Read the package README first.
6
6
 
7
7
  ## 0. What the owner decides (ask)
8
8
 
@@ -13,6 +13,7 @@ looks ladder (`/looks`) and held input that does not stick (`/input`). Use only
13
13
  3. The quality: `"high"` (toon body and ink outline, 2 draws) or `"low"` (no outline, 1 draw, for slow phones).
14
14
  4. How the game moves the character: speed in metres a second, gravity and jump. The package animates; it does not move the object.
15
15
  5. Does the game sell looks? If yes, the game is the seller (its own checkout); see step 7.
16
+ 6. The friends list (step 9): Nanoterra's (the default), the game's own, or none. It is the admin's `friends` setting; ask which one the game starts with.
16
17
 
17
18
  **Check:** the owner has named the server (yes or no), the source of the character and the quality.
18
19
 
@@ -117,7 +118,28 @@ import { pauseWhenLeft, releaseWhenLost, swallowsKeyup } from "@nano133/nanoterr
117
118
 
118
119
  **Check:** hold a move key, hide the tab or switch windows, come back: the character stands still until the key is pressed again.
119
120
 
120
- ## 9. Do not
121
+ ## 9. The friends list (the admin's `friends` setting)
122
+
123
+ ```ts
124
+ import { mountFriends } from "@nano133/nanoterra/friends/panel";
125
+ import "@nano133/nanoterra/friends/style.css";
126
+ ```
127
+
128
+ - Read the setting on the server with `loadGameSettings(store, defaults)` (`friends` is `"nanoterra"`, `"own"` or `"off"`) and pass it as `mode`. Do not read it in a
129
+ second place.
130
+ - `"nanoterra"`: `mountFriends(el, { mode, game })` with the game's id on Nanoterra. Mount it when the game starts, not when the key is pressed: the window holds the
131
+ player's live connection. Never hide its element with `display: none` or remove it; `destroy()` is for the end of the game. Call `setWhere(text)` when the player moves
132
+ (plain text, friends see it). Give `onJoin` only to save the game before it leaves; the kit asks the player first and accepts only https addresses.
133
+ - `"own"`: add `createFriendsHandler({ store, directory, player })` from `@nano133/nanoterra/friends` to the server, with `createD1Friends(env.DB)` (add
134
+ `@nano133/nanoterra/friends/d1.sql` to the migrations) or `createFirestoreFriends(db)`. `player(req)` reads the game's own PLAYER sign-in, never the admin's.
135
+ `directory.byName` and `directory.names` read the game's own player names. Call `store.forget(player)` from the game's "Delete my data".
136
+ - A store of the game's own (another database) passes `friendsStoreConformance` from `@nano133/nanoterra/friends/conformance` before it is used.
137
+
138
+ **Check:** with `"nanoterra"`, the key shows and the window loads at once (the network tab shows `/embed/friends` before the key is pressed). With `"own"`, two players
139
+ in two browsers: one adds the other by name, the other accepts, both show online with their place, an invite shows, a block hides the blocker from the other. With
140
+ `"off"`, no key.
141
+
142
+ ## 10. Do not
121
143
 
122
144
  - Do not edit the geometry, the skeleton or the toon and ink materials.
123
145
  - Do not add the katana, the headband or other brands' art. A game's own worn model goes on a socket (`c.socket("head" | "hand" | "back")` or
@@ -15,6 +15,7 @@ export interface D1Like {
15
15
  export declare const D1_KEYS: {
16
16
  readonly character: "setting:character";
17
17
  readonly signin: "setting:signin";
18
+ readonly friends: "setting:friends";
18
19
  };
19
20
  /**
20
21
  * The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
@@ -1,11 +1,11 @@
1
1
  // The admin store for a game on Cloudflare D1, with the tables of the game starter (migrations/0001_init.sql): `meta`, `orders` and `owned`.
2
- // It writes ONLY the two settings (`setting:character`, `setting:signin` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
2
+ // It writes ONLY the three settings (`setting:character`, `setting:signin`, `setting:friends` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
3
3
  // (`logins: true`): then the one other write is `DELETE FROM sessions WHERE scope = 'admin'` ("sign out everywhere").
4
4
  //
5
5
  // import { createD1Store } from "@nano133/nanoterra/admin/d1";
6
6
  // const store = createD1Store(env.DB);
7
7
  import { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers } from "../summary.js";
8
- export const D1_KEYS = { character: "setting:character", signin: "setting:signin" };
8
+ export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" };
9
9
  /**
10
10
  * The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
11
11
  * (`DELETE FROM sessions WHERE scope = 'admin'`, and `UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0` so that a request signed a moment ago
@@ -15,9 +15,9 @@ export const D1_KEYS = { character: "setting:character", signin: "setting:signin
15
15
  export function createD1Store(db, options = {}) {
16
16
  const store = {
17
17
  async readSettings() {
18
- const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?)").bind(D1_KEYS.character, D1_KEYS.signin).all()).results;
18
+ const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?, ?)").bind(D1_KEYS.character, D1_KEYS.signin, D1_KEYS.friends).all()).results;
19
19
  const row = (key) => rows.find((r) => r.key === key)?.value;
20
- return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin) };
20
+ return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends) };
21
21
  },
22
22
  async saveSettings(patch, now) {
23
23
  const write = (key, value) => db.prepare("INSERT INTO meta (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at").bind(key, value, now);
@@ -26,6 +26,8 @@ export function createD1Store(db, options = {}) {
26
26
  batch.push(write(D1_KEYS.character, patch.character));
27
27
  if (patch.signin)
28
28
  batch.push(write(D1_KEYS.signin, patch.signin));
29
+ if (patch.friends)
30
+ batch.push(write(D1_KEYS.friends, patch.friends));
29
31
  if (batch.length)
30
32
  await db.batch(batch);
31
33
  },
@@ -32,6 +32,7 @@ export declare const FIRESTORE_SCAN_LIMIT = 5000;
32
32
  export declare const FIRESTORE_KEYS: {
33
33
  readonly character: "setting:character";
34
34
  readonly signin: "setting:signin";
35
+ readonly friends: "setting:friends";
35
36
  };
36
37
  export declare function createFirestoreStore(db: FirestoreLike, collections?: {
37
38
  meta?: string;
@@ -1,15 +1,15 @@
1
1
  // The admin store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface below, which the
2
- // Admin SDK's `Firestore` fits. It writes ONLY the two settings; everything else it does is a read.
2
+ // Admin SDK's `Firestore` fits. It writes ONLY the three settings; everything else it does is a read.
3
3
  //
4
4
  // The data model (the names a game must have; only the three collection names can be changed):
5
- // meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "rate" (value is JSON { usd, sources })
5
+ // meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "setting:friends", "rate" (value is JSON { usd, sources })
6
6
  // orders/{id} { player, lookId, usd, amount, status, createdAt, expiresAt, paidAt, payer, hash } `amount` is the raw amount as text
7
7
  // owned/{player__lookId} { player, lookId, orderId, grantedAt }
8
8
  //
9
9
  // The summary reads the newest 5000 orders (the counts and the totals are over those) and every owned look: for a small game. A big game should keep running totals.
10
10
  import { parseRate, summarize } from "../summary.js";
11
11
  export const FIRESTORE_SCAN_LIMIT = 5000;
12
- export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin" };
12
+ export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" };
13
13
  const text = (v, fallback = "") => (typeof v === "string" ? v : fallback);
14
14
  const num = (v, fallback = 0) => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
15
15
  const orNull = (v) => (typeof v === "string" ? v : null);
@@ -23,8 +23,8 @@ export function createFirestoreStore(db, collections = {}) {
23
23
  };
24
24
  return {
25
25
  async readSettings() {
26
- const [character, signin] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin)]);
27
- return { character: character?.value, signin: signin?.value };
26
+ const [character, signin, friends] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin), valueOf(FIRESTORE_KEYS.friends)]);
27
+ return { character: character?.value, signin: signin?.value, friends: friends?.value };
28
28
  },
29
29
  async saveSettings(patch, now) {
30
30
  const batch = db.batch();
@@ -33,6 +33,8 @@ export function createFirestoreStore(db, collections = {}) {
33
33
  (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.character), { value: patch.character, at: now }), n++);
34
34
  if (patch.signin)
35
35
  (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
36
+ if (patch.friends)
37
+ (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.friends), { value: patch.friends, at: now }), n++);
36
38
  if (n)
37
39
  await batch.commit();
38
40
  },
@@ -71,7 +71,7 @@ export type StoreHarness = {
71
71
  at: number;
72
72
  };
73
73
  }): Promise<void>;
74
- /** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the two settings. Same rows, same text. */
74
+ /** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the three settings. Same rows, same text. */
75
75
  snapshot(): Promise<string>;
76
76
  /** Only for a store with `recentLogins` and `signOutEverywhere`: put admin sign-ins in the game's database (the store only reads them). */
77
77
  seedLogins?(rows: {
@@ -40,7 +40,7 @@ async function read(res) {
40
40
  /** A refusal carries words and a reason and nothing else: no data of the game. */
41
41
  const REFUSAL_KEYS = new Set(["error", "reason", "signedInAs"]);
42
42
  /** Words of the game's data that a refusal or an answer to a visitor must never hold. */
43
- const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"warnings"/;
43
+ const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"warnings"/;
44
44
  export function adminConformance(host) {
45
45
  const cases = [];
46
46
  const add = (name, run) => cases.push({ name, run });
@@ -264,7 +264,15 @@ export function adminStoreConformance(make) {
264
264
  const order = (id, over) => ({ id, player: A, lookId: "mint-cap", usd: "0.25", amountRaw: rawOf(0.25), status: "paid", createdAt: NOW - 1000, expiresAt: NOW + 1000, paidAt: NOW - 500, payer: A, hash: `H${id}`, ...over });
265
265
  add("a new store has no settings of its own", async ({ store }) => {
266
266
  const s = await store.readSettings();
267
- check(s.character === undefined && s.signin === undefined, `expected no settings, got ${JSON.stringify(s)}`);
267
+ check(s.character === undefined && s.signin === undefined && s.friends === undefined, `expected no settings, got ${JSON.stringify(s)}`);
268
+ });
269
+ add("the friends list is kept as its own setting: each of its three words, and a patch of it leaves the other two", async ({ store }) => {
270
+ await store.saveSettings({ signin: "none", character: "own" }, NOW);
271
+ for (const [i, friends] of ["own", "off", "nanoterra"].entries()) {
272
+ await store.saveSettings({ friends }, NOW + 1 + i);
273
+ const s = await store.readSettings();
274
+ check(s.friends === friends && s.signin === "none" && s.character === "own", `after friends ${friends}: ${JSON.stringify(s)}`);
275
+ }
268
276
  });
269
277
  add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
270
278
  await store.saveSettings({ signin: "none" }, NOW);
@@ -280,12 +288,12 @@ export function adminStoreConformance(make) {
280
288
  add("an empty patch writes nothing and does not fail", async ({ store }) => {
281
289
  await store.saveSettings({}, NOW);
282
290
  const s = await store.readSettings();
283
- check(s.character === undefined && s.signin === undefined, "an empty patch must save nothing");
291
+ check(s.character === undefined && s.signin === undefined && s.friends === undefined, "an empty patch must save nothing");
284
292
  });
285
- add("saving changes nothing but the two settings: no order, no owned look, no other meta row", async (h) => {
293
+ add("saving changes nothing but the settings: no order, no owned look, no other meta row", async (h) => {
286
294
  await h.seed({ orders: [order("1", {}), order("2", { status: "open" })], owned: [{ player: A, lookId: "mint-cap", at: NOW }], rate: { value: JSON.stringify({ usd: 1.2, sources: ["x"] }), at: NOW } });
287
295
  const before = await h.snapshot();
288
- await h.store.saveSettings({ character: "own", signin: "none" }, NOW + 5);
296
+ await h.store.saveSettings({ character: "own", signin: "none", friends: "off" }, NOW + 5);
289
297
  check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
290
298
  });
291
299
  add("the summary counts the orders by status, reads an open order past its expiry as expired, and totals the money", async ({ store, seed }) => {
@@ -59,5 +59,5 @@ export declare const ADMIN_LIMITS: {
59
59
  readonly gameBodyMax: 512;
60
60
  };
61
61
  export declare function createAdminHandler(options: AdminHandlerOptions): AdminHandler;
62
- /** The settings as the game's own routes need them (the listing file, the character): the stored words, or the game's defaults. A fault in the store gives the defaults. */
62
+ /** The settings as the game's own routes need them (the listing file, the character, the friends list): the stored words, or the game's defaults. A fault in the store gives the defaults. */
63
63
  export declare function loadGameSettings(store: Pick<AdminStore, "readSettings">, defaults: GameSettings): Promise<GameState>;
@@ -1,8 +1,8 @@
1
1
  // The admin handler: the routes of /admin on the Web `Request` and `Response` types, so it fits a Worker, Firebase Functions and Node.
2
2
  //
3
3
  // GET {base}/summary the numbers: status, orders, the looks that players own
4
- // GET {base}/game the two switches
5
- // POST {base}/game a press: { character } or { signin } (only the two known words)
4
+ // GET {base}/game the game switches
5
+ // POST {base}/game a press: { character }, { signin } or { friends } (only the known words)
6
6
  // POST {base}/check the payment check, when the game gives one
7
7
  //
8
8
  // EVERY request under the base goes through `adminAccess` first, before the route is even looked up: a visitor who is not the owner gets 401 or 403
@@ -159,7 +159,7 @@ export function createAdminHandler(options) {
159
159
  }
160
160
  return { routes, basePath: options.basePath ?? "/api/admin", handle };
161
161
  }
162
- /** The settings as the game's own routes need them (the listing file, the character): the stored words, or the game's defaults. A fault in the store gives the defaults. */
162
+ /** The settings as the game's own routes need them (the listing file, the character, the friends list): the stored words, or the game's defaults. A fault in the store gives the defaults. */
163
163
  export async function loadGameSettings(store, defaults) {
164
164
  try {
165
165
  return mergeSettings(await store.readSettings(), defaults);
@@ -9,5 +9,5 @@ export type { AdminLogin, AdminOrder, AdminPlayer, AdminStatus, AdminStore, Admi
9
9
  export { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, statusOf, summarize } from "./summary.js";
10
10
  export type { OrderRow } from "./summary.js";
11
11
  export { RAW_PER_XNO, xnoText } from "./amount.js";
12
- export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
13
- export type { Character, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
12
+ export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_FRIENDS, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
13
+ export type { Character, Friends, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // adminAccess the guard: one pure function, who may open /admin
4
4
  // createAdminHandler the routes, on Request and Response
5
- // loadGameSettings the two switches for the game's own routes
5
+ // loadGameSettings the game switches for the game's own routes
6
6
  // createMemoryStore a store in memory, for tests
7
7
  // (the conformance suite is its own path: "@nano133/nanoterra/admin/conformance"; the adapters are "/admin/d1" and "/admin/firestore")
8
8
  export { ADMIN_SESSION_MS, CLOCK_SKEW_MS, OWNER_PATTERN, adminAccess } from "./guard.js";
@@ -11,4 +11,4 @@ export { createMemoryStore } from "./memory.js";
11
11
  export { ADMIN_LOGINS_SHOWN } from "./store.js";
12
12
  export { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, statusOf, summarize } from "./summary.js";
13
13
  export { RAW_PER_XNO, xnoText } from "./amount.js";
14
- export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
14
+ export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_FRIENDS, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
@@ -14,13 +14,15 @@ export function createMemoryStore(seed = {}) {
14
14
  pendingAdminRequests: 0,
15
15
  pendingGameRequests: 0,
16
16
  async readSettings() {
17
- return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin") };
17
+ return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin"), friends: store.rows.get("setting:friends") };
18
18
  },
19
19
  async saveSettings(patch) {
20
20
  if (patch.character)
21
21
  store.rows.set("setting:character", patch.character);
22
22
  if (patch.signin)
23
23
  store.rows.set("setting:signin", patch.signin);
24
+ if (patch.friends)
25
+ store.rows.set("setting:friends", patch.friends);
24
26
  store.writes++;
25
27
  },
26
28
  async summary(now) {
@@ -2,6 +2,6 @@ import { type GameState } from "../switches.js";
2
2
  import { type Api } from "./api.js";
3
3
  /**
4
4
  * The card for the settings `initial`. It keeps its own state: a press shows "Saving…", then "Saved." or the server's words. `defaultsFile` is where the game's defaults are
5
- * written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the two switches too).
5
+ * written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the switches too).
6
6
  */
7
7
  export declare function gameCard(api: Api, initial: GameState, defaultsFile?: string, onChange?: (state: GameState) => void): HTMLElement;
@@ -1,12 +1,13 @@
1
- // The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra". A press saves at once
2
- // (POST {api}/game, behind the same admin guard as every admin call) and the game reads the setting at run time, so the creator pushes and deploys
3
- // nothing. The words are in ../switches.ts. The card changes ONLY these two settings: never a look, a price or an order.
4
- import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf } from "../switches.js";
1
+ // The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra", and one three-way
2
+ // choice, "Friends list: Nanoterra / The game's own / Off". A press saves at once (POST {api}/game, behind the same admin guard as every admin call) and
3
+ // the game reads the setting at run time, so the creator pushes and deploys nothing. The words are in ../switches.ts. The card changes ONLY these three
4
+ // settings: never a look, a price or an order.
5
+ import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf } from "../switches.js";
5
6
  import { ApiError } from "./api.js";
6
7
  import { h } from "./dom.js";
7
8
  /**
8
9
  * The card for the settings `initial`. It keeps its own state: a press shows "Saving…", then "Saved." or the server's words. `defaultsFile` is where the game's defaults are
9
- * written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the two switches too).
10
+ * written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the switches too).
10
11
  */
11
12
  export function gameCard(api, initial, defaultsFile = "the game's own settings", onChange) {
12
13
  let state = initial;
@@ -14,7 +15,11 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
14
15
  /** Under the character switch: it works only through sign-in, so it says so, and says what a player gets while sign-in is Off. */
15
16
  const needs = h("p", { class: "fine switch-needs" });
16
17
  const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("div", { class: "card-head" }, h("h2", { id: "game-title" }, "Game")));
17
- card.append(h("p", { class: "fine card-intro" }, `Two choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
18
+ card.append(h("p", { class: "fine card-intro" }, `Three choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
19
+ /** The friends choice: a radio group of three keys (arrow keys move between them, as a radio group does). */
20
+ const friendsKeys = new Map();
21
+ const friendsNote = h("p", { class: "switch-note", id: "switch-friends-note" });
22
+ const friendsStatus = h("span", { class: "fine", role: "status", "aria-live": "polite" });
18
23
  const draw = () => {
19
24
  for (const [id, row] of rows) {
20
25
  const on = stateOf(id, state);
@@ -25,6 +30,33 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
25
30
  }
26
31
  needs.hidden = !stateOf("character", state);
27
32
  needs.textContent = signinOn(state.signin) ? CHARACTER_NEEDS_SIGNIN : CHARACTER_SIGNIN_OFF;
33
+ for (const [id, key] of friendsKeys) {
34
+ const on = state.friends === id;
35
+ key.setAttribute("aria-checked", on ? "true" : "false");
36
+ key.tabIndex = on ? 0 : -1;
37
+ key.classList.toggle("is-on", on);
38
+ }
39
+ friendsNote.textContent = FRIENDS_CHOICES[state.friends]?.note ?? "";
40
+ };
41
+ const choose = async (id) => {
42
+ if (state.friends === id)
43
+ return;
44
+ for (const key of friendsKeys.values())
45
+ key.disabled = true;
46
+ friendsStatus.textContent = "Saving…";
47
+ try {
48
+ state = await api.post("/game", { friends: id });
49
+ friendsStatus.textContent = "Saved.";
50
+ draw();
51
+ onChange?.(state);
52
+ }
53
+ catch (e) {
54
+ friendsStatus.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Nothing was changed.";
55
+ }
56
+ finally {
57
+ for (const key of friendsKeys.values())
58
+ key.disabled = false;
59
+ }
28
60
  };
29
61
  const press = async (id) => {
30
62
  const row = rows.get(id);
@@ -55,6 +87,24 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
55
87
  if (id === "signin")
56
88
  card.append(h("p", { class: "fine switch-review" }, REVIEW_NOTE, " ", h("a", { href: REVIEW_LINK, target: "_blank", rel: "noopener noreferrer" }, "Open the launch page")));
57
89
  }
90
+ const order = Object.keys(FRIENDS_CHOICES);
91
+ const group = h("div", { class: "seg friends-choice", role: "radiogroup", "aria-labelledby": "switch-friends-label", "aria-describedby": "switch-friends-note" });
92
+ for (const id of order) {
93
+ const key = h("button", { class: "seg-btn", type: "button", role: "radio", "aria-checked": "false", "data-friends": id }, FRIENDS_CHOICES[id].name);
94
+ key.addEventListener("click", () => void choose(id));
95
+ key.addEventListener("keydown", (e) => {
96
+ const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
97
+ if (!step)
98
+ return;
99
+ e.preventDefault();
100
+ const next = order[(order.indexOf(id) + step + order.length) % order.length];
101
+ friendsKeys.get(next).focus();
102
+ void choose(next);
103
+ });
104
+ friendsKeys.set(id, key);
105
+ group.append(key);
106
+ }
107
+ card.append(h("div", { class: "switch-row friends-row" }, h("div", { class: "switch-head" }, h("span", { class: "switch-title", id: "switch-friends-label" }, FRIENDS_TITLE), group), friendsNote, friendsStatus));
58
108
  draw();
59
109
  return card;
60
110
  }