@nano133/nanoterra 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +206 -10
  3. package/ai/SPEC.md +51 -2
  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 +12 -4
  10. package/dist/admin/handler.d.ts +1 -1
  11. package/dist/admin/handler.js +2 -2
  12. package/dist/admin/index.d.ts +2 -2
  13. package/dist/admin/index.js +1 -1
  14. package/dist/admin/memory.js +3 -1
  15. package/dist/admin/panel/gameCard.js +13 -10
  16. package/dist/admin/panel/icons.d.ts +17 -0
  17. package/dist/admin/panel/icons.js +3 -0
  18. package/dist/admin/panel/overview.js +1 -1
  19. package/dist/admin/store.d.ts +2 -1
  20. package/dist/admin/switches.d.ts +13 -3
  21. package/dist/admin/switches.js +25 -6
  22. package/dist/character/create.js +6 -2
  23. package/dist/character/layer.d.ts +14 -0
  24. package/dist/character/layer.js +10 -0
  25. package/dist/emotes/clip.d.ts +79 -0
  26. package/dist/emotes/clip.js +165 -0
  27. package/dist/emotes/clips.d.ts +3 -0
  28. package/dist/emotes/clips.js +2 -0
  29. package/dist/emotes/events.d.ts +22 -0
  30. package/dist/emotes/events.js +19 -0
  31. package/dist/emotes/icons.d.ts +1 -0
  32. package/dist/emotes/icons.js +6 -0
  33. package/dist/emotes/index.d.ts +8 -0
  34. package/dist/emotes/index.js +11 -0
  35. package/dist/emotes/list.d.ts +123 -0
  36. package/dist/emotes/list.js +23 -0
  37. package/dist/emotes/options.d.ts +16 -0
  38. package/dist/emotes/options.js +34 -0
  39. package/dist/emotes/panel/index.d.ts +3 -0
  40. package/dist/emotes/panel/index.js +9 -0
  41. package/dist/emotes/panel/mount.d.ts +56 -0
  42. package/dist/emotes/panel/mount.js +360 -0
  43. package/dist/emotes/player.d.ts +37 -0
  44. package/dist/emotes/player.js +101 -0
  45. package/dist/emotes/wheel.d.ts +42 -0
  46. package/dist/emotes/wheel.js +110 -0
  47. package/dist/friends/panel/mount.d.ts +3 -3
  48. package/dist/friends/panel/mount.js +46 -12
  49. package/dist/options/common.d.ts +88 -0
  50. package/dist/options/common.js +59 -0
  51. package/dist/options/core.d.ts +130 -0
  52. package/dist/options/core.js +420 -0
  53. package/dist/options/index.d.ts +7 -0
  54. package/dist/options/index.js +24 -0
  55. package/dist/options/panel/index.d.ts +2 -0
  56. package/dist/options/panel/index.js +10 -0
  57. package/dist/options/panel/mount.d.ts +30 -0
  58. package/dist/options/panel/mount.js +378 -0
  59. package/dist/options/reflect.d.ts +5 -0
  60. package/dist/options/reflect.js +27 -0
  61. package/package.json +12 -4
  62. package/src/admin/adapters/d1.ts +5 -4
  63. package/src/admin/adapters/firestore.ts +6 -5
  64. package/src/admin/conformance.ts +14 -5
  65. package/src/admin/handler.ts +2 -2
  66. package/src/admin/index.ts +2 -2
  67. package/src/admin/memory.ts +2 -1
  68. package/src/admin/panel/gameCard.ts +13 -9
  69. package/src/admin/panel/icons.ts +3 -0
  70. package/src/admin/panel/overview.ts +1 -1
  71. package/src/admin/store.ts +2 -2
  72. package/src/admin/switches.ts +29 -12
  73. package/src/character/create.ts +6 -2
  74. package/src/character/layer.ts +21 -0
  75. package/src/emotes/clip.ts +204 -0
  76. package/src/emotes/clips.ts +7 -0
  77. package/src/emotes/events.ts +28 -0
  78. package/src/emotes/icons.ts +7 -0
  79. package/src/emotes/icons.webp +0 -0
  80. package/src/emotes/index.ts +16 -0
  81. package/src/emotes/list.ts +33 -0
  82. package/src/emotes/options.ts +43 -0
  83. package/src/emotes/panel/index.ts +11 -0
  84. package/src/emotes/panel/mount.ts +390 -0
  85. package/src/emotes/panel/style.css +415 -0
  86. package/src/emotes/player.ts +131 -0
  87. package/src/emotes/wheel.ts +131 -0
  88. package/src/friends/panel/mount.ts +47 -13
  89. package/src/friends/panel/style.css +26 -0
  90. package/src/options/common.ts +65 -0
  91. package/src/options/core.ts +465 -0
  92. package/src/options/index.ts +41 -0
  93. package/src/options/panel/index.ts +12 -0
  94. package/src/options/panel/mount.ts +418 -0
  95. package/src/options/panel/style.css +528 -0
  96. package/src/options/reflect.ts +28 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 (2026-11-01)
4
+
5
+ - **An options menu for a game** (`@nano133/nanoterra/options`, `/options/panel` and `/options/style.css`). Wes: "build options menu and emotes in kit"
6
+ (2026-11-01).
7
+ - **The store** (`createOptions({ game, defs })`, no DOM): a game declares its settings as data, of four kinds: `"toggle"`, `"choice"`, `"range"` and `"key"`
8
+ (a key binding, a `KeyboardEvent.code`; two bindings never share a key: a taken key swaps). Each has a key, a short label, an optional help line, a tab
9
+ (`"basic"` or `"advanced"`), an optional group and a default, one value or one for each device class `{ phone?, tablet?, desktop?, reducedMotion?, default }`
10
+ (`detectDevice`: a coarse pointer with a short side under 600 px is a phone, another coarse pointer a tablet, else a desktop). The device's reduced-motion
11
+ setting is followed live until the player sets the option. Only the values that differ from the default are kept, in `localStorage` under
12
+ `n133-options:<game>`; a kept value that does not fit is dropped on read; a refused storage works in memory; `migrate` reads an old save. `get`, `set`,
13
+ `reset`, `values`, `defs`, `def`, `defaultOf`, `isDefault`, `subscribe`, `subscribeDefs`, `add` (a module's section, keys like `emotes.show`; a key used twice
14
+ throws) and `redetect`.
15
+ - **The common settings** a game opts into by key (`common(...)`, `COMMON_OPTIONS`): `masterVolume`, `musicVolume`, `effectsVolume`, `cameraSensitivity`,
16
+ `invertLook`, `stickSide` (a phone and a tablet only), `reducedMotion`, `quality` and `showFps`. `keyBinding(action, label, code)` and `optionSection(name, defs,
17
+ group?)` make defs.
18
+ - **The panel** (`mountOptions(el, store, { title?, key?, onOpen?, onClose?, onRangeDone?, confirm? })`): a gear key and a sheet (from the bottom on a phone held
19
+ upright, from the right on a wide or sideways screen) with Basic and Advanced tabs (ARIA tabs, the arrow keys), groups, a switch, a row of keys or a
20
+ drop-down, a slider with its value, a key cap that waits for a key. A change applies at once and shows in place (no Save key; the panel never scrolls back to
21
+ the top). "Reset to defaults" asks in the panel, never in a browser dialog. `key: false` for a game's own pause menu. Light and dark: it follows
22
+ `data-n133-theme` on `<html>`, else `prefers-color-scheme`; every colour is a `--n133-*` variable with a `--n133-dark-*` twin.
23
+ - `reflectOptions(store, ["reducedMotion"])` keeps `data-n133-opt-reduced-motion` on `<html>` for the page's CSS.
24
+ - Ideas from Echo Island's own draft (its key bindings with a swap, the live reduced motion, "Less motion" in the page's CSS, rows changed in place, a sound when
25
+ a slider is let go).
26
+ - The admin panel's icons have a `gear`.
27
+
28
+ - **Emotes for a game** (`@nano133/nanoterra/emotes`, `/emotes/player`, `/emotes/wheel`, `/emotes/style.css` and `/emotes/icons.webp`). Wes: "build options
29
+ menu and emotes in kit" (2026-11-01). Nano Island's eleven emotes, the same as nano133.com's (2026-10-30):
30
+ - **The clips** (`/emotes/player`): the Wave is the character's own `wave()`; Cheer, Clap, Dance, Bow, Shrug, Laugh, Point, Sit, Facepalm and Spin are the
31
+ island's Blender clips, made on this package's skeleton. `createEmotes(character, { onEmote, onStop })` gives `play(id)`, `stop()` and `playing`. A clip is a
32
+ layer over the character's pose: `update` solves the walk, the clip blends over it, then the tail, the wings and the hair follow, so the frame loop does not
33
+ change. The island's rules: fades of 0.12 s in, 0.22 s out at the end and 0.18 s on a stop; a whole-body clip stops on a move or a jump, an upper-body one
34
+ (Clap, Shrug, Point, Facepalm) plays over the walk; Dance and Sit loop until a move; reduced motion holds one frame. `playRemote(character, { id, at? })`
35
+ plays another player's emote and ignores an id that is not one; `stopEmote(character)`. The clips (84 kB, 18.8 kB gzip) load on the first emote
36
+ (`loadEmotes`, a dynamic `import()`, so a chunk of their own).
37
+ - **The wheel** (`/emotes/wheel`): `mountEmotes(el, { game, onPlay })`, the island's wheel in plain DOM: an Emotes key, the wheel in the middle over a dim
38
+ page, eight places, a tap or a hold-and-slide, Edit ("Your wheel is full. Take one off first."), B and 1 to 8, Escape; kept per game in this browser
39
+ (`n133-emotes:<game>`). Nanoterra's emotes, the game's own (`{ id, label, icon? }`), or both. Light and dark. The pictures load when it first opens.
40
+ - **Multiplayer:** `onEmote({ id, loop, at, again })` (a loop again every 10 s, `LOOP_AGAIN_MS`), `onStop({ id, at, why })`, and the message
41
+ `emoteMessage(e)` = `{ emote: id }`, read back with `readEmoteMessage`. The kit has no network.
42
+ - **Options:** `EMOTE_OPTIONS` for `/options`: "Show the Emotes key", "Show other players' emotes" and the wheel's key (`keys.emotes`, B).
43
+ - **Licence:** the clips and the icons picture are part of the package and under its MIT licence (Wes, 2026-11-01).
44
+ - **The admin's Game card: "Allow Nanoterra emotes"** (`emotes`: `"on"` by default, or `"off"`), kept as `setting:emotes` by the memory, D1 and Firestore stores
45
+ (a fourth setting; the store contract has a case for it). `GameState.emotes`, `DEFAULT_EMOTES`, `isEmotes`; the Overview shows it.
46
+
47
+ ## 0.7.1 (2026-10-31)
48
+
49
+ - **Friends: the panel asks in the panel, never in a browser dialog.** Remove, Block and Join (both modes) showed `window.confirm`; now the question and a key for
50
+ each answer show at the top of the panel, which opens for it. Escape or closing the panel answers no. The `confirm` option stays, and may return a promise.
51
+ - **Friends, "own": no Invite key without `onJoin`.** A game with no way to join a friend gave an invite that led nowhere.
52
+
3
53
  ## 0.7.0 (2026-10-30)
4
54
 
5
55
  - **The admin panel has a light and a dark theme** (`/admin/panel`, `/admin/style.css`). A theme key in the top bar (and on the signed-out screen) opens Device,
package/README.md CHANGED
@@ -4,13 +4,15 @@ Stage: Pre-alpha. What this means: https://nano133.com/stages
4
4
 
5
5
  Nanoterra for your game, in **one package**. [Nanoterra](https://nano133.com/build/nanoterra) is
6
6
  nano133.com's island. A player who is signed in there presses Play, and arrives at your game as
7
- themselves. This package gives your game four things:
7
+ themselves. This package gives your game these things:
8
8
 
9
9
  - **The sign-in ticket** (`/ticket`): the player's name, Nano address and character, read in the browser. No server, no secret.
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
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.
14
+ - **An options menu** (`/options`, unreleased): the game's settings as data, with defaults for each device, kept in the player's browser, and a ready Options key and panel (Basic and Advanced); common settings to opt into, and a section another module adds.
15
+ - **Emotes** (`/emotes`, unreleased): Nano Island's eleven emotes on the player's character (the Wave, Cheer, Clap, Dance and seven more, made in Blender), and the emote wheel of eight that the player picks, which also plays a game's own animations.
14
16
 
15
17
  It replaces `@nano133/character` 0.1.0, `@nano133/ticket` 0.1.0 and `@nano133/looks` 0.1.0
16
18
  (see "Moving from the three old packages"). Sign-in, checkout and late returns stay in their own packages
@@ -73,8 +75,13 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
73
75
  | `@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
76
  | `@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
77
  | `@nano133/nanoterra/friends/conformance` | the friends rules as test cases, for a store a game writes itself (unreleased) | no | n/a |
78
+ | `@nano133/nanoterra/options` | the options store (no DOM), the common settings, `reflectOptions` and the panel's `mountOptions` (unreleased) | no | 2.9 kB (`createOptions` alone), 8.7 kB (all) |
79
+ | `@nano133/nanoterra/options/panel` | the Options key and panel alone, and its stylesheet `@nano133/nanoterra/options/style.css` (unreleased) | no | 2.3 kB of CSS (9.4 kB minified) |
80
+ | `@nano133/nanoterra/emotes` | the emotes' list, the wheel's eight places, the multiplayer message and the player's options: no DOM, no Three.js (unreleased) | no | 1.4 kB |
81
+ | `@nano133/nanoterra/emotes/player` | the emotes on the kit's character: `createEmotes`, `playRemote`, `loadEmotes` (unreleased) | yes | 2.9 kB; the clips load on the first emote, a chunk of their own: 84 kB, 18.8 kB gzip |
82
+ | `@nano133/nanoterra/emotes/wheel` | the Emotes key and the emote wheel: `mountEmotes`, and its stylesheet `@nano133/nanoterra/emotes/style.css` (unreleased) | no | 5.9 kB + 7.4 kB of CSS (minified); the pictures (`/emotes/icons.webp`, 34 kB) load when the wheel first opens |
76
83
 
77
- Measured 2026-10-26 (the friends paths 2026-10-30): `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
84
+ Measured 2026-10-26 (the friends paths 2026-10-30, the options and the emotes paths 2026-11-01): `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
78
85
  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.
79
86
 
80
87
  ## The character
@@ -485,7 +492,7 @@ import { createAdminHandler } from "@nano133/nanoterra/admin";
485
492
 
486
493
  const admin = createAdminHandler({
487
494
  store, // your database: readSettings(), saveSettings(patch, now), summary(now) (a memory store is in the package, for tests)
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)
495
+ defaults: { character: "nanoterra", signin: "exchange", friends: "nanoterra", emotes: "on" }, // what the switches are until /admin sets them (your game.config.json; friends and emotes may be left out)
489
496
  owner: ownerAddress, // "nano_..." lowercase, its checksum checked by you; null when none is set
490
497
  session: (req, now) => readAdminSession(req, now), // YOUR admin login: its own cookie; return { address, createdAt, via: "wallet" } or null
491
498
  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")
@@ -511,9 +518,9 @@ neither header (curl) and the browser's own address bar (`Sec-Fetch-Site: none`)
511
518
  **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
512
519
  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
513
520
  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
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.
521
+ nothing of the fault (`onError` hears it). The only writes are the four 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.
515
522
 
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,
523
+ **Your own routes read the switches** with `loadGameSettings(store, defaults)` (for the listing file's `signin` word, the character, the friends list and the emotes): the stored word, else your default,
517
524
  and your defaults when the store fails.
518
525
 
519
526
  **The conformance suite** proves your whole chain, not only the guard: the cookie you read, the session lookup, the owner you pass.
@@ -559,8 +566,8 @@ admin may make an admin login, and never from another sign-in. A security read f
559
566
 
560
567
  ### The store (an adapter for your database)
561
568
 
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
569
+ `createAdminHandler` needs a store: three calls, `readSettings()`, `saveSettings(patch, now)` and `summary(now)`. Two adapters come with the package, and **both write only the four settings** (the D1 store also deletes the admin sessions, but only when the game turns on the sign-in list: see below)
570
+ (`setting:character`, `setting:signin`, `setting:friends` and `setting:emotes`); every other call they make is a read. Both must pass the same **store contract**, `adminStoreConformance(make)` in
564
571
  `@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,
565
572
  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.
566
573
 
@@ -622,7 +629,7 @@ status warnings, one banner); four numbers (Earned, Open now, To send back, Play
622
629
  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).
623
630
  **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
624
631
  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
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
632
+ look, with their looks. **Settings**: the **Game** card (the character and sign-in switches, the friends list's three-way choice and "Allow Nanoterra emotes"), the Sign-ins card and the facts (the address, the mode, the nodes, the last price). The tabs are
626
633
  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.
627
634
  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
628
635
  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`,
@@ -812,10 +819,199 @@ for (const c of friendsStoreConformance(async () => ({ store: newEmptyStore(), d
812
819
  ```
813
820
 
814
821
  **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
815
- 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
822
+ sends a leaving pulse when the page goes away. It draws Requests (Accept, Decline), Friends (online and the place, Invite to your `where` and Join an invite, both only when you
816
823
  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
817
824
  no live connection: only Nanoterra's do.
818
825
 
826
+ **Questions.** Remove, Block and (in both modes) Join ask the player first, in the panel itself: the question and a key for each answer; Escape or closing the
827
+ panel answers no. The kit never opens a browser dialog. To ask in your game's own dialog, pass `confirm(question)`, which returns a boolean or a promise of one.
828
+
829
+ ## The options menu (`/options`; unreleased)
830
+
831
+ A game's settings as data, kept in the player's browser, and a ready Options key and panel. A game declares its settings; the kit resolves their defaults for
832
+ the device, keeps only what the player changed, and draws them in two tabs, **Basic** and **Advanced**. A game with its own menu uses the store alone.
833
+
834
+ ```ts
835
+ import { common, createOptions, keyBinding, mountOptions, reflectOptions } from "@nano133/nanoterra/options";
836
+ import "@nano133/nanoterra/options/style.css";
837
+
838
+ const options = createOptions({
839
+ game: "my-game", // the storage key is n133-options:my-game
840
+ defs: [
841
+ ...common("masterVolume", "musicVolume", "effectsVolume", "cameraSensitivity", "invertLook", "stickSide", "reducedMotion", "quality"),
842
+ { kind: "toggle", key: "hints", label: "Show hints", help: "Arrows to the next gem", tab: "basic", group: "Game", default: true },
843
+ keyBinding("jump", "Jump", "Space"), // keys.jump: a KeyboardEvent.code
844
+ ],
845
+ });
846
+ music.volume = (options.get<number>("musicVolume") / 100) ** 2;
847
+ options.subscribe((key, value) => apply(key, value)); // every change: the panel, the game, a reset, a default that moved
848
+ reflectOptions(options, ["reducedMotion"]); // <html data-n133-opt-reduced-motion="true|false"> for your CSS
849
+ const panel = mountOptions(document.getElementById("options")!, options); // the gear key and the panel
850
+ // panel.open(), panel.close(), panel.isOpen, panel.destroy()
851
+ ```
852
+
853
+ **The defs.** Each has a `key`, a `label` (short: the player reads it), an optional `help` line, a `tab` (`"basic"` or `"advanced"`), an optional `group` heading, a
854
+ `default`, and optionally `devices` (show the row only on `"phone"`, `"tablet"` or `"desktop"`; the value is kept everywhere).
855
+
856
+ | `kind` | Value | Drawn as | Its own fields |
857
+ |---|---|---|---|
858
+ | `"toggle"` | `true` or `false` | a switch: the whole row is the key | |
859
+ | `"choice"` | one of `choices[].value` (text or a number) | a row of keys (at most 4 labels of 10 characters), else a drop-down | `choices: [{ value, label }]` (two or more), `display?: "buttons" \| "list"` |
860
+ | `"range"` | a number on a step from `min` to `max` | a slider, its value and unit beside the label | `min`, `max`, `step`, `unit?` (put after the number as written: `"%"`, `" s"`) |
861
+ | `"key"` | a `KeyboardEvent.code` (`"KeyE"`, `"Space"`): the key's place, the same on every layout | a key cap; pressed, it waits for the next key (Escape keeps the old one) | |
862
+
863
+ A bad def (an empty label, a default that does not fit, a choice with one value) and a key that is there twice throw a `TypeError` or an `Error` when the store is
864
+ made or `add` is called, with what is wrong; nothing of that list is added.
865
+
866
+ **Defaults for each device.** A default is one value, or one for each device class: `{ phone?, tablet?, desktop?, reducedMotion?, default }`. The class: a coarse
867
+ pointer (a finger) with a screen whose short side is under 600 px is a **phone**, any other coarse pointer a **tablet**, anything else a **desktop**
868
+ (`detectDevice`; pass `device: () => "phone"` to choose it yourself). `reducedMotion` wins while the device asks for less motion
869
+ (`prefers-reduced-motion: reduce`), and the kit follows that setting **live**: a player who never set the option follows the device when it changes.
870
+ `store.redetect()` reads both again (a game that passes its own `reducedMotion` function calls it).
871
+
872
+ **What the browser keeps.** One `localStorage` key, `n133-options:<game>`, holds **only the values that differ from this device's default**, so a better default you
873
+ ship later reaches every player who never changed that setting. A kept value that no longer fits (the wrong type, out of range, a choice you removed) is dropped
874
+ when it is read, and so is a key that no def has, unless it has a dot: a module's section (`emotes.show`) may be added later, so its kept values wait for it. A
875
+ browser that refuses storage (some private windows) keeps the values for the page. `migrate(stored, storage)` (optional) changes what was kept before it is read:
876
+ a renamed key, or a game's old save under another key (read it when `stored` is empty, then remove it).
877
+
878
+ **The store** (`createOptions` returns it; no DOM): `get(key)` (throws for an unknown key), `set(key, value)` (a range's number goes to its nearest step inside min
879
+ and max; a wrong type or an unknown choice throws a `TypeError`), `reset(key?)`, `values()`, `defs()`, `def(key)`, `defaultOf(key)`, `isDefault(key?)`,
880
+ `subscribe(fn(key, value))` (returns the call that stops it), `subscribeDefs(fn)`, `add(defs)`, `redetect()`, `game` and `device`. **Two key bindings never share
881
+ a key:** setting one to a key that another has gives the other this one's old key, and both changes are heard.
882
+
883
+ **The common settings** (`common(...keys)` gives copies; `COMMON_OPTIONS` holds them, to spread and change: `{ ...COMMON_OPTIONS.quality, default: "low" }`). They are
884
+ plain defs: your game does what they say.
885
+
886
+ | Key | Label | Tab, group | Kind | Default |
887
+ |---|---|---|---|---|
888
+ | `masterVolume` | Volume | Basic, Sound | range 0 to 100, step 5, `%` | 100 |
889
+ | `musicVolume` | Music | Basic, Sound | range 0 to 100, step 5, `%` | 100 |
890
+ | `effectsVolume` | Sound effects | Basic, Sound | range 0 to 100, step 5, `%` | 100 |
891
+ | `cameraSensitivity` | Camera speed | Basic, Camera | range 10 to 100, step 5 | 50 (the game's own speed) |
892
+ | `stickSide` | Move stick | Basic, Controls (a phone and a tablet only) | choice `"left"`, `"right"` | `"left"` |
893
+ | `invertLook` | Invert look | Advanced, Camera | toggle | off |
894
+ | `reducedMotion` | Less motion | Advanced, Comfort | toggle | the device's setting |
895
+ | `quality` | Quality | Advanced, Graphics | choice `"low"`, `"medium"`, `"high"` | `"medium"` on a phone, else `"high"` |
896
+ | `showFps` | Show frame rate | Advanced, Graphics | toggle | off |
897
+
898
+ A volume's square is a fair loudness curve (`(v / 100) ** 2`: Echo Island's). `keyBinding(action, label, code)` makes a `"key"` def `keys.<action>` in Advanced,
899
+ under "Keys", on a desktop only.
900
+
901
+ **A section from another module.** A kit module (the emotes) or a part of your game adds its own settings later, beside the others:
902
+ `options.add(optionSection("emotes", defs, "Emotes"))` puts `emotes.` before each key and the group "Emotes" on each def that has none. Its keys are namespaced
903
+ (`emotes.show`), a key that is already there throws at once, a value kept before the section came is read when it comes, and a mounted panel draws the new rows.
904
+
905
+ **The panel.** `mountOptions(el, store, { title?, key?, onOpen?, onClose?, onRangeDone?, confirm? })` puts a 44 px key (a gear and "Options") in `el`, and a panel: a
906
+ sheet from the bottom on a phone held upright, from the right on a wide or sideways screen, over a scrim (a press on it closes the panel). The two tabs are real tabs
907
+ (the arrow keys, Home and End; one tab alone has no tab bar); each tab draws its rows without a group first, then each group under its heading. **A change applies
908
+ at once** (there is no Save key), and a change from elsewhere shows in place: the rows are drawn once, a slider under a finger is never drawn again, and the panel
909
+ never scrolls back to the top. **Reset to defaults** asks in the panel ("Put every option back to its default?", Reset and Cancel), never in a browser dialog;
910
+ Escape answers the question first, then closes the panel. `key: false` draws no key, for a game that puts "Options" in its own pause menu and calls `open()`;
911
+ the focus goes back where it was. `onOpen` and `onClose` let a game pause under the panel; `onRangeDone(key, value)` hears a slider let go (play a sound at the new
912
+ level). The panel holds the focus while it is open. Every key, switch, choice and slider is 44 px or more, and a row's label is 16 px.
913
+
914
+ **Light and dark.** The panel follows `data-n133-theme="light" | "dark"` on the page's `<html>` when the page sets it (the admin panel does), else the device's
915
+ `prefers-color-scheme`. Every colour is a variable: `--n133-ink`, `--n133-ink-soft`, `--n133-paper`, `--n133-ground` (the tab bar and a choice's well),
916
+ `--n133-line`, `--n133-gem`, `--n133-gem-dark`, `--n133-on-gem` set the light theme, and each has a `--n133-dark-*` twin; `--n133-radius`, `--n133-font` and
917
+ `--n133-options-z` (default 1100, above the friends panel) too. The stylesheet is scoped under `.n133-options`.
918
+
919
+ **Less motion in your CSS.** `reflectOptions(store, keys, target?)` keeps each key's value on `<html>` (or `target`) as `data-n133-opt-<key in kebab case>`, and
920
+ returns the call that stops it:
921
+
922
+ ```css
923
+ html[data-n133-opt-reduced-motion="true"] .my-hud * { animation: none !important; transition: none !important; }
924
+ ```
925
+
926
+ ## Emotes (`/emotes`; unreleased)
927
+
928
+ Nano Island's emotes for a game: the same eleven, on the same character, with the same wheel. **The Wave** is the character's own `wave()`; **Cheer, Clap,
929
+ Dance, Bow, Shrug, Laugh, Point, Sit, Facepalm and Spin** are clips made in Blender on the character's own skeleton (the island makes them from this package's
930
+ `BONES`), so they fit every base. The wheel also plays **a game's own animations**: give it your list and an `onPlay`.
931
+
932
+ ```ts
933
+ import { createEmotes, loadEmotes, playRemote } from "@nano133/nanoterra/emotes/player";
934
+ import { emoteMessage, readEmoteMessage } from "@nano133/nanoterra/emotes";
935
+ import { mountEmotes } from "@nano133/nanoterra/emotes/wheel";
936
+ import "@nano133/nanoterra/emotes/style.css";
937
+
938
+ const emotes = createEmotes(hero, { onEmote: (e) => net.send(emoteMessage(e)) }); // hero: createCharacter(...); onEmote only for a multiplayer game
939
+ const wheel = mountEmotes(document.getElementById("emotes")!, {
940
+ game: "my-game", // the wheel's places are kept per game, in this browser
941
+ onPlay: (id) => emotes.play(id), // the player picked one
942
+ onOpen: () => void loadEmotes(), // the clips load when the wheel first opens, not with the game
943
+ nanoterraEmotes: settings.emotes, // the admin's "Allow Nanoterra emotes": "on" (the default) or "off"
944
+ });
945
+ // Each frame: nothing new. hero.update(dt, motion) plays the emote over the walk.
946
+ // Another player's emote, from your network:
947
+ net.on("message", (m, from) => { const id = readEmoteMessage(m); if (id) playRemote(characterOf(from), { id }); });
948
+ ```
949
+
950
+ **The clips.** A clip turns each bone from its parent, 30 frames a second, and the hips' place; the game runs no IK for it. It plays as a layer over the
951
+ character's pose: `update` solves the walk, then the clip blends over it (a fade in of 0.12 s, a fade out of 0.22 s at its end), then the tail, the wings and the
952
+ long hair follow. The rules are the island's:
953
+
954
+ | Emotes | Kind | What stops it |
955
+ |---|---|---|
956
+ | Cheer, Dance, Bow, Laugh, Sit, Spin | the whole body | a move (the pose's speed over 0.4 m/s), a jump, the water, or `stop()`, with a fade of 0.18 s |
957
+ | Clap, Shrug, Point, Facepalm | the spine, the arms and the head: they play over the walk | their end, the water, or `stop()` |
958
+ | Dance, and Sit (its sitting down plays once, then its seated part) | loops | as above: a loop plays until the player moves |
959
+ | The Wave | the character's own wave (1.8 s) | its end |
960
+
961
+ With `reducedMotion` on the character, an emote shows its one picture (the clip's hold frame) for its time. Point never raises a straight arm: the hand stays
962
+ below the shoulder with the elbow bent (Wes's rule on the island). A clip's time is the sum of the steps `update` took, so a paused game pauses its emotes.
963
+ The kit's pose has no swim: a game with water calls `emotes.stop()` (or `stopEmote(character)`) when a character goes in.
964
+
965
+ **Loaded only when needed.** The clips (84 kB, 18.8 kB gzip) are in a module that only `import()` reaches (`loadEmotes`), so a bundler puts them in a chunk of
966
+ their own; `onOpen` loads them when the wheel first opens, and a clip that plays before they arrive (another player's) loads them and starts when they come. The
967
+ pictures (`icons.webp`, one strip of eleven 128 px squares, 34 kB) are found with `new URL("…", import.meta.url)` and load when the wheel first shows them. Checked
968
+ on Vite 8 (`vite build`: a `clips-<hash>.js` chunk and an `icons-<hash>.webp` file, neither fetched before the wheel opens; `vite` dev with the package
969
+ pre-bundled: the same) and in plain ESM with an import map. A bundler that does not follow `new URL(…, import.meta.url)` can pass the picture's address itself
970
+ (`icons`), e.g. Vite's `import icons from "@nano133/nanoterra/emotes/icons.webp?url"`.
971
+
972
+ | Call (`/emotes/player`) | What it does |
973
+ |---|---|
974
+ | `createEmotes(character, { onEmote?, onStop? })` | The local player's emotes. `play(id)` plays a Nanoterra emote (false, and nothing happens, for another id); `stop()` stops the clip with its fade; `playing` is the clip on show or null; `destroy()` stops the events. |
975
+ | `onEmote({ id, loop, at, again })` | The player played an emote; while a loop plays, it fires again every 10 s (`LOOP_AGAIN_MS`) with `again: true`, so a player who comes later sees it. Send `emoteMessage(e)`, which is `{ emote: id }`. |
976
+ | `onStop({ id, at, why })` | The player's clip ended: `"end"`, `"move"` (a move, a jump or the water) or `"stop"`. Nothing to send for a move: the others see the character move, and that ends it on their screens too. Send a stop only for `"stop"`, and call `stopEmote` on their side. |
977
+ | `playRemote(character, { id, at? }, { options? })` | Another player's emote on their character. An id that is not one of the eleven is ignored (false). The same play twice (the same `at`) is one, so you may pass the last one each frame; a loop that is on show goes on through a repeat. `at` is when it came (`performance.now()`; left out: now). |
978
+ | `stopEmote(character)` | Stops any character's clip, with its fade. |
979
+ | `loadEmotes()`, `emotesLoaded()` | Loads the clips (once; a failed load may be tried again), and says whether they are here. |
980
+
981
+ **The wheel** (`/emotes/wheel`) is nano133's island wheel in plain DOM. `mountEmotes(element, options)` puts an **Emotes** key (44 px) in `element`; a press opens the
982
+ wheel in the middle of the screen over a dim page: eight round places (58 px or more), slot 1 at the top and on clockwise, each the emote's picture and name, and a
983
+ middle disc with the name of the place under the finger and **Edit**.
984
+
985
+ - A tap on the key opens it and a tap on a place plays it; or a **hold on the key and a slide** onto a place and a let-go plays that one (a slide under 14 px is a tap; a
986
+ let-go in the middle leaves the wheel open). A press beside the wheel or Escape closes it.
987
+ - On a keyboard **B** opens and closes it (not while a field has the keys, or when your `keyAllowed(e)` says no: B bound to an action, a dialog of your own) and **1 to
988
+ 8** play its places while it is open; the places show their numbers only where there is a mouse.
989
+ - **Edit** shows every emote of the list (it may hold more than eight; it scrolls): a tap puts one on the wheel in its first empty place or takes it off; a ninth says
990
+ "Your wheel is full. Take one off first."; a tap on a place takes its emote off. The places are kept in this browser per game (`n133-emotes:<game>`); a browser
991
+ that refuses storage keeps them for the page. Nanoterra's first wheel is the island's: Wave, Cheer, Clap, Dance, Laugh, Point, Sit, Spin.
992
+ - `emotes`: the list, Nanoterra ids (`"dance"`) and your own (`{ id, label, icon? }`, `icon` a square picture's address) in the order Edit shows them; default
993
+ Nanoterra's eleven. `defaults`: your first wheel. `nanoterraEmotes: "off"` (the admin's switch) leaves Nanoterra's out and keeps yours; with nothing left
994
+ there is no key. `keys: false` turns B and 1 to 8 off. `theme`: `"light"`, `"dark"` or `"device"` (the default: the page's `data-n133-theme`, which the admin
995
+ panel sets too, else `prefers-color-scheme`). It returns `open()`, `close()`, `isOpen`, `slots` and `destroy()`.
996
+ - Theme it with the same `--n133-*` properties as the admin panel (and their `--n133-dark-*` twins), and `--n133-emotes-z` for its z-index (default 1000).
997
+
998
+ The clips and the icons are part of the package and under its MIT licence.
999
+
1000
+ **The admin's switch.** The Game card has **"Allow Nanoterra emotes"** (`emotes` in the settings: `"on"`, the default, or `"off"`). Read it with
1001
+ `loadGameSettings` and pass it to the wheel as `nanoterraEmotes`. When it is off, also do not call `playRemote` and do not send the player's emotes: the game shows no
1002
+ Nanoterra emote. Your own emotes may still use the wheel.
1003
+
1004
+ **The player's options.** For the options menu (`/options`), `/emotes` exports three settings: `emotes.show` "Show the Emotes key" (Basic, on),
1005
+ `emotes.others` "Show other players' emotes" (Advanced, on) and the wheel's key, `keys.emotes` "Emotes wheel" (Advanced, under Keys, B, a desktop only: what
1006
+ `keyBinding("emotes", "Emotes wheel", "KeyB")` makes). `EMOTE_OPTIONS` holds all three with their whole keys, for `createOptions({ defs: [...yours, ...EMOTE_OPTIONS] })`
1007
+ or `store.add(EMOTE_OPTIONS)`; `EMOTE_OPTION_DEFS` holds the two toggles with short keys, for `store.add(optionSection("emotes", EMOTE_OPTION_DEFS, "Emotes"))`, and
1008
+ `EMOTE_KEY_OPTION` the key. Pass the store as `options` to `mountEmotes` (the key hides, live; B, or the player's own key, still opens the wheel) and to `playRemote`
1009
+ (the others' emotes do not play). With no store, both toggles are on and the key is B. `/emotes` imports only the options module's types, none of its code.
1010
+
1011
+ **The message on the wire** is yours to carry: one small object, `{ emote: id }`. nano133.com's island is one example, not a rule: its room server takes
1012
+ `{ t: "react", k: "dance" }` from a player, checks the id against its own list and sends the others `{ t: "react", id: <player>, k: "dance" }`; an old server that
1013
+ does not know an id drops it, and nothing breaks.
1014
+
819
1015
  ## Moving from the three old packages
820
1016
 
821
1017
  | Before | Now |
@@ -834,7 +1030,7 @@ no live connection: only Nanoterra's do.
834
1030
  - The island's item art (see "Worn things and sockets"): a game brings its own things.
835
1031
  - A pose for a held thing: the arms swing free, so a two-handed thing (a `grip_L` as well as a `grip_R`) is held in the right hand only.
836
1032
  - The katana, the saya, the headband and the dojo look of the island's Ananos game. (The skeleton keeps their bones, with no vertices.)
837
- - First-person arms, swimming, climbing, sitting and the room's other moves: only idle, walk, run, jump and wave.
1033
+ - First-person arms, swimming, climbing and the room's other moves: only idle, walk, run, jump and wave, and the emotes (`/emotes`, which sit too).
838
1034
  - Shared geometry for many characters at once, and a react-three-fiber wrapper.
839
1035
  - `WebGPURenderer`: the ink is a GLSL `ShaderMaterial`, so use `WebGLRenderer`.
840
1036
  - Any server or Nano code. This package draws a character and reads a ticket; it moves no money.
package/ai/SPEC.md CHANGED
@@ -2,7 +2,7 @@
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
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.
5
+ looks ladder (`/looks`), held input that does not stick (`/input`), the friends list (`/friends`, with its panel), the options menu (`/options`) and the emotes (`/emotes`, with the wheel). 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
 
@@ -14,6 +14,8 @@ looks ladder (`/looks`), held input that does not stick (`/input`) and the frien
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
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.
17
+ 7. The options menu (step 10): which settings the game can really honour (a game with no music offers no Music slider), and whether the game opens it from its own
18
+ pause menu or shows the kit's gear key.
17
19
 
18
20
  **Check:** the owner has named the server (yes or no), the source of the character and the quality.
19
21
 
@@ -139,7 +141,54 @@ import "@nano133/nanoterra/friends/style.css";
139
141
  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
142
  `"off"`, no key.
141
143
 
142
- ## 10. Do not
144
+ ## 10. The options menu (any game with settings)
145
+
146
+ ```ts
147
+ import { common, createOptions, keyBinding, mountOptions, reflectOptions } from "@nano133/nanoterra/options";
148
+ import "@nano133/nanoterra/options/style.css";
149
+ ```
150
+
151
+ - Make ONE store when the game starts: `createOptions({ game, defs })`, with `game` the game's id. Take the common settings the game honours by key
152
+ (`common("masterVolume", "musicVolume", ...)`) and add the game's own defs (`kind` `"toggle"`, `"choice"`, `"range"` or `"key"`; a short `label`; `tab`
153
+ `"basic"` for what most players want, `"advanced"` for the rest; a `group`; a `default`, one value or `{ phone?, tablet?, desktop?, reducedMotion?, default }`).
154
+ - Read values with `get(key)` where the game uses them, and apply a change at once in `subscribe((key, value) => ...)`: a volume, the camera speed, the quality.
155
+ Never keep a second copy of a setting in the game's own storage.
156
+ - `mountOptions(el, store)` for the gear key, or `mountOptions(el, store, { key: false })` and `open()` from the game's pause menu. Pause a free round in
157
+ `onOpen` and go on in `onClose`.
158
+ - With `reducedMotion`: honour it in the game's code (no shakes, no camera swings, no flashes) and call `reflectOptions(store, ["reducedMotion"])` so the page's
159
+ CSS can calm its own animations (`html[data-n133-opt-reduced-motion="true"]`).
160
+ - A kit module or a part of the game that comes later adds a section: `store.add(optionSection("name", defs, "Heading"))`. Its keys start with `name.`; never
161
+ use a key twice (the store throws).
162
+ - Do not use a setting for a game's rules, a score or a payment: a setting only changes how the game looks, sounds and is controlled.
163
+
164
+ **Check:** change a setting, reload: it holds; set it back: the browser keeps nothing for it (`localStorage["n133-options:<game>"]` has no such key). On a
165
+ phone held upright the panel is a sheet from the bottom, on a desktop a sheet on the right; the tabs work by touch; Reset asks in the panel.
166
+
167
+ ## 11. Emotes (any game with the kit's character, or its own animations; the admin's `emotes` setting)
168
+
169
+ ```ts
170
+ import { createEmotes, loadEmotes, playRemote } from "@nano133/nanoterra/emotes/player";
171
+ import { emoteMessage, readEmoteMessage, EMOTE_OPTIONS } from "@nano133/nanoterra/emotes";
172
+ import { mountEmotes } from "@nano133/nanoterra/emotes/wheel";
173
+ import "@nano133/nanoterra/emotes/style.css";
174
+ ```
175
+
176
+ - Read `emotes` with `loadGameSettings` (`"on"` by default, or `"off"`) and pass it to the wheel as `nanoterraEmotes`. When it is `"off"`, do not call
177
+ `playRemote` and do not send the player's emotes. Do not read it in a second place.
178
+ - With the kit's character: `const emotes = createEmotes(hero)` once, and `mountEmotes(el, { game, onPlay: (id) => emotes.play(id), onOpen: () => void
179
+ loadEmotes() })`. Change nothing in the frame loop: `hero.update(dt, motion)` plays the emote. Never import the clips yourself: `loadEmotes` loads them.
180
+ - With the game's own animations: `mountEmotes(el, { game, emotes: [{ id, label, icon? }, ...], onPlay })`; Nanoterra's ids may be in the same list.
181
+ - Multiplayer: send `emoteMessage(e)` from `onEmote` (a loop fires again every 10 s: send it again); on the other side, `readEmoteMessage(m)` and
182
+ `playRemote(characterOf(sender), { id })`, which ignores an id that is not an emote. Nothing to send when the player moves: the move ends it everywhere.
183
+ A server that relays emotes checks the id against the list (`EMOTES`) and drops any other.
184
+ - With an options menu (step 10): add `EMOTE_OPTIONS` to the store and pass the store as `options` to `mountEmotes` and `playRemote`.
185
+ - A game with water calls `emotes.stop()` (or `stopEmote(character)`) when a character goes in: the kit's pose does not swim.
186
+
187
+ **Check:** the network tab shows no `clips` chunk and no `icons` picture before the wheel first opens. On a phone held upright: a tap on Emotes opens the wheel in
188
+ the middle; a hold on the key and a slide onto a place plays it; Edit refuses a ninth with "Your wheel is full. Take one off first."; the wheel holds after a
189
+ reload. A walk ends Dance; Clap goes on over the walk. On a keyboard: B opens it, 2 plays the second place, Escape closes it.
190
+
191
+ ## 12. Do not
143
192
 
144
193
  - Do not edit the geometry, the skeleton or the toon and ink materials.
145
194
  - 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
@@ -16,6 +16,7 @@ export declare const D1_KEYS: {
16
16
  readonly character: "setting:character";
17
17
  readonly signin: "setting:signin";
18
18
  readonly friends: "setting:friends";
19
+ readonly emotes: "setting:emotes";
19
20
  };
20
21
  /**
21
22
  * 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 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
2
+ // It writes ONLY the four settings (`setting:character`, `setting:signin`, `setting:friends`, `setting:emotes` 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", friends: "setting:friends" };
8
+ export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes" };
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, D1_KEYS.friends).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, D1_KEYS.emotes).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), friends: row(D1_KEYS.friends) };
20
+ return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends), emotes: row(D1_KEYS.emotes) };
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);
@@ -28,6 +28,8 @@ export function createD1Store(db, options = {}) {
28
28
  batch.push(write(D1_KEYS.signin, patch.signin));
29
29
  if (patch.friends)
30
30
  batch.push(write(D1_KEYS.friends, patch.friends));
31
+ if (patch.emotes)
32
+ batch.push(write(D1_KEYS.emotes, patch.emotes));
31
33
  if (batch.length)
32
34
  await db.batch(batch);
33
35
  },
@@ -33,6 +33,7 @@ export declare const FIRESTORE_KEYS: {
33
33
  readonly character: "setting:character";
34
34
  readonly signin: "setting:signin";
35
35
  readonly friends: "setting:friends";
36
+ readonly emotes: "setting:emotes";
36
37
  };
37
38
  export declare function createFirestoreStore(db: FirestoreLike, collections?: {
38
39
  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 three settings; everything else it does is a read.
2
+ // Admin SDK's `Firestore` fits. It writes ONLY the four 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", "setting:friends", "rate" (value is JSON { usd, sources })
5
+ // meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "setting:friends", "setting:emotes", "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", friends: "setting:friends" };
12
+ export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes" };
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, 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 };
26
+ const [character, signin, friends, emotes] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin), valueOf(FIRESTORE_KEYS.friends), valueOf(FIRESTORE_KEYS.emotes)]);
27
+ return { character: character?.value, signin: signin?.value, friends: friends?.value, emotes: emotes?.value };
28
28
  },
29
29
  async saveSettings(patch, now) {
30
30
  const batch = db.batch();
@@ -35,6 +35,8 @@ export function createFirestoreStore(db, collections = {}) {
35
35
  (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
36
36
  if (patch.friends)
37
37
  (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.friends), { value: patch.friends, at: now }), n++);
38
+ if (patch.emotes)
39
+ (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.emotes), { value: patch.emotes, at: now }), n++);
38
40
  if (n)
39
41
  await batch.commit();
40
42
  },
@@ -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 three 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 four 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"|"friends"|"warnings"/;
43
+ const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"emotes"|"warnings"/;
44
44
  export function adminConformance(host) {
45
45
  const cases = [];
46
46
  const add = (name, run) => cases.push({ name, run });
@@ -264,7 +264,7 @@ 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 && s.friends === undefined, `expected no settings, got ${JSON.stringify(s)}`);
267
+ check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined, `expected no settings, got ${JSON.stringify(s)}`);
268
268
  });
269
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
270
  await store.saveSettings({ signin: "none", character: "own" }, NOW);
@@ -274,6 +274,14 @@ export function adminStoreConformance(make) {
274
274
  check(s.friends === friends && s.signin === "none" && s.character === "own", `after friends ${friends}: ${JSON.stringify(s)}`);
275
275
  }
276
276
  });
277
+ add("the emotes switch is kept as its own setting: off and on, and a patch of it leaves the other three", async ({ store }) => {
278
+ await store.saveSettings({ signin: "none", character: "own", friends: "off" }, NOW);
279
+ for (const [i, emotes] of ["off", "on"].entries()) {
280
+ await store.saveSettings({ emotes }, NOW + 1 + i);
281
+ const s = await store.readSettings();
282
+ check(s.emotes === emotes && s.signin === "none" && s.character === "own" && s.friends === "off", `after emotes ${emotes}: ${JSON.stringify(s)}`);
283
+ }
284
+ });
277
285
  add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
278
286
  await store.saveSettings({ signin: "none" }, NOW);
279
287
  let s = await store.readSettings();
@@ -288,12 +296,12 @@ export function adminStoreConformance(make) {
288
296
  add("an empty patch writes nothing and does not fail", async ({ store }) => {
289
297
  await store.saveSettings({}, NOW);
290
298
  const s = await store.readSettings();
291
- check(s.character === undefined && s.signin === undefined && s.friends === undefined, "an empty patch must save nothing");
299
+ check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined, "an empty patch must save nothing");
292
300
  });
293
301
  add("saving changes nothing but the settings: no order, no owned look, no other meta row", async (h) => {
294
302
  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 } });
295
303
  const before = await h.snapshot();
296
- await h.store.saveSettings({ character: "own", signin: "none", friends: "off" }, NOW + 5);
304
+ await h.store.saveSettings({ character: "own", signin: "none", friends: "off", emotes: "off" }, NOW + 5);
297
305
  check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
298
306
  });
299
307
  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 friends list): 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 emotes): 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>;
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // GET {base}/summary the numbers: status, orders, the looks that players own
4
4
  // GET {base}/game the game switches
5
- // POST {base}/game a press: { character }, { signin } or { friends } (only the known words)
5
+ // POST {base}/game a press: { character }, { signin }, { friends } or { emotes } (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 friends list): 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 emotes): 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);