@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.
- package/CHANGELOG.md +92 -0
- package/README.md +390 -6
- package/ai/SPEC.md +71 -3
- package/dist/admin/panel/mount.d.ts +3 -0
- package/dist/admin/panel/mount.js +4 -2
- package/dist/player-signin/adapters/d1.d.ts +10 -0
- package/dist/player-signin/adapters/d1.js +108 -0
- package/dist/player-signin/adapters/firestore.d.ts +45 -0
- package/dist/player-signin/adapters/firestore.js +168 -0
- package/dist/player-signin/conformance.d.ts +11 -0
- package/dist/player-signin/conformance.js +160 -0
- package/dist/player-signin/handler.d.ts +64 -0
- package/dist/player-signin/handler.js +364 -0
- package/dist/player-signin/host.d.ts +4 -0
- package/dist/player-signin/host.js +10 -0
- package/dist/player-signin/index.d.ts +9 -0
- package/dist/player-signin/index.js +15 -0
- package/dist/player-signin/memory.d.ts +15 -0
- package/dist/player-signin/memory.js +101 -0
- package/dist/player-signin/panel/index.d.ts +9 -0
- package/dist/player-signin/panel/index.js +8 -0
- package/dist/player-signin/panel/memo.d.ts +12 -0
- package/dist/player-signin/panel/memo.js +41 -0
- package/dist/player-signin/panel/mount.d.ts +21 -0
- package/dist/player-signin/panel/mount.js +107 -0
- package/dist/player-signin/panel/view.d.ts +48 -0
- package/dist/player-signin/panel/view.js +363 -0
- package/dist/player-signin/panel/words.d.ts +103 -0
- package/dist/player-signin/panel/words.js +111 -0
- package/dist/player-signin/scheme-check.d.ts +3 -0
- package/dist/player-signin/scheme-check.js +14 -0
- package/dist/player-signin/scheme.d.ts +13 -0
- package/dist/player-signin/scheme.js +28 -0
- package/dist/player-signin/types.d.ts +116 -0
- package/dist/player-signin/types.js +33 -0
- package/dist/shop/adapters/d1.d.ts +25 -0
- package/dist/shop/adapters/d1.js +200 -0
- package/dist/shop/adapters/firestore.d.ts +53 -0
- package/dist/shop/adapters/firestore.js +245 -0
- package/dist/shop/address.d.ts +14 -0
- package/dist/shop/address.js +104 -0
- package/dist/shop/amount.d.ts +26 -0
- package/dist/shop/amount.js +59 -0
- package/dist/shop/conformance.d.ts +13 -0
- package/dist/shop/conformance.js +203 -0
- package/dist/shop/handler.d.ts +76 -0
- package/dist/shop/handler.js +156 -0
- package/dist/shop/index.d.ts +19 -0
- package/dist/shop/index.js +21 -0
- package/dist/shop/match.d.ts +23 -0
- package/dist/shop/match.js +58 -0
- package/dist/shop/memory.d.ts +13 -0
- package/dist/shop/memory.js +106 -0
- package/dist/shop/nodes.d.ts +47 -0
- package/dist/shop/nodes.js +106 -0
- package/dist/shop/orders.d.ts +47 -0
- package/dist/shop/orders.js +82 -0
- package/dist/shop/panel/index.d.ts +9 -0
- package/dist/shop/panel/index.js +14 -0
- package/dist/shop/panel/looks.d.ts +33 -0
- package/dist/shop/panel/looks.js +61 -0
- package/dist/shop/panel/mount.d.ts +68 -0
- package/dist/shop/panel/mount.js +221 -0
- package/dist/shop/panel/pay.d.ts +27 -0
- package/dist/shop/panel/pay.js +128 -0
- package/dist/shop/panel/qr.d.ts +9 -0
- package/dist/shop/panel/qr.js +281 -0
- package/dist/shop/panel/words.d.ts +89 -0
- package/dist/shop/panel/words.js +79 -0
- package/dist/shop/price.d.ts +23 -0
- package/dist/shop/price.js +43 -0
- package/dist/shop/scan.d.ts +24 -0
- package/dist/shop/scan.js +80 -0
- package/dist/shop/security.d.ts +10 -0
- package/dist/shop/security.js +56 -0
- package/dist/shop/store.d.ts +101 -0
- package/dist/shop/store.js +4 -0
- package/dist/shop/worker.d.ts +43 -0
- package/dist/shop/worker.js +76 -0
- package/package.json +21 -6
- package/src/admin/panel/mount.ts +7 -2
- package/src/player-signin/adapters/d1.sql +45 -0
- package/src/player-signin/adapters/d1.ts +120 -0
- package/src/player-signin/adapters/firestore.ts +194 -0
- package/src/player-signin/conformance.ts +184 -0
- package/src/player-signin/handler.ts +404 -0
- package/src/player-signin/host.ts +12 -0
- package/src/player-signin/index.ts +20 -0
- package/src/player-signin/memory.ts +94 -0
- package/src/player-signin/panel/index.ts +13 -0
- package/src/player-signin/panel/memo.ts +49 -0
- package/src/player-signin/panel/mount.ts +129 -0
- package/src/player-signin/panel/style.css +478 -0
- package/src/player-signin/panel/view.ts +433 -0
- package/src/player-signin/panel/words.ts +210 -0
- package/src/player-signin/scheme-check.ts +14 -0
- package/src/player-signin/scheme.ts +32 -0
- package/src/player-signin/types.ts +103 -0
- package/src/shop/adapters/d1.sql +52 -0
- package/src/shop/adapters/d1.ts +245 -0
- package/src/shop/adapters/firestore.ts +281 -0
- package/src/shop/address.ts +102 -0
- package/src/shop/amount.ts +66 -0
- package/src/shop/conformance.ts +232 -0
- package/src/shop/handler.ts +202 -0
- package/src/shop/index.ts +30 -0
- package/src/shop/match.ts +63 -0
- package/src/shop/memory.ts +102 -0
- package/src/shop/nodes.ts +115 -0
- package/src/shop/orders.ts +93 -0
- package/src/shop/panel/index.ts +19 -0
- package/src/shop/panel/looks.ts +106 -0
- package/src/shop/panel/mount.ts +280 -0
- package/src/shop/panel/pay.ts +192 -0
- package/src/shop/panel/qr.ts +256 -0
- package/src/shop/panel/style.css +538 -0
- package/src/shop/panel/words.ts +172 -0
- package/src/shop/price.ts +52 -0
- package/src/shop/scan.ts +90 -0
- package/src/shop/security.ts +50 -0
- package/src/shop/store.ts +99 -0
- 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").
|
|
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
|
-
|
|
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`
|
|
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
|
-
-
|
|
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
|
|