@nano133/nanoterra 0.8.1 → 0.9.3

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 (122) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +390 -6
  3. package/ai/SPEC.md +71 -3
  4. package/dist/admin/panel/mount.d.ts +3 -0
  5. package/dist/admin/panel/mount.js +4 -2
  6. package/dist/player-signin/adapters/d1.d.ts +10 -0
  7. package/dist/player-signin/adapters/d1.js +108 -0
  8. package/dist/player-signin/adapters/firestore.d.ts +45 -0
  9. package/dist/player-signin/adapters/firestore.js +168 -0
  10. package/dist/player-signin/conformance.d.ts +11 -0
  11. package/dist/player-signin/conformance.js +160 -0
  12. package/dist/player-signin/handler.d.ts +64 -0
  13. package/dist/player-signin/handler.js +364 -0
  14. package/dist/player-signin/host.d.ts +4 -0
  15. package/dist/player-signin/host.js +10 -0
  16. package/dist/player-signin/index.d.ts +9 -0
  17. package/dist/player-signin/index.js +15 -0
  18. package/dist/player-signin/memory.d.ts +15 -0
  19. package/dist/player-signin/memory.js +101 -0
  20. package/dist/player-signin/panel/index.d.ts +9 -0
  21. package/dist/player-signin/panel/index.js +8 -0
  22. package/dist/player-signin/panel/memo.d.ts +12 -0
  23. package/dist/player-signin/panel/memo.js +41 -0
  24. package/dist/player-signin/panel/mount.d.ts +21 -0
  25. package/dist/player-signin/panel/mount.js +107 -0
  26. package/dist/player-signin/panel/view.d.ts +48 -0
  27. package/dist/player-signin/panel/view.js +363 -0
  28. package/dist/player-signin/panel/words.d.ts +103 -0
  29. package/dist/player-signin/panel/words.js +111 -0
  30. package/dist/player-signin/scheme-check.d.ts +3 -0
  31. package/dist/player-signin/scheme-check.js +14 -0
  32. package/dist/player-signin/scheme.d.ts +13 -0
  33. package/dist/player-signin/scheme.js +28 -0
  34. package/dist/player-signin/types.d.ts +116 -0
  35. package/dist/player-signin/types.js +33 -0
  36. package/dist/shop/adapters/d1.d.ts +25 -0
  37. package/dist/shop/adapters/d1.js +200 -0
  38. package/dist/shop/adapters/firestore.d.ts +53 -0
  39. package/dist/shop/adapters/firestore.js +245 -0
  40. package/dist/shop/address.d.ts +14 -0
  41. package/dist/shop/address.js +104 -0
  42. package/dist/shop/amount.d.ts +26 -0
  43. package/dist/shop/amount.js +59 -0
  44. package/dist/shop/conformance.d.ts +13 -0
  45. package/dist/shop/conformance.js +203 -0
  46. package/dist/shop/handler.d.ts +76 -0
  47. package/dist/shop/handler.js +156 -0
  48. package/dist/shop/index.d.ts +19 -0
  49. package/dist/shop/index.js +21 -0
  50. package/dist/shop/match.d.ts +23 -0
  51. package/dist/shop/match.js +58 -0
  52. package/dist/shop/memory.d.ts +13 -0
  53. package/dist/shop/memory.js +106 -0
  54. package/dist/shop/nodes.d.ts +47 -0
  55. package/dist/shop/nodes.js +106 -0
  56. package/dist/shop/orders.d.ts +47 -0
  57. package/dist/shop/orders.js +82 -0
  58. package/dist/shop/panel/index.d.ts +9 -0
  59. package/dist/shop/panel/index.js +14 -0
  60. package/dist/shop/panel/looks.d.ts +33 -0
  61. package/dist/shop/panel/looks.js +61 -0
  62. package/dist/shop/panel/mount.d.ts +68 -0
  63. package/dist/shop/panel/mount.js +221 -0
  64. package/dist/shop/panel/pay.d.ts +27 -0
  65. package/dist/shop/panel/pay.js +128 -0
  66. package/dist/shop/panel/qr.d.ts +9 -0
  67. package/dist/shop/panel/qr.js +281 -0
  68. package/dist/shop/panel/words.d.ts +89 -0
  69. package/dist/shop/panel/words.js +79 -0
  70. package/dist/shop/price.d.ts +23 -0
  71. package/dist/shop/price.js +43 -0
  72. package/dist/shop/scan.d.ts +24 -0
  73. package/dist/shop/scan.js +80 -0
  74. package/dist/shop/security.d.ts +10 -0
  75. package/dist/shop/security.js +56 -0
  76. package/dist/shop/store.d.ts +101 -0
  77. package/dist/shop/store.js +4 -0
  78. package/dist/shop/worker.d.ts +43 -0
  79. package/dist/shop/worker.js +76 -0
  80. package/package.json +21 -6
  81. package/src/admin/panel/mount.ts +7 -2
  82. package/src/player-signin/adapters/d1.sql +45 -0
  83. package/src/player-signin/adapters/d1.ts +120 -0
  84. package/src/player-signin/adapters/firestore.ts +194 -0
  85. package/src/player-signin/conformance.ts +184 -0
  86. package/src/player-signin/handler.ts +404 -0
  87. package/src/player-signin/host.ts +12 -0
  88. package/src/player-signin/index.ts +20 -0
  89. package/src/player-signin/memory.ts +94 -0
  90. package/src/player-signin/panel/index.ts +13 -0
  91. package/src/player-signin/panel/memo.ts +49 -0
  92. package/src/player-signin/panel/mount.ts +129 -0
  93. package/src/player-signin/panel/style.css +478 -0
  94. package/src/player-signin/panel/view.ts +433 -0
  95. package/src/player-signin/panel/words.ts +210 -0
  96. package/src/player-signin/scheme-check.ts +14 -0
  97. package/src/player-signin/scheme.ts +32 -0
  98. package/src/player-signin/types.ts +103 -0
  99. package/src/shop/adapters/d1.sql +52 -0
  100. package/src/shop/adapters/d1.ts +245 -0
  101. package/src/shop/adapters/firestore.ts +281 -0
  102. package/src/shop/address.ts +102 -0
  103. package/src/shop/amount.ts +66 -0
  104. package/src/shop/conformance.ts +232 -0
  105. package/src/shop/handler.ts +202 -0
  106. package/src/shop/index.ts +30 -0
  107. package/src/shop/match.ts +63 -0
  108. package/src/shop/memory.ts +102 -0
  109. package/src/shop/nodes.ts +115 -0
  110. package/src/shop/orders.ts +93 -0
  111. package/src/shop/panel/index.ts +19 -0
  112. package/src/shop/panel/looks.ts +106 -0
  113. package/src/shop/panel/mount.ts +280 -0
  114. package/src/shop/panel/pay.ts +192 -0
  115. package/src/shop/panel/qr.ts +256 -0
  116. package/src/shop/panel/style.css +538 -0
  117. package/src/shop/panel/words.ts +172 -0
  118. package/src/shop/price.ts +52 -0
  119. package/src/shop/scan.ts +90 -0
  120. package/src/shop/security.ts +50 -0
  121. package/src/shop/store.ts +99 -0
  122. package/src/shop/worker.ts +107 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,97 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.3 (2026-11-01)
4
+
5
+ - **A session that ended at Buy.** A 401 from the server at Buy (the session ended, though the game's `signedIn()` still said yes) showed a
6
+ "Sign in" key that asked `signedIn()` again, got yes, and posted the same refused order again. The key now calls `signIn()` without asking, and the
7
+ new `onSessionEnd()` hook on `mountShop` lets the game mark the player signed out (the Echo Island worker found it).
8
+
9
+ ## 0.9.2 (2026-11-01)
10
+
11
+ - Not published on npm: its changes reached npm in 0.9.3.
12
+ - **`onSignInCancel(lookId, look)` on `mountShop`** (the Echo Island worker found the gap while it moved Echo's shop to `/shop`). A game with its own
13
+ looks sheet calls `shop.buy(lookId)`; when the player closes the sign-in without signing in, the kit now opens nothing (and closes its sheet if it
14
+ was open) and calls the hook, so the game shows its own sheet again. Before, the kit's own looks list opened. A Buy in the kit's own list still
15
+ goes back to that list and does not call the hook.
16
+ - **`buy(lookId)` starts on the next frame.** A game's own `<dialog>` that closed just before has had its `close` event by then (Chromium fires it at
17
+ the frame), so the game's close handler can no longer take the focus and the Escape key from the kit's sheet. `dialog.close(); shop.buy(id)` is safe.
18
+ `shop.isOpen` is false until that frame; `open()` still opens at once. The README says to close the game's dialog first.
19
+ - **Phone-first sizes by default** in the shop's sheets and the sign-in sheet: every text 16 px or more (was 11 to 15 px: the tabs, the Looks key,
20
+ each look's line, the notes, the price-source line, the time left, the address and labels by hand, the copy keys, the wallet tiles' names, "Or a
21
+ wallet app", the back key, the status, "Last used"), and every key, tab, close key, back key, fold summary, copy key, wallet tile and link 48 px or
22
+ more each way (was 44 px; the inline "open a wallet" link was 17 px tall). "Last used" sits in the flow now (under a wallet's name, at the end of
23
+ the passkey's row) so it never covers a word. A game that wants smaller text or keys sets them in its own stylesheet, after the kit's.
24
+
25
+ ## 0.9.1 (2026-11-01)
26
+
27
+ - Not published on npm: its changes reached npm in 0.9.3.
28
+ - **The words of the shop's sheets and of the player's sign-in sheet are the game's to set** (the coordinator asked for it: Echo Island's move to
29
+ `/shop` waited on it; Wes prefers fewer words). `words` on `mountShop`, `looksView` and `payPanel` (`ShopWords`, defaults `SHOP_WORDS`,
30
+ `shopWords()` merges) and on `mountPlayerSignin` and `signInView` (`PlayerSigninWords`, defaults `PLAYER_SIGNIN_WORDS`, `PlayerSigninWordsOption`
31
+ for a game's partial words and faults, `playerSigninWords()` merges). Every visible word, every key and every name for a screen reader; a text
32
+ with a value is a function (`buyTitle(look)`, `endsIn(time)`, `prices(sources)`, `waitingFor(wallet)`). `refusal(text, status)` turns the
33
+ server's refusals into the game's words. `faultWords(code, rpId, words?)` takes the words. Not the game's to change: the stamp "TEST MODE: DO
34
+ NOT PAY", and "Powered by CoinGecko" while CoinGecko is a price feed (`priceLine` adds it back).
35
+ - **Shorter default words.** The pay sheet: 30 words in TEST MODE (was about 85) and 29 live (was about 47), no sentence over 10 words. Live:
36
+ "Send exactly 0.25 XNO", "Ends in 14:59.", "Waiting for payment…", "Price: Kraken, KuCoin. Powered by CoinGecko."; by hand: "Another amount
37
+ does not match." TEST MODE: the stamp, "Nothing can be paid here.", the wallet key "Wallet off: test mode", no timer and no status (nothing
38
+ waits for a payment). Paid late: "Paid after the order ended, so no look. The creator sends it back." The looks sheet: "Looks never change
39
+ play. Pay in Nano, straight to the creator." and "Sign in first to buy a look."; "Back" (was "Back to looks"). The sign-in sheet: the lines
40
+ over 10 words split or cut, with the same meaning (the spare key's note, the password manager's and the "two answers" faults, the QR lines).
41
+ - `test/panel-words.test.ts` counts the words a player sees (happy-dom) and proves a game's words reach the page.
42
+
43
+ ## 0.9.0 (2026-11-01)
44
+
45
+ - **The player's sign-in** (`@nano133/nanoterra/player-signin`, `/player-signin/conformance`, `/player-signin/d1` with `/player-signin/d1.sql`,
46
+ `/player-signin/firestore`, `/player-signin/panel` and `/player-signin/style.css`). Wes: "start the player passkey sign-in too" (2026-11-01); the kit's change
47
+ list asked for "a passkey sign-in for players" (a prompt-pack run found that players without a wallet app cannot buy).
48
+ - **A passkey** (the passkey purse of `@nano133/signin`: a Nano account made in the browser from the passkey's PRF output, by the game's own FROZEN scheme)
49
+ and the three **wallet apps** (Berry Wallet, NanChat, Nautilus), in one path. The server checks signatures only and holds no key; a player is a Nano
50
+ address whichever way they sign in. `@nano133/signin` (`^0.5.0`) is a new **optional** peer, needed by this path only.
51
+ - **The server** (`createPlayerSignin({ store, ... })`): the game starter's routes and answers (`/api/signin/start`, `/nano/:id`, `/nautilus`, `/nanchat`,
52
+ `/check`, `/api/signout`) plus `POST /api/signin/passkey`; the starter's session cookie (`session`, `__Host-session` on https, 30 days, the token's SHA-256
53
+ kept); `player(request)` for the shop and the friends list, `session`, `signIn` (the game's own code exchange), `signOut`, `forget` ("Delete my data") and
54
+ `clean`. `sharedPaths` leaves the admin's calls to the game. `schemeVector` and `checkScheme` for the scheme's fixed vectors.
55
+ - **The stores:** memory, D1 (the starter's own `signin_requests` and `sessions`, plus `signin_once`: a starter's players stay signed in; admin rows are never
56
+ touched) and Firestore (a small interface, no Firebase dependency), with `playerSigninStoreConformance`.
57
+ - **The sheet** (`mountPlayerSignin`, `signInView`): passkey first, then the wallet apps; no passkey on a preview address (`*.workers.dev`, `previewHost`;
58
+ `passkey: true` offers it there), where a passkey would be lost when the game moves to its own domain; "Make a passkey" and "Use my passkey", "Keep a spare key" after a new
59
+ passkey, the faults in plain words (no PRF and the rest), QR or one key per wallet, phone first, light and dark. The purse's signing code loads only at a
60
+ passkey sign-in.
61
+ - **The admin** (`mountAdmin`, `playerPasskeys: true`): on `*.workers.dev` its "Check these" banner says "Passkeys turn on when your game has its own domain."
62
+ - **A shop for a game's looks** (`@nano133/nanoterra/shop`, `/shop/d1`, `/shop/d1.sql`, `/shop/firestore`, `/shop/worker`, `/shop/conformance`,
63
+ `/shop/panel` and `/shop/style.css`). Wes: "start the shop kit path" (2026-11-01). A game installs the game starter's shop instead of copying its
64
+ 2,600 lines. The starter's money model, unchanged: the money goes straight from the player's wallet to the creator's address, the server holds
65
+ no key and only reads the ledger, and each order asks for a unique, clean amount (at most 4 decimals of XNO) that a payment must match exactly.
66
+ - **The rules** (no DOM, no database): the starter's amounts (raw text and BigInt, prices rounded up), the reservation (one step more while
67
+ another held order has the amount, at most a tenth more; an amount held until an hour after the order ends), the match (a confirmed send of
68
+ exactly the amount to exactly the address, first seen after the order; late is "paid late", no look), the price (the median of three feeds,
69
+ kept 5 minutes, the last good one for 6 hours), the nodes with failover (`nodesFor`: Nanswap first with a key, then the game's own list or
70
+ the public nodes), and the payment scan (`scanPayments`: one scan for the whole game every 4 s, fails closed). The creator's address is
71
+ checked with its checksum (`validAddress`, the package's own 5-byte BLAKE2b): a typo keeps test mode on.
72
+ - **The routes** (`createShopHandler`): the starter's paths and JSON (`POST /api/orders`, `GET /api/orders/{id}`, `POST /api/orders/{id}/check`)
73
+ and its words for each refusal, the game's player through `player(request)`, the same-origin check on POST, a limit of new orders and the person
74
+ check as hooks. **Test mode** until the owner's own address is set AND test mode is off: no address and no link in the answer.
75
+ - **The stores**, each keeping the rules in one step: memory, D1 on the starter's own tables (`createD1Shop`; `d1.sql` with `IF NOT EXISTS`: a
76
+ game from the starter switches with no migration and keeps its orders and owned looks; `d1Limit` and `visitorKey` on the same rows), and
77
+ Firestore (`createFirestoreShop`: the admin's orders, owned and meta, plus a document for each held amount and each recorded payment, in
78
+ transactions). `forget(player)` for "Delete my data": waits while a payment may still come; deletes the owned looks and the unpaid orders;
79
+ keeps a paid order as the sales record under "deleted", and a late payment's payer (the creator sends it back by hand).
80
+ - **The conformance suite** (`shopStoreConformance`, 17 short cases with `SHOP_CASE_TIMEOUT_MS`): one owner per order, a paid order never paid
81
+ twice, one payment for one order, an expired order past its hold never paid, unique amounts among held orders (25 at once), busy, 39-digit
82
+ amounts, the scan's claim, the price, and `forget`.
83
+ - **The Worker** (`createShop(env, { catalogue, player })`): the starter's settings (`RECEIVE_ADDRESS`, `TEST_MODE`, `NODE_API_KEY`, `NANO_NODES`,
84
+ `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET`), Turnstile's check, the limit in D1, `scan()` for the cron, `config()` for the page and `status()`
85
+ (with the starter's warnings) for the admin.
86
+ - **The panel** (`mountShop`): the Looks key (or `key: false` and `open()`), the looks sheet with a tab for each wear slot, Buy through the
87
+ game's `signIn()` (the shop draws no sign-in), the pay sheet with the package's own QR code (SVG, byte mode, level M; the same modules as
88
+ the starter's qrcode-generator), the starter's "TEST MODE: DO NOT PAY" stamp with the wallet key off, a check every 5 s for 2 minutes, and
89
+ `onOwned(lookId)`. Phone first; on a phone held sideways the QR code stands alone on the left and the wallet key stays in view. Light and
90
+ dark with the `--n133-*` variables and their `--n133-dark-*` twins.
91
+ - **The CSP lists** (`SHOP_CSP`, `mergeCsp`): Turnstile's origin for `script-src` and `frame-src`, merged into the game's own policy.
92
+ - Ideas from Echo Island's shop (a tab for each wear slot with its plain choice first; the wallet key in a foot that stays in view) and from the
93
+ prompt pack's runs (the shop's tests read a fixture, not a game's looks; the conformance cases are short and each its own test).
94
+
3
95
  ## 0.8.1 (2026-11-01)
4
96
 
5
97
  - **A closed panel gives the keys back to the game.** The options panel, the friends panel and the emote wheel put the focus back on their key when they
package/README.md CHANGED
@@ -12,10 +12,12 @@ themselves. This package gives your game these things:
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`, since 0.6.0): 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
14
  - **An options menu** (`/options`, since 0.8.0): 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
+ - **The player's sign-in** (`/player-signin`, since 0.9.0): a passkey (Face ID, Touch ID or the device PIN) or a wallet app (Berry Wallet, NanChat, Nautilus), the sessions and a ready sign-in sheet, for a game with a server. It needs `@nano133/signin` (an optional peer).
15
16
  - **Emotes** (`/emotes`, since 0.8.0): 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.
17
+ - **A shop** (`/shop`, since 0.9.0): sells looks for Nano (XNO) straight to the creator's address, with no key on the server: the routes, the payment check, D1 and Firestore stores, and the Looks and pay sheets (QR code, test mode stamp).
16
18
 
17
19
  It replaces `@nano133/character` 0.1.0, `@nano133/ticket` 0.1.0 and `@nano133/looks` 0.1.0
18
- (see "Moving from the three old packages"). Sign-in, checkout and late returns stay in their own packages
20
+ (see "Moving from the three old packages"). The signature checks, checkout and late returns stay in their own packages
19
21
  ([`@nano133/signin`](../signin), [`@nano133/checkout`](../checkout), [`@nano133/late-returns`](../late-returns)).
20
22
 
21
23
  nano133.com's island and its 3D room draw the player with **this same code**: the geometry, the skeleton, the colours and the record
@@ -29,7 +31,8 @@ Pre-alpha means: the shape of the API may still change, and the character has no
29
31
  npm install @nano133/nanoterra three
30
32
  ```
31
33
 
32
- `three` is an **optional** peer dependency (`>=0.174.0`). A game that only reads a ticket does not need it.
34
+ `three` is an **optional** peer dependency (`>=0.174.0`). A game that only reads a ticket does not need it. `@nano133/signin` is the other optional peer:
35
+ only the player's sign-in (`/player-signin`) needs it (`npm install @nano133/nanoterra @nano133/signin`).
33
36
 
34
37
  ## Quick start
35
38
 
@@ -80,8 +83,18 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
80
83
  | `@nano133/nanoterra/emotes` | the emotes' list, the wheel's eight places, the multiplayer message and the player's options: no DOM, no Three.js (since 0.8.0) | no | 1.4 kB |
81
84
  | `@nano133/nanoterra/emotes/player` | the emotes on the kit's character: `createEmotes`, `playRemote`, `loadEmotes` (since 0.8.0) | yes | 2.9 kB; the clips load on the first emote, a chunk of their own: 84 kB, 18.8 kB gzip |
82
85
  | `@nano133/nanoterra/emotes/wheel` | the Emotes key and the emote wheel: `mountEmotes`, and its stylesheet `@nano133/nanoterra/emotes/style.css` (since 0.8.0) | no | 5.9 kB + 7.4 kB of CSS (minified); the pictures (`/emotes/icons.webp`, 34 kB) load when the wheel first opens |
83
-
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.
86
+ | `@nano133/nanoterra/player-signin` | the player's sign-in: the handler, the sessions, `player(request)`, `forget`, a memory store, `schemeVector` (server code; it needs `@nano133/signin`; since 0.9.0) | no | 5.1 kB without `@nano133/signin` |
87
+ | `@nano133/nanoterra/player-signin/d1`, `/player-signin/firestore` | the store for D1 (with the migration `/player-signin/d1.sql`) or Firestore (a small interface, no Firebase dependency) (since 0.9.0) | no | 1.2 kB, 1.3 kB |
88
+ | `@nano133/nanoterra/player-signin/panel` | the sign-in sheet, and its stylesheet `@nano133/nanoterra/player-signin/style.css` (since 0.9.0) | no | 8.7 kB with `@nano133/signin/passkey` + 8.1 kB of CSS (minified); the purse's signing code loads at a passkey sign-in, a chunk of its own: 39.8 kB gzip |
89
+ | `@nano133/nanoterra/player-signin/conformance` | the store's rules as test cases, for a store a game writes itself (since 0.9.0) | no | n/a |
90
+
91
+ | `@nano133/nanoterra/shop` | the shop's rules and routes, the payment scan, a memory store, the CSP lists (server code; since 0.9.0) | no | 9.1 kB (all), 7.1 kB (`createShopHandler` alone) |
92
+ | `@nano133/nanoterra/shop/d1`, `/shop/firestore` | the shop's store on D1 (the starter's tables, `/shop/d1.sql`) or Firestore: `createD1Shop`, `createFirestoreShop` (since 0.9.0) | no | 2.4 kB, 2.2 kB |
93
+ | `@nano133/nanoterra/shop/worker` | the shop for a Worker with D1, from the starter's settings: `createShop` (since 0.9.0) | no | 10.0 kB (with the D1 store and Turnstile) |
94
+ | `@nano133/nanoterra/shop/panel` | the Looks key, the looks sheet and the pay sheet with its own QR code: `mountShop`, and its stylesheet `@nano133/nanoterra/shop/style.css` (since 0.9.0) | no | 10.0 kB + 2.5 kB of CSS (gzip; 8.5 kB minified) |
95
+ | `@nano133/nanoterra/shop/conformance` | the shop's money rules as test cases, for a store a game writes itself (since 0.9.0) | no | n/a |
96
+
97
+ Measured 2026-10-26 (the friends paths 2026-10-30, the options, the emotes, the player sign-in and the shop paths 2026-11-01): `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
85
98
  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.
86
99
 
87
100
  ## The character
@@ -633,7 +646,8 @@ look, with their looks. **Settings**: the **Game** card (the character and sign-
633
646
  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.
634
647
  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
635
648
  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`,
636
- `loginsNote` and `fetch`.
649
+ `loginsNote`, `playerPasskeys` (the game offers its players a passkey: on `*.workers.dev` the banner says that passkeys turn on with the game's own
650
+ domain) and `fetch`.
637
651
 
638
652
  **The Shelf** shows the looks your game sells now in a grid of cards: a big picture on a soft tint of the look's colour, the price with its step on the ladder ("25¢ ·
639
653
  statement"), what it has sold and its share of the sales, its slots as chips; filters (All, For sale, Free, Not sold yet) and a sort (Best selling, price, name, the
@@ -1014,6 +1028,376 @@ or `store.add(EMOTE_OPTIONS)`; `EMOTE_OPTION_DEFS` holds the two toggles with sh
1014
1028
  `{ 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
1015
1029
  does not know an id drops it, and nothing breaks.
1016
1030
 
1031
+ ## The player's sign-in (`/player-signin`; since 0.9.0)
1032
+
1033
+ The PLAYER's sign-in for a game with a server, in one path: **a passkey** (Face ID, Touch ID or the device PIN: no app to install) and the three
1034
+ **wallet apps** (Berry Wallet, NanChat, Nautilus). No password, no email and no payment, and **the server holds no key**: every way is a signature from a
1035
+ key the player holds, and the server checks it with [`@nano133/signin`](../signin), an optional peer of this package:
1036
+
1037
+ ```sh
1038
+ npm install @nano133/nanoterra @nano133/signin
1039
+ ```
1040
+
1041
+ A player is a **Nano address**, whichever way they sign in. The shop (`/shop`) and the friends list key a player by it, so a look a player buys stays with
1042
+ that address. The admin's sign-in is another thing (`/admin`): this path never makes an admin login.
1043
+
1044
+ ### The passkey: the passkey purse
1045
+
1046
+ The passkey gives the page 32 secret bytes (its **PRF** extension), and those bytes become the seed of a Nano account in the browser: the player's
1047
+ address. The browser signs a short text that names the game's host with that account, and the server checks the signature (`POST {base}/passkey`). The
1048
+ server keeps no credential and checks no assertion: the proof is the signature, as for a wallet. The seed never leaves the page and is never stored.
1049
+
1050
+ - **The scheme is the game's own, and it is FROZEN.** A player's account (and so their looks) lies at the address these exact texts give:
1051
+
1052
+ ```ts
1053
+ // FROZEN: never edit. A new scheme is a new versionByte with new texts.
1054
+ export const SCHEME = { input: "mygame.com player v1", salt: "mygame.com", info: "mygame.com player seed v1", versionByte: 1 };
1055
+ ```
1056
+
1057
+ Write it once, in one file, and test it with **fixed vectors** that must never change (a PRF output, its seed, its address):
1058
+
1059
+ ```ts
1060
+ import { schemeVector } from "@nano133/nanoterra/player-signin";
1061
+ assert.deepEqual(await schemeVector(SCHEME, "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"), { seed: "…", address: "nano_…" });
1062
+ ```
1063
+
1064
+ Run it once to read the seed and the address, write them into the test, and never change them. The kit gives no default scheme on purpose.
1065
+ - **A passkey belongs to one domain** (its relying party id: the page's host, or `rpId`). Moving the game to another domain gives every player another
1066
+ account. Choose the domain before the first player. So the sheet offers **no passkey on a preview address** (`*.workers.dev`, `previewHost`), where the game
1067
+ starter runs first: the wallet apps only, until the game has its own domain (`passkey: true` offers it there anyway). The admin says so in its "Check these"
1068
+ banner when `mountAdmin` gets `playerPasskeys: true`: "Passkeys turn on when your game has its own domain."
1069
+ - **It needs PRF** in the browser and in the passkey's provider. Where the browser says it has none, the passkey shows last ("Not on this browser"); where a
1070
+ passkey gives no PRF answer, the sheet says so in plain words and offers the wallet apps. As known in 2026-11, not tested here on each device (the sheet
1071
+ asks the browser, `getClientCapabilities`, and never guesses):
1072
+ Safari on iOS 18 and macOS 15 and later with iCloud Keychain, Chrome on Android with Google Password Manager, Chrome on a computer with Google Password
1073
+ Manager or iCloud Keychain (macOS 15), 1Password and recent Bitwarden: yes. Windows Hello and older systems: often no. Firefox: from 135, where the system's
1074
+ provider has it. A phone used through the computer's QR code (hybrid) gives its own PRF.
1075
+ - **The spare key.** The player's looks belong to the passkey's address. A synced passkey (iCloud Keychain, Google Password Manager) survives a lost phone;
1076
+ a lost passkey with no sync loses the address and its looks (no XNO is in it: the payments go straight to the creator). So after a NEW passkey the sheet
1077
+ shows **"Keep a spare key"**: the seed, at the press only, and how to use it (import it as a seed in a Nano wallet such as Nautilus, then sign in with that
1078
+ wallet: the same address). It can be skipped, not missed. `spareKey: false` turns it off (a game for small children, say). Signing in with the wallet that
1079
+ PAID does not bring the looks back: a look belongs to the address that was signed in when it was bought. Linking a wallet to a passkey account (one
1080
+ player, two ways in) is planned for a later version.
1081
+
1082
+ ### The server
1083
+
1084
+ ```ts
1085
+ import { createPlayerSignin } from "@nano133/nanoterra/player-signin";
1086
+ import { createD1PlayerSignin } from "@nano133/nanoterra/player-signin/d1";
1087
+
1088
+ const signin = createPlayerSignin({
1089
+ store: createD1PlayerSignin(env.DB), // or createFirestorePlayerSignin(getFirestore()), or createMemoryPlayerSignin() for a test
1090
+ siteAddress: env.RECEIVE_ADDRESS, // Nautilus only NAMES it in its request; no key, no payment
1091
+ limit: (key, n, ms, now) => d1Limit(key, n, ms, now), // your limiter (default: this copy's memory)
1092
+ visitor: (req) => visitorKey(req), // a keyed hash of the IP address (default: the CF-Connecting-IP header)
1093
+ personCheck: (token, req) => turnstileOk(token, req), // optional (Turnstile)
1094
+ });
1095
+ const answer = await signin.handle(request); // null when the request is not a sign-in route
1096
+ if (answer) return answer;
1097
+ const player = await signin.player(request); // the signed-in player's address, or null: the shop's and the friends' `player` hook
1098
+ ```
1099
+
1100
+ | Route (`base` "/api/signin") | Who calls it | What it does |
1101
+ |---|---|---|
1102
+ | `POST {base}/start` `{ way, turnstile? }` | the page | starts a wallet request: `{ way, id, link, walletLink?, code?, expiresAt }` and the starter cookie (30 minutes) |
1103
+ | `GET`, `POST {base}/nano/:id` | Berry Wallet | reads the request; posts its signature (204 and no body when accepted, 401 and the words when refused) |
1104
+ | `POST {base}/nautilus?id=` | Nautilus | its signature (always 200: Nautilus shows only a 200's words) |
1105
+ | `POST {base}/nanchat` | NanChat's relay server | its signature (always 200; a refusal starts "NOT signed in"); limited in total and per nonce, never per visitor |
1106
+ | `POST {base}/check` `{ id, way }` | the page, every 2 s | `{ status }`: `waiting`, `expired`, `elsewhere` (another browser started it), `used`, or `signedin` with `address` and the session cookie |
1107
+ | `POST {base}/passkey` `{ address, at, signature, turnstile? }` | the page | the passkey purse's signature: `{ status: "signedin", address }` and the session cookie |
1108
+ | `POST /api/signout` | the page | ends this browser's session: `{ ok: true }` |
1109
+
1110
+ These are the game starter's own routes and answers, so its page works unchanged. The rules:
1111
+
1112
+ - The signed texts name the game's **host** (the request's, or `domain`: with `domain` set, a call to another host is refused, 403). A signature made for
1113
+ another site counts nowhere here.
1114
+ - **Only the browser that started a wallet request is signed in by it** (its starter cookie), **once**: the delivery and its session are one step of the store.
1115
+ - A passkey signature counts **once** (by the hash of its text) and only within 5 minutes either way of its time: a replay is 409, a stale one 400.
1116
+ - The page's calls must come from the game's own page (Origin), 403 otherwise; the wallets' calls have no cookie and no Origin. Starts and passkey sign-ins:
1117
+ 20 in 10 minutes per visitor (`limit`); checks 90 a minute; the wallets' calls 30 a minute per IP address. A store's fault is a plain 500 (`onError` hears it).
1118
+ - **The session** is the starter's: the cookie `session` (`__Host-session` on https; HttpOnly, SameSite=Lax, 30 days). The store keeps only the SHA-256 of
1119
+ its token and `via`: `"wallet"`, `"passkey"` or `"nanoterra"`.
1120
+ - `signin.session(request)` gives `{ address, createdAt, via }`; `signin.signIn(request, address, "nanoterra")` makes a session for the game's own code
1121
+ exchange and returns the Set-Cookie value; `signin.signOut(request)` likewise; `signin.forget(player)` is for **"Delete my data"** (every session, signed
1122
+ request and once-only row of the player); `signin.clean()` is for a cron.
1123
+ - **`sharedPaths: true`** for a game whose own admin signs in on the same paths (the starter's `scope: "admin"`): the admin's calls, and a wallet's call for a
1124
+ request this store does not hold, answer null, and the game's admin code answers them.
1125
+ - `ways` (default all three), `passkey: false`, `base`, `signOutPath`, `cookies`, `sessionDays` change the rest.
1126
+
1127
+ **The stores** read and write only their three tables or collections, and only the player's rows:
1128
+
1129
+ - **D1:** add `@nano133/nanoterra/player-signin/d1.sql` to the migrations. Its `signin_requests` and `sessions` are the game starter's own tables (`CREATE
1130
+ TABLE IF NOT EXISTS`): on the starter it adds only `signin_once` and three indexes, and **every player stays signed in**. Rows of the scope `'admin'` are
1131
+ never read, delivered, ended or deleted. Other table names: `createD1PlayerSignin(db, { requests, sessions, once })`, and the same names in the SQL.
1132
+ - **Firestore:** `createFirestorePlayerSignin(db, { requests, sessions, once })` (default `signin_requests`, `sessions`, `signin_once`). Each document
1133
+ carries `deleteAt`: set a TTL policy on it for each collection. Firestore's own single-field indexes are enough.
1134
+ - **Your own database:** a `PlayerSigninStore` that passes `playerSigninStoreConformance` from `@nano133/nanoterra/player-signin/conformance`.
1135
+
1136
+ ### The sheet
1137
+
1138
+ ```ts
1139
+ import { mountPlayerSignin } from "@nano133/nanoterra/player-signin/panel";
1140
+ import "@nano133/nanoterra/player-signin/style.css";
1141
+ import { SCHEME } from "./scheme"; // the frozen scheme above
1142
+
1143
+ const signin = mountPlayerSignin(document.body, {
1144
+ name: "My Game", // the device shows it next to the passkey: "My Game · 1 Nov 2026"
1145
+ scheme: SCHEME,
1146
+ qr: (text) => qrCanvas(text), // a QR code for a wallet's link on a computer (no hook: the link as text)
1147
+ personCheck: (box) => turnstileToken(box), // optional
1148
+ onSignedIn: (player) => refresh(), // { address, via: "wallet" | "passkey" }
1149
+ });
1150
+ const player = await signin.open(); // the shop's "sign in first": the player, or null when the sheet closed
1151
+ ```
1152
+
1153
+ - **Passkey first** ("Use a passkey", "Face ID, Touch ID or your device PIN"), then the three wallet apps as tiles. The passkey step has "Use my passkey" and
1154
+ "Make a passkey"; the main key is "Use" in a browser that made or used one before, and the device refuses a second passkey here ("This device has your
1155
+ passkey already. Use it."). A new passkey is used at once (its seed comes from a USE, never from the answer at create); a browser that allows one prompt
1156
+ per press shows one key, "Open my passkey".
1157
+ - The faults, in plain words: no passkey used, no PRF, no passkeys in this browser, a password manager's answer in a form the kit does not read, another
1158
+ game's passkey, another passkey than the one just made, two answers for one passkey, the wrong host. None names a seed or a key; none says to delete a passkey.
1159
+ - A wallet: on a touch screen one key that opens the app; with a mouse its QR code. The page asks every 2 s, at most 5 minutes.
1160
+ - `signInView(options)` is the same content for a game with a sheet of its own (`{ node, stop, reset }`).
1161
+ - Phone first (since 0.9.3 by default): every text 16 px or more, and every key, wallet tile, link, the back key and the close key 48 px or more each way
1162
+ ("Last used" sits in the flow, under a wallet's name or at the end of the passkey's row). The sheet comes from the bottom on a phone held upright, and is
1163
+ a card in the middle on a wide or sideways screen; Escape, the close key and the scrim close it; no browser dialog. Light and dark like the options panel (`data-n133-theme` on `<html>`, else the device), every colour a
1164
+ `--n133-*` variable with a `--n133-dark-*` twin; `--n133-signin-z` (default 1200) for its z-index.
1165
+ - The page loads the purse's signing code (`@nano133/signin/purse`, 40 kB gzip) only when a passkey signs in.
1166
+ - The browser remembers, in `localStorage["n133.player-signin.v1:<rpId>"]`, the ids of its passkeys, which were made here and never opened, and the last
1167
+ way: no secret and no address.
1168
+
1169
+ **Its words** (since 0.9.3). `words` on `mountPlayerSignin` and `signInView` changes any word of the sheet, for the game's own words or language: the keys
1170
+ of `PlayerSigninWords` (the defaults are `PLAYER_SIGNIN_WORDS`), any of them, over the defaults. A text with a value in it is a function of that value, and
1171
+ `faults` changes the words of any passkey fault (`faultWords(code, rpId, words)` gives them). The wallets' names stay. `refusal(text, status)` turns the
1172
+ server's refusals (in English) into the game's words.
1173
+
1174
+ ```ts
1175
+ mountPlayerSignin(document.body, {
1176
+ name: "My Game",
1177
+ scheme: SCHEME,
1178
+ words: { lead: "No password.", waitingFor: (wallet) => `Waiting for ${wallet}…`, faults: { cancelled: { title: "Stopped", text: "No passkey was used." } } },
1179
+ });
1180
+ ```
1181
+
1182
+ ## The shop (`/shop`; since 0.9.0)
1183
+
1184
+ A shop that sells **looks** for Nano (XNO), the way the [game starter](https://github.com/nano133-com/nano-game-starter) sells them. A game
1185
+ installs it instead of copying the starter's shop.
1186
+
1187
+ **The money model.** The money goes **straight from the player's wallet to the creator's address**. The server holds **no key** and sends
1188
+ nothing: it only reads the Nano ledger to see that a payment came. Each order asks for a unique, clean amount (the price rounded up to
1189
+ 0.0001 XNO, one step more while another order holds that amount), and a payment counts only when it is a confirmed send of exactly that
1190
+ amount to exactly that address, first seen after the order was made. (This is not `@nano133/checkout`'s address for each checkout: that
1191
+ one puts a key on the server.)
1192
+
1193
+ | Path | What |
1194
+ |---|---|
1195
+ | `@nano133/nanoterra/shop` | the rules (amounts in raw text and BigInt, the reservation, the match, the price, the nodes, the address checksum), the routes (`createShopHandler`), the payment scan (`scanPayments`), a memory store, the CSP lists. No DOM, no database, no package. |
1196
+ | `@nano133/nanoterra/shop/d1`, `/shop/d1.sql` | the store on Cloudflare D1, on the starter's own tables (`createD1Shop`), and `d1Limit` and `visitorKey` |
1197
+ | `@nano133/nanoterra/shop/firestore` | the store on Firestore (a small interface, no Firebase dependency): `createFirestoreShop` |
1198
+ | `@nano133/nanoterra/shop/worker` | the shop for a Worker with D1, set up from the starter's settings: `createShop(env, { catalogue, player })` |
1199
+ | `@nano133/nanoterra/shop/panel`, `/shop/style.css` | the Looks key, the looks sheet and the pay sheet (QR code, test stamp), in plain DOM: `mountShop` |
1200
+ | `@nano133/nanoterra/shop/conformance` | the money rules as test cases, for a store a game writes itself |
1201
+
1202
+ ### On a Worker with D1 (the starter's way)
1203
+
1204
+ ```ts
1205
+ import { createShop } from "@nano133/nanoterra/shop/worker";
1206
+ import looks from "../../looks.json";
1207
+
1208
+ // In the Worker's fetch, after the game's own sign-in has read the PLAYER session:
1209
+ const shop = createShop(env, { catalogue: looks, player: async () => session?.address ?? null });
1210
+ const answer = await shop.handle(request); // POST /api/orders, GET /api/orders/{id}, POST /api/orders/{id}/check; null for any other path
1211
+ if (answer) return answer;
1212
+
1213
+ // The cron (wrangler.jsonc "triggers": { "crons": ["*/10 * * * *"] }): finds a payment of a player who closed the page.
1214
+ async scheduled(_event, env, ctx) { ctx.waitUntil(createShop(env, opts).scan()); }
1215
+ ```
1216
+
1217
+ `createShop` reads the starter's settings: `RECEIVE_ADDRESS` (the creator's address), `TEST_MODE`, `NODE_API_KEY` (a secret, optional),
1218
+ `NANO_NODES` (optional), `TURNSTILE_SITE_KEY` and the secret `TURNSTILE_SECRET` (both, or no person check). `"none"`, `"skip"`, `"off"` and
1219
+ `"-"` mean "not set". It also gives `shop.owned(player)` (for the game's `/api/me`), `shop.config()` (`{ testMode, turnstileSiteKey }`, for
1220
+ the game's `/api/config`), `shop.status()` (the address, test mode, the nodes and the warnings, for the admin's Status card) and `shop.scan()`
1221
+ (the admin's "Check payments now": `createAdminHandler({ ..., check: (at) => shop.scan(at) })`).
1222
+
1223
+ On any other server, use the handler and a store:
1224
+
1225
+ ```ts
1226
+ import { createShopHandler } from "@nano133/nanoterra/shop";
1227
+ import { createFirestoreShop } from "@nano133/nanoterra/shop/firestore";
1228
+
1229
+ const shop = createShopHandler({
1230
+ store: createFirestoreShop(getFirestore()), // or createD1Shop(env.DB), or createMemoryShop() for a test
1231
+ catalogue: looks, // looks.json as it was read: checked with checkLooks (a bad file throws here)
1232
+ payee: process.env.RECEIVE_ADDRESS, // the creator's address: payments go straight there
1233
+ testMode: process.env.TEST_MODE === "on",
1234
+ player: async (req) => signedInPlayerId(req), // YOUR player's sign-in (never the admin's): an id, or null
1235
+ nodes: nodesFor({ NODE_API_KEY: process.env.NODE_API_KEY }), // optional: the public nodes by default
1236
+ personCheck: async (token, req) => myTurnstileCheck(token, req), // optional
1237
+ });
1238
+ ```
1239
+
1240
+ ### The routes and the answers
1241
+
1242
+ The starter's paths and JSON, so the starter's page keeps working. `base` is `"/api/orders"`.
1243
+
1244
+ - `POST {base}` `{ lookId, turnstile? }`: a new order. 201 and the **pay view**; 200 and the same open order again (one waiting order per look).
1245
+ The browser sends a look's id and nothing else: **the server decides every price**.
1246
+ - `GET {base}/{id}`: the pay view of the player's own order. Another player's order looks like no order at all (404).
1247
+ - `POST {base}/{id}/check`: a payment scan first (at most one for the whole game every 4 s), then the pay view.
1248
+ - The pay view: `{ id, lookId, usd, amount, status, createdAt, expiresAt, xno, test, prices }`, and **only outside test mode** `address` and
1249
+ `uri` (`nano:<address>?amount=<raw>`, every digit). `status` is `"open"`, `"paid"`, `"expired"` or `"paid_late"`.
1250
+ - Refusals are `{ error }` with the starter's words: 401 "Sign in first.", 403 (a POST from another page, or a failed person check), 400
1251
+ (no such look, a free look, a body that is not readable), 409 "You already own this look.", 429 (10 new orders in 10 minutes for one
1252
+ visitor; three waiting orders for one player), 503 (no believable price, or every amount of the price held), a plain 500 for a fault
1253
+ (`onError` hears it; the player sees nothing of it).
1254
+
1255
+ ### The money rules (every store keeps them: `shopStoreConformance` proves it)
1256
+
1257
+ - An order holds its amount from its start to **an hour after** it ends (15 minutes): **no two held orders ever hold the same amount**, even
1258
+ when they are made at the same instant. A late payment is then still recognised, and never counts for a newer order.
1259
+ - Amounts are text and BigInt, never floats. Prices round **up** (the creator never gets less than the price). At most a tenth of the price
1260
+ more (and at most 100 amounts); then the player is asked to try again in a minute.
1261
+ - A payment is ONE step: the order becomes `"paid"` and the look goes to **the order's player** (once), or `"paid_late"` (sent more than 2
1262
+ minutes after the order ended: no look; the creator sends it back by hand from their wallet: the server has no key). The hash is recorded:
1263
+ **one payment pays one order, once**, and a paid order is never paid twice. An order whose hold has ended can never be paid.
1264
+ - The scan **fails closed**: when no node answers, nothing is credited and the next scan tries again.
1265
+ - The price is the median of CoinGecko, Kraken and KuCoin (no keys), kept 5 minutes; the last good one for up to 6 hours when every feed
1266
+ fails; then nothing is sold. The pay sheet says "Powered by CoinGecko" (CoinGecko's terms).
1267
+
1268
+ ### Test mode
1269
+
1270
+ **On until the owner's own address is set AND test mode is off.** With no payee, the example address
1271
+ (`nano_1111…hifc8npp`), an address with a wrong checksum (`validAddress` checks it) or `testMode: true`, the pay view carries **no address
1272
+ and no link**, and the pay sheet shows **"TEST MODE: DO NOT PAY"** where the QR code would be, with the wallet key off. The sheet decides it
1273
+ from the order, never from a setting of the page. A payment to an address that nobody holds the key for is lost for good.
1274
+
1275
+ ### The stores
1276
+
1277
+ - **D1** (`createD1Shop(env.DB)`): the starter's tables `orders`, `owned` and `meta` (the keys `rate`, `scan`; `salt` for `visitorKey`), column
1278
+ for column. `@nano133/nanoterra/shop/d1.sql` creates them with `IF NOT EXISTS` (and `hits` for `d1Limit`): a new game adds it to its
1279
+ migrations; **a game made from the starter needs no migration** (its `migrations/0001_init.sql` made them). The names can be changed
1280
+ (`{ tables: { orders, owned, meta } }`), but the admin's D1 store reads the starter's names. The rules are in the statements:
1281
+ `amount_lock` and `hash` are UNIQUE, a payment and its grant are one batch, the scan's claim is one upsert.
1282
+ - **Firestore** (`createFirestoreShop(db, { orders, owned, meta, amounts, hashes })`): `orders/{id}`, `owned/{player__lookId}` and `meta/rate`
1283
+ are what the admin's Firestore store reads; `shop_amounts/{amount}` holds each held amount and `shop_hashes/{hash}` each recorded payment, so a
1284
+ transaction can keep the two unique rules. The indexes Firestore makes by itself are enough.
1285
+ - **Your own database:** write a `ShopStore` and run the rules on it (each case is short; give each its own test, with `SHOP_CASE_TIMEOUT_MS`):
1286
+
1287
+ ```ts
1288
+ import { shopStoreConformance, SHOP_CASE_TIMEOUT_MS } from "@nano133/nanoterra/shop/conformance";
1289
+ for (const c of shopStoreConformance(async () => ({ store: newEmptyStore(), dump: () => allRowsAsText() }))) it(c.name, c.run, SHOP_CASE_TIMEOUT_MS);
1290
+ ```
1291
+
1292
+ **"Delete my data":** `store.forget(player, now)`. While an order of the player may still be paid (its amount is held) it deletes nothing and
1293
+ answers `waitUntil` (try again then). Else the owned looks and the orders that were never paid go; a paid order stays as the creator's sales
1294
+ record with the player (and its payer) replaced by `"deleted"`, and its payment's hash kept (so it never counts again); a **late** payment keeps
1295
+ its payer, because the creator must send it back.
1296
+
1297
+ ### The panel
1298
+
1299
+ ```ts
1300
+ import { mountShop } from "@nano133/nanoterra/shop/panel";
1301
+ import "@nano133/nanoterra/shop/style.css";
1302
+
1303
+ const shop = mountShop(document.getElementById("shop")!, {
1304
+ looks, // looks.json: the list (the server still decides every price)
1305
+ owned: () => me.owned, // the look ids the player owns (your /api/me: shop.owned(player))
1306
+ signedIn: () => Boolean(me.address),
1307
+ signIn: () => mySignIn(), // YOUR sign-in sheet: true when a player is signed in after it. The shop draws no sign-in.
1308
+ worn: (look) => wornIn(look.wear ?? "outfit") === look.id,
1309
+ wear: (look) => wearIt(look),
1310
+ takeOff: (slot) => takeOffIt(slot), // optional: each item tab starts with its plain choice
1311
+ onOwned: (lookId, look) => { me.owned.push(lookId); wearIt(look); }, // a paid order gave this look
1312
+ personCheck: (box) => turnstileToken(box), // optional: Turnstile drawn in `box`
1313
+ slots: { shovel: { tab: "Shovels", plain: "Plain shovel" } }, // optional words for the tabs
1314
+ });
1315
+ // key: false draws no Looks key: open it from your own key with shop.open(), or shop.buy("mint-cap") from your own Buy.
1316
+ ```
1317
+
1318
+ - The looks sheet: a tab for each wear slot (the outfits first) when the game sells items, each look with its price before any step; Wear,
1319
+ Wearing, or Buy. `picture(look)` gives the game's own picture instead of the swatch.
1320
+ - Buy: the game's sign-in (`signIn`) when nobody is signed in, then the person check, then the pay sheet: the amount, the QR code of the exact
1321
+ `nano:` link (drawn by the package as SVG: no QR library, no outside service), "Open wallet", the address and the amount by hand, the time
1322
+ left (live only), and a check every 5 seconds for 2 minutes ("Check again" after). Paid: "Paid. Thank you!" and `onOwned`. Paid late: no look,
1323
+ and the creator sends it back. Ended: a payment sent in time still counts.
1324
+ - Phone first (since 0.9.3 by default): every text 16 px or more (the price-source line, the time left, "Enter by hand"'s address and labels too), and
1325
+ every key, tab, the Looks key, the close key, the fold "Enter by hand" and its copy keys 48 px or more each way. On a phone held upright the sheet
1326
+ rises from the bottom; on a wide screen it stands in the middle; on a phone held sideways the QR code (or the stamp) stands alone on the left and
1327
+ the wallet key stays in view. A game that wants smaller text sets it in its own stylesheet, after the kit's.
1328
+ - Light and dark: `theme: "light" | "dark" | "device"` (the default: the page's `data-n133-theme`, else the device's setting). Every colour is a
1329
+ `--n133-*` variable with a `--n133-dark-*` twin (and `--n133-sun` for Buy, `--n133-warn` for the stamp). The QR code stays dark on white.
1330
+ - **A shop that is a modal `<dialog>` of the game's own** (its own sheet around `open()`) closes it in its `signIn` hook before it opens the sign-in
1331
+ sheet, or the sign-in sheet stays hidden behind it. The game starter's `src/client/main.ts` does this.
1332
+ - **Buy from a game's own `<dialog>`** (its own looks sheet calls `shop.buy(lookId)`): close the game's dialog first, and let it finish closing (its
1333
+ `close` event) before the kit's sheet opens, or the game's close handler takes the focus and the Escape key from the kit's sheet. Since 0.9.2 the
1334
+ kit does this itself: `buy(lookId)` starts on the next frame (Chromium fires a dialog's `close` event at the frame, after a `setTimeout(0)`; a
1335
+ hidden page has no frames and starts at once), so `dialog.close(); shop.buy(id)` is safe. `shop.isOpen` is false until that frame. `open()` still opens at once.
1336
+ - **`onSessionEnd()`** (since 0.9.3): the server refused an order with 401 (the player's session ended, though `signedIn()` said yes). The
1337
+ game marks the player signed out. The sheet's "Sign in" key then calls `signIn()` without asking `signedIn()` first, so a stale "yes" cannot
1338
+ post the refused order again.
1339
+ - **`onSignInCancel(lookId, look)`** (since 0.9.3): when the game's own Buy (`buy(lookId)`) asks for a sign-in and the player closes it without signing
1340
+ in, the kit opens nothing (it closes its sheet if it was open) and calls this hook: the game shows its own looks sheet again. A Buy in the kit's own
1341
+ list goes back to that list, as before, and does not call it.
1342
+
1343
+ ```ts
1344
+ const shop = mountShop(el, { ...options, key: false, onSignInCancel: () => myLooksDialog.showModal() });
1345
+ myBuyKey.onclick = () => { myLooksDialog.close(); shop.buy(look.id); };
1346
+ ```
1347
+
1348
+ **Its words** (since 0.9.3). `words` on `mountShop` (and on `looksView` and `payPanel`) changes any word of the looks sheet and the pay sheet: the
1349
+ titles, the lines, every key, the states (waiting, paid, paid late, ended), the refusals (`refusal(text, status)`: the server's words are English) and
1350
+ the names for a screen reader. Give the keys of `ShopWords` you want, over the defaults (`SHOP_WORDS`); a text with a value in it is a function of
1351
+ that value:
1352
+
1353
+ ```ts
1354
+ mountShop(el, { ...options, words: { sendExactly: "Pay exactly", buyTitle: (look) => `Get ${look}` } });
1355
+ ```
1356
+
1357
+ The defaults are short: the pay sheet shows at most 30 words (TEST MODE and live) and no sentence has more than 10 (`test/panel-words.test.ts`).
1358
+ Two things are not the game's words: the stamp **"TEST MODE: DO NOT PAY"**, and **"Powered by CoinGecko"** while CoinGecko is one of the price feeds
1359
+ (its API terms; `priceLine` adds it when the game's `prices` leaves it out). Keep the meaning of the safety lines in any language: live, send the
1360
+ **exact** amount, and the order ends at the timer; in test mode nothing can be paid and the wallet key is off; a late payment gives no look and
1361
+ the creator sends it back.
1362
+
1363
+ ### The page's Content Security Policy
1364
+
1365
+ The shop needs no other origin, except Turnstile when the game turns it on. Merge its lists into the game's own policy; never replace the game's:
1366
+
1367
+ ```ts
1368
+ import { SHOP_CSP, mergeCsp } from "@nano133/nanoterra/shop";
1369
+ const CONTENT_SECURITY_POLICY = mergeCsp(MY_POLICY, SHOP_CSP); // adds Turnstile to script-src and frame-src; keeps every source of yours
1370
+ ```
1371
+
1372
+ ### Moving the game starter onto the package
1373
+
1374
+ The tables, the routes and the answers are the starter's, so the switch keeps **every order, owned look, price, scan time and limit
1375
+ counter** as they are, and the page needs no change for the server part.
1376
+
1377
+ 1. `npm install @nano133/nanoterra@<this release>`. No migration: the starter's `0001_init.sql` already made `orders`, `owned`, `meta` and
1378
+ `hits` (the shop's only tables; 0002 to 0007 hold sign-in, Nanoterra, admin and friends tables).
1379
+ 2. `src/worker/index.ts`: after `const session = await sessionOf(db, req, now)`, make the shop and let it answer (it replaces `createOrder`, the
1380
+ two order routes and `payView`): `const shop = createShop(env, { catalogue: looks, player: async () => session?.address ?? null, now: () => now });`
1381
+ then `const answer = await shop.handle(req); if (answer) return answer;`. In `/api/me`, `owned` is `await shop.owned(session.address)`. In
1382
+ `/api/config`, `testMode` and `turnstileSiteKey` can come from `shop.config()` (the same rules). In `scheduled`, `shop.scan(now)` replaces `sweep`.
1383
+ Keep the router's own per-visitor memory limits, its `sameOrigin` check and `secure()` as they are.
1384
+ 3. `src/worker/admin.ts`: `check: (at) => shop.scan(at)` and `nodes: nodesFor(env).map((n) => n.name)` (`nodesFor` from `@nano133/nanoterra/shop`).
1385
+ 4. Delete `src/worker/{orders,match,sweep,price,nodes}.ts` and their tests (`orders`, `sweep`, `price`, `nodes`, `amount`): the package carries
1386
+ them and their tests. `turnstile.ts` and `ratelimit.ts` stay while the sign-in uses them (or use `turnstileCheck`, `d1Limit` and `visitorKey`
1387
+ from the package: the same rows). `src/shared/amount.ts` goes when nothing else imports it.
1388
+ 5. The page: `mountShop` with `key: false` (the starter's own Looks keys call `shop.open()`), `signIn` opening the starter's sign-in sheet,
1389
+ `onOwned` reading `/api/me` again and wearing an outfit, `personCheck` with the starter's Turnstile. Delete `src/client/ui/{looks,pay}.ts`
1390
+ and the looks and pay rules of `style.css`; set the `--n133-*` variables from the starter's `:root`. `ui/qr.ts` (and `qrcode-generator`)
1391
+ stays while the sign-in sheet draws its QR code (or use `qrSvg` from `@nano133/nanoterra/shop/panel`).
1392
+ 6. Keep the starter's route tests (`admin.test.ts` "test mode" and "never shows one player's order to another"): they pass unchanged.
1393
+
1394
+ ### What the shop does not do
1395
+
1396
+ - **Sign a player in.** It takes the game's signed-in player (`player(request)`) and asks the game for a sign-in (`signIn()`); the kit's
1397
+ player sign-in (wallets and a passkey) is its own path.
1398
+ - **Send money back.** It holds no key: a late payment is listed (`"paid_late"`), and the creator sends it back by hand.
1399
+ - **Sell anything random**, or anything that changes a score, a speed or a rule: a look only changes how the character looks.
1400
+
1017
1401
  ## Moving from the three old packages
1018
1402
 
1019
1403
  | Before | Now |
@@ -1035,7 +1419,7 @@ does not know an id drops it, and nothing breaks.
1035
1419
  - 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).
1036
1420
  - Shared geometry for many characters at once, and a react-three-fiber wrapper.
1037
1421
  - `WebGPURenderer`: the ink is a GLSL `ShaderMaterial`, so use `WebGLRenderer`.
1038
- - Any server or Nano code. This package draws a character and reads a ticket; it moves no money.
1422
+ - Move money. The shop (`/shop`) reads the Nano ledger to see a payment to the creator's address; no part of the package holds a key or sends.
1039
1423
 
1040
1424
  ## Build and test
1041
1425