@nano133/nanoterra 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +269 -12
  3. package/ai/SPEC.md +7 -2
  4. package/dist/admin/adapters/d1.d.ts +27 -0
  5. package/dist/admin/adapters/d1.js +73 -0
  6. package/dist/admin/adapters/firestore.d.ts +40 -0
  7. package/dist/admin/adapters/firestore.js +53 -0
  8. package/dist/admin/amount.d.ts +5 -0
  9. package/dist/admin/amount.js +11 -0
  10. package/dist/admin/conformance.d.ts +132 -0
  11. package/dist/admin/conformance.js +467 -0
  12. package/dist/admin/guard.d.ts +38 -0
  13. package/dist/admin/guard.js +57 -0
  14. package/dist/admin/handler.d.ts +63 -0
  15. package/dist/admin/handler.js +170 -0
  16. package/dist/admin/http.d.ts +17 -0
  17. package/dist/admin/http.js +76 -0
  18. package/dist/admin/index.d.ts +13 -0
  19. package/dist/admin/index.js +14 -0
  20. package/dist/admin/memory.d.ts +32 -0
  21. package/dist/admin/memory.js +40 -0
  22. package/dist/admin/panel/api.d.ts +11 -0
  23. package/dist/admin/panel/api.js +33 -0
  24. package/dist/admin/panel/dom.d.ts +7 -0
  25. package/dist/admin/panel/dom.js +33 -0
  26. package/dist/admin/panel/gameCard.d.ts +4 -0
  27. package/dist/admin/panel/gameCard.js +56 -0
  28. package/dist/admin/panel/index.d.ts +10 -0
  29. package/dist/admin/panel/index.js +13 -0
  30. package/dist/admin/panel/mount.d.ts +30 -0
  31. package/dist/admin/panel/mount.js +147 -0
  32. package/dist/admin/panel/shelf.d.ts +24 -0
  33. package/dist/admin/panel/shelf.js +271 -0
  34. package/dist/admin/panel/shelfData.d.ts +37 -0
  35. package/dist/admin/panel/shelfData.js +68 -0
  36. package/dist/admin/panel/signins.d.ts +6 -0
  37. package/dist/admin/panel/signins.js +69 -0
  38. package/dist/admin/store.d.ts +121 -0
  39. package/dist/admin/store.js +3 -0
  40. package/dist/admin/summary.d.ts +39 -0
  41. package/dist/admin/summary.js +85 -0
  42. package/dist/admin/switches.d.ts +58 -0
  43. package/dist/admin/switches.js +61 -0
  44. package/dist/character/bases/bee.js +7 -5
  45. package/dist/character/bases/spec.d.ts +7 -0
  46. package/dist/character/create.d.ts +15 -0
  47. package/dist/character/create.js +69 -2
  48. package/dist/character/ctx.d.ts +12 -2
  49. package/dist/character/index.d.ts +2 -0
  50. package/dist/character/index.js +2 -0
  51. package/dist/character/make.js +2 -1
  52. package/dist/character/preview.d.ts +32 -0
  53. package/dist/character/preview.js +112 -0
  54. package/dist/character/sockets.d.ts +37 -0
  55. package/dist/character/sockets.js +66 -0
  56. package/dist/looks/change.d.ts +8 -4
  57. package/dist/looks/change.js +25 -3
  58. package/dist/looks/index.d.ts +3 -3
  59. package/dist/looks/index.js +2 -2
  60. package/dist/looks/json.d.ts +2 -2
  61. package/dist/looks/json.js +6 -4
  62. package/dist/looks/look.d.ts +29 -3
  63. package/dist/looks/look.js +60 -4
  64. package/dist/model/check.d.ts +59 -0
  65. package/dist/model/check.js +681 -0
  66. package/dist/model/index.d.ts +2 -0
  67. package/dist/model/index.js +5 -0
  68. package/dist/model/limits.d.ts +103 -0
  69. package/dist/model/limits.js +132 -0
  70. package/package.json +11 -3
  71. package/src/admin/adapters/d1.ts +94 -0
  72. package/src/admin/adapters/firestore.ts +78 -0
  73. package/src/admin/amount.ts +14 -0
  74. package/src/admin/conformance.ts +605 -0
  75. package/src/admin/guard.ts +86 -0
  76. package/src/admin/handler.ts +200 -0
  77. package/src/admin/http.ts +74 -0
  78. package/src/admin/index.ts +21 -0
  79. package/src/admin/memory.ts +59 -0
  80. package/src/admin/panel/api.ts +37 -0
  81. package/src/admin/panel/dom.ts +31 -0
  82. package/src/admin/panel/gameCard.ts +63 -0
  83. package/src/admin/panel/index.ts +18 -0
  84. package/src/admin/panel/mount.ts +227 -0
  85. package/src/admin/panel/shelf.ts +361 -0
  86. package/src/admin/panel/shelfData.ts +87 -0
  87. package/src/admin/panel/signins.ts +68 -0
  88. package/src/admin/panel/style.css +551 -0
  89. package/src/admin/store.ts +98 -0
  90. package/src/admin/summary.ts +103 -0
  91. package/src/admin/switches.ts +74 -0
  92. package/src/character/bases/bee.ts +7 -5
  93. package/src/character/bases/spec.ts +4 -0
  94. package/src/character/create.ts +73 -2
  95. package/src/character/ctx.ts +17 -2
  96. package/src/character/index.ts +2 -0
  97. package/src/character/make.ts +2 -1
  98. package/src/character/preview.ts +148 -0
  99. package/src/character/sockets.ts +81 -0
  100. package/src/looks/change.ts +25 -6
  101. package/src/looks/index.ts +3 -3
  102. package/src/looks/json.ts +6 -4
  103. package/src/looks/look.ts +66 -5
  104. package/src/model/check.ts +624 -0
  105. package/src/model/index.ts +5 -0
  106. package/src/model/limits.ts +193 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,93 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-10-28)
4
+
5
+ - **Sockets** (`@nano133/nanoterra/character`): three attach points on the character, `c.socket("head" | "hand" | "back")`, each a `THREE.Object3D` on a bone
6
+ (`head`, `grip_R`, `chest`) that follows the walk, the run, the jump and the wave. The head socket sits at the base's hat frame and scales with it and the head's
7
+ size; the hand socket is in the middle of the drawn right hand (a wide build or base draws its hands further out than the bone) and holds a thing pointing
8
+ forward (`HAND_HOLD`); the back socket is on the back of the torso at the chest. A socket is the same object for the character's whole life: a rebuild (a new
9
+ base, hair or clothes) moves it, with what hangs on it, to the new bones. New: `SOCKETS`, `socketFrame`, `placeSocket`, `isSocketName`, `HAND_HOLD`, the types
10
+ `SocketName`, `SocketFrame`, `SocketLook`; `BaseSpec` may set `handAt` and `back`.
11
+ - **A model on a socket**: `WornAdapter` takes an optional `socket` and `object` (and `add` is now optional). `createCharacter({ worn })` and `c.setWorn(worn)`
12
+ hang each object on its socket; a model on a socket builds no geometry, and its `fit`, `hidesBadge`, `hideTail` and the new `hideWings` (a back item takes the Bee's wings off, shapes and bones, as `hideTail` takes a tail off) still apply. The character never frees an
13
+ object that the game hung: `setWorn` takes the old ones off, and `dispose` takes the sockets off the dead bones.
14
+ - **`@nano133/nanoterra/model`**: the 3D model check, moved here from nano133.com (`app/launch/model`, which now keeps the same code). `checkModel(bytes,
15
+ limits)` reads a `.glb` from its bytes, never throws, and answers the problems with a fix each, the facts and, new, **advice** (a large PNG or JPEG, 32-bit
16
+ positions, parts that share a material). New facts: `draws` (each part of each drawn mesh instance), `materials`, `anchor`. New limits: `maxDraws`, and
17
+ `anchor` (a marker whose origin is the attach point: the zone is measured from it). The cosmetic presets of Wes's table (2026-10-28): `HEAD_ITEM`,
18
+ `HAND_ITEM` (its anchor is `grip_R`; `grip_L` is allowed; the zone is 0.96 x 0.68 m, from 1.17 m below the grip to 0.43 m above: Wes's choice A of 2026-10-28, so that a shovel held by its T-handle fits), `BACK_ITEM`, and `COSMETIC_LIMITS` by socket; `ISLAND_MODEL` is the island's (a disc of 10 m
19
+ radius, 18 m high). A misspelt marker with capitals in its name (`Grip_R`) now gets its rename line too. A fix line gives a zone's number with two decimals when one is not exact (1.17 m), and as before otherwise (the island's 20.0 m).
20
+
21
+ - **The Shelf's pictures on the real character**: `createLookPreview` (in `@nano133/nanoterra/character`) draws a look on a character (the game's own
22
+ `body`, else the package's character with its shirt in the look's colour) with one shared offscreen WebGL renderer, one picture at a time, kept by the
23
+ look's text; with no WebGL it answers null. `previewPicture(load)` (in `@nano133/nanoterra/admin/panel`, which still loads no Three.js) is a Shelf
24
+ `lookPicture` that shows the flat picture first and the drawn one when it is ready; `load` is called once, so a game imports its preview file only when
25
+ the Shelf opens. New CSS: `.shelf-preview`.
26
+
27
+ ## 0.2.0 (2026-10-27)
28
+
29
+ - **Slots** on a look (`@nano133/nanoterra/looks`, and the Shelf of `/admin/panel`): `slots?: Record<string, string>`, what a game defines for itself (a pair of wings, a trail colour). Names are 1 to 24
30
+ lowercase letters, digits or dashes starting with a letter; values are words (1 to 40 lowercase letters, digits or dashes) or `#rrggbb` colours; at most 8 on a look. `lookProblems` checks them
31
+ (`slots`, `slots.<name>`), `checkLooks` keeps them, `looksJson` writes them after `change`, `diffLooks` lists a change as the field `"slots"` (order and the case of a colour are not changes), and
32
+ `removalProblems` reports a changed slot on a look that a player owns (players own a look by its id; a price, a name, a colour or a hat of an owned look stays allowed). New: `sameSlots`,
33
+ `cleanSlots`, `slotValueOk`, `SLOTS_MAX`, `SLOT_NAME_PATTERN`, `SLOT_WORD_PATTERN`, the type `Slots`. A file from before slots reads and writes exactly as it did. The Shelf keeps the slots in its
34
+ draft and has a Slots editor. This is a minor change (0.2.0); a game on 0.1.0 drops slots, so it must move to the new version.
35
+ - **`@nano133/nanoterra/admin`** (step 1 of the admin panel): the admin of a game, in the package, with no Three.js, no database and no cookie. The game gives a session and a store.
36
+ - `adminAccess(input)`: the guard, ONE pure function. The same five checks as the game starter's `adminGuard`, in the same order (owner set, session present, wallet login,
37
+ same address, not older than one day). It only refuses MORE than the starter does: a bad owner address, a time that is not a number or lies more than five minutes in the
38
+ future, a login word that is not exactly "wallet", and a limit above one day are all refusals. A fault inside it is a refusal.
39
+ - `createAdminHandler(options)`: the routes (summary, the two game switches, the payment check) on Web `Request` and `Response`. Every path under the base goes through the
40
+ guard first, so a visitor learns nothing about the routes. A POST must come from the game's own page. No CORS header, always `no-store`, a body limit, a rate limit.
41
+ - `AdminStore`: what a game gives (read the two settings, save a checked patch, the summary). The only writes are the two `setting:` values.
42
+ - `loadGameSettings(store, defaults)`: the two switches for the game's own routes (the listing file, the character); the defaults when the store fails.
43
+ - `createMemoryStore()`: a store in memory, for tests.
44
+ - **`@nano133/nanoterra/admin/panel`** (step 2): `mountAdmin(element, options)`, the admin page in plain DOM with no framework: the Game card, the status warnings, the totals, the Shelf (the
45
+ starter's, moved here: it edits a draft and gives text to copy, and sends no request), the orders with "Paid late" and an optional "Check payments now", the looks that players own,
46
+ and the screens for a visitor who is signed out, a wrong wallet, a Nanoterra sign-in and a missing owner address. The game gives `signIn`, `signOut` and its `looks`.
47
+ `@nano133/nanoterra/admin/style.css`: its stylesheet, every rule under `.n133-admin`. The summary of the handler now says `canCheck`.
48
+ - **`@nano133/nanoterra/admin/d1`** and **`/admin/firestore`** (step 3): `createD1Store(db)` (the game starter's tables, with the starter's own queries) and `createFirestoreStore(db, names?)` (a small interface; no
49
+ Firebase dependency). Both write ONLY the two settings. `adminStoreConformance(make)` (in `/admin/conformance`) is the store contract that every adapter passes: the memory store, D1 on SQLite and Firestore on a fake.
50
+ The summary's numbers come from one file (`summarize`, `moneyOf`, `shapeOrders`), so every adapter says the same thing. The memory store now takes orders with a raw `amountRaw`, like a real database.
51
+ - **The Game card's words for sign-in Off** (Wes, "off means off"): "Sign-in from Nanoterra is Off, so every player plays as the island's first character, a player who signed in from Nanoterra before
52
+ included." The card only says it: the game's own page must draw no Nanoterra character while sign-in is Off (the game starter's `src/client/ui/player.ts` does).
53
+ - **Who signed in, and "Sign out everywhere"** (optional, Wes's security ideas): `AdminLogin` (time, country, short browser name) and the optional store calls `recentLogins(limit)` and
54
+ `signOutEverywhere()`. With them the summary has `logins` (the last 10, only those three things, whatever the store gives) and `canSignOutEverywhere`, the handler has `POST {base}/signout-all`
55
+ (guarded, same origin, 5 a minute, deletes the admin sessions and no player session), and the panel has a Sign-ins card with a two-press "Sign out everywhere". `createD1Store(db, { logins: true })`
56
+ reads `admin_logins` and deletes `sessions WHERE scope = 'admin'`. Memory store: `createMemoryStore({ logins: true })`. The store contract has cases for both. The panel option `loginsNote` says what
57
+ the game keeps and for how long (30 days suggested).
58
+ - **`@nano133/nanoterra/admin/conformance`**: `adminConformance(host)` gives test cases that a game runs against its OWN session code and store: every route refuses a visitor
59
+ who is signed out, a login made for the game's players (also its token in the admin cookie), a Nanoterra sign-in, a login made some way the game does not know, another wallet, a missing
60
+ owner address, an expired login and a login that is old but still valid in the store; OPTIONS and HEAD are refused with no preflight answer; other spellings of an admin path open nothing with
61
+ any method; the game's other routes under the base (`otherRoutes`) refuse a visitor; a POST from another site is refused; a body that is too big is refused; every answer is `no-store` with
62
+ no CORS header. (The first version of this suite passed four wirings that leak everything; a security read found them, and each is a test now.)
63
+ - **A fourth security read** (the same fresh agent as the third, 2026-10-27, on the third-read fixes) found that `adminSigninConformance` still passed wrong chains: one with no binding of a request to its
64
+ browser inside one scope, one whose cross-scope ask gave a login in the STORED scope, one whose `sign()` stopped landing (the checks then ran on an unsigned request), one that checked the scope only
65
+ while the request was fresh, one whose Sign out everywhere left a request that was still waiting for a signature, and one with no `signOutEverywhere` at all (the case was skipped). Fixed: `sign()` must
66
+ throw on a refusal; a cross-scope ask must give no login of either kind and the request must still sign in afterwards (the signature is proven to have landed); a same-scope ask from another browser gives
67
+ none; the host may give `advance(ms)` to repeat the asks after the admin's request life; `adminSigninConformance(host, { signOutEverywhere: true })` fails a host that has no call; the store contract
68
+ REQUIRES `seedRequests` and `pendingRequests` when the store has `signOutEverywhere`. `createD1Store(db, { logins: true })`'s `signOutEverywhere` also ends the pending admin requests
69
+ (`expires_at = 0`). Eight fake chains (one good, seven wrong) run against the contract.
70
+ A fifth read of these fixes (same agent) found four small gaps, fixed: `adminSigninConformance(host, { advance: true })` fails a host that cannot move its clock (the time case could be skipped);
71
+ the late asks are made at 10 and at 55 minutes (a scope check that holds for 30 minutes passed); a collected request must give another browser no login either; and the kit's `dist` must be
72
+ built before `npm pack` (a packed tarball of an unbuilt change tested the old code). Ten fake chains (one good, nine wrong).
73
+ - **A third security read** (another new fresh agent, 2026-10-27) found that the suite still passed four wirings that leak: a game that caches 200 answers by URL, one that keeps the sessions it has read
74
+ in memory (a login that sign-out or Sign out everywhere ended still opens), one that also reads a player's token from a Bearer header, and one that reads it from the query. Fixed: the suite sends every
75
+ refused visitor again right after the owner was served; `host.endLogin` and the login that used Sign out everywhere are used again and must be refused; `req.tokenAt` sends a player's token in other
76
+ places than the cookie; and `adminSigninConformance(host)` is a contract for the code that MAKES the logins (a game request never makes an admin login and the other way round, for the browser that
77
+ started it, another browser and retyped credentials; once; and a signed admin request makes no login after Sign out everywhere). `createD1Store(db, { logins: true })`'s `signOutEverywhere` also marks
78
+ the admin sign-in requests that were not delivered as delivered (needs `signin_requests.scope` and `.delivered`). The list shows a browser name only when it is words. Four tests run the four wirings
79
+ against the suite, and three fake sign-in chains run against the contract (one good, three wrong).
80
+ - **A second security read** (new fresh agent, 2026-10-27) found that the second conformance suite still passed some wirings that leak or act. Fixed: the suite sends a foreign Origin (another site,
81
+ `"null"`, a sibling subdomain) and Sec-Fetch-Site cross-site and same-site on every route with every method, for the owner too; it sends an old login that is still valid THREE times (the last two
82
+ with `req.again`) so a rolling session fails; `expired_login` is now made less than a day ago so only the game's own store can refuse it; new kind `signed_out_login`; the spelling tricks run on
83
+ every route and method; the game's other routes must refuse with a refusal that carries no data. **The handler now refuses a read that says it comes from another page**, as it refused a write.
84
+ Looks: `wholeCents` is exact (`0.300000001` and `1e-9` no longer pass); a slot colour is written in lower case (a change of only its case is no change); the names `constructor` and
85
+ `prototype` are reserved; `checkLooks` checks and copies one snapshot; the Shelf never gives an id that a player owns to a new look.
86
+ - **Fixes from a security read of the first admin code:** odd spellings of the base path (a bad percent code, backslashes, many levels of encoding) now reach the guard instead of falling through;
87
+ a fault in the game's `now()` or `onError()` can no longer reject `handle()` with the raw error; the default limiter is keyed by base path and owner, so two games in one process do not share a
88
+ count; the message for an AI assistant takes only valid look ids, written as JSON strings (an id from a database cannot write a line into it); the summary counts only the four statuses it knows
89
+ and reads a dollar text only when it is plain dollars.
90
+
3
91
  ## 0.1.0
4
92
 
5
93
  First version (Pre-alpha). **One package for Nanoterra.** It replaces `@nano133/character` 0.1.0, `@nano133/ticket` 0.1.0 and
package/README.md CHANGED
@@ -60,7 +60,12 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
60
60
  | `@nano133/nanoterra/character/record` | the character's **record**: pack, unpack, check, `fromLegacy`, `toLegacy`, `diceRecord`, the lists | no | 4.4 kB (all), 1.9 kB (pack, unpack and the two legacy calls) |
61
61
  | `@nano133/nanoterra/ticket` | read and check a sign-in ticket | no | 4.7 kB (all), 3.1 kB (`readTicket` alone) |
62
62
  | `@nano133/nanoterra/looks` | the looks ladder | no | 1.9 kB (all), 0.9 kB (`checkLooks` alone) |
63
- | `@nano133/nanoterra/character` | the builder and `createCharacter` (it includes the record) | yes | 20.4 kB (all), 17.8 kB (`createCharacter` alone) |
63
+ | `@nano133/nanoterra/character` | the builder and `createCharacter` (it includes the record), with the three sockets | yes | 21.8 kB (all, with the sockets and `createLookPreview` of 0.3.0; 20.4 kB on 0.2.0), 17.8 kB (`createCharacter` alone, measured on 0.2.0) |
64
+ | `@nano133/nanoterra/model` | the 3D model check: a `.glb` against the island's limits or a cosmetic's (since 0.3.0) | no | 8.8 kB |
65
+ | `@nano133/nanoterra/admin` | the admin of a game: the guard, the handler, the store type (server code; since 0.2.0) | no | 3.7 kB |
66
+ | `@nano133/nanoterra/admin/d1`, `/admin/firestore` | the admin store for D1 (the starter's tables) or Firestore (a small interface, no Firebase dependency): `createD1Store`, `createFirestoreStore` (since 0.2.0) | no | 1.5 kB each |
67
+ | `@nano133/nanoterra/admin/panel` | the admin page, drawn in the browser into one element, and its stylesheet `@nano133/nanoterra/admin/style.css` (since 0.2.0) | no | 10.6 kB on 0.3.0 (8.5 kB measured on 0.2.0) + 2.4 kB of CSS |
68
+ | `@nano133/nanoterra/admin/conformance` | test cases that a game runs against its own admin (a dev dependency in practice; since 0.2.0) | no | n/a |
64
69
 
65
70
  Measured 2026-10-26: `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
66
71
  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.
@@ -81,7 +86,7 @@ c.dispose();
81
86
 
82
87
  | Call | What it does |
83
88
  |---|---|
84
- | `createCharacter({ record?, look?, colors?, quality?, reducedMotion? })` | Builds a character. `record` is the player's whole character (see "The record"). `look` is the five colours `{ skin, hair, shirt, pants, shoes }` of a player whose ticket has no record: the Classic body in those colours (read only when `record` is left out). Neither: the island's first look. `colors` paints colours over the record's (any `"#rrggbb"`: a shop dye is not on the island's lists). `quality` is `"high"` (default) or `"low"`. Something that is not good throws a `TypeError` that names the part and the value. |
89
+ | `createCharacter({ record?, look?, colors?, quality?, reducedMotion?, worn? })` | Builds a character. `record` is the player's whole character (see "The record"). `look` is the five colours `{ skin, hair, shirt, pants, shoes }` of a player whose ticket has no record: the Classic body in those colours (read only when `record` is left out). Neither: the island's first look. `colors` paints colours over the record's (any `"#rrggbb"`: a shop dye is not on the island's lists). `quality` is `"high"` (default) or `"low"`. Something that is not good throws a `TypeError` that names the part and the value. |
85
90
  | `c.object` | A `THREE.Group`: feet at y = 0, facing +Z. Add it to your scene; move it with `position`, turn it with `rotation.y`. It holds the skinned meshes **and the blob shadow** (a disc in the body). |
86
91
  | `c.update(dt, { speed, airborne, verticalSpeed })` | One frame. `dt` is in **seconds** (a frame longer than 0.05 s counts as 0.05 s). Idle, walk, run and jump come from your numbers. Every field is optional. See "Motion". |
87
92
  | `c.wave()` | Waves the left arm for 1.8 s (`WAVE_SECONDS`), as the island does. |
@@ -89,7 +94,9 @@ c.dispose();
89
94
  | `c.setColors(colors \| null)` | Paints these colours over the record's; `null` gives the record's own back. No new geometry. |
90
95
  | `c.record`, `c.look` | The record now (a frozen copy), and its five colours (the older view). |
91
96
  | `c.reducedMotion` | A boolean you may change: true turns off the swing of the arms and legs and the bob, and makes the wave still. Set it from `prefers-reduced-motion`. |
92
- | `c.dispose()` | Frees the geometries, materials and textures that the character made. The other calls do nothing after it. |
97
+ | `c.socket(name)` | An attach point, a `THREE.Object3D` on a bone: `"head"`, `"hand"` or `"back"`. Add your own object to it and it follows the bone. It is the same object for the character's whole life: a rebuild moves it, with what hangs on it, to the new bones (since 0.3.0; see "Worn things and sockets"). |
98
+ | `c.setWorn(worn)` | The worn things (`WornAdapter`): shapes merged into the body, or a model on a socket. A model on a socket builds no geometry; the objects of things no longer worn come off their socket (since 0.3.0). |
99
+ | `c.dispose()` | Frees the geometries, materials and textures that the character made. The other calls do nothing after it. It never frees an object that you hung on a socket: that is yours. |
93
100
 
94
101
  Also exported from `/character`: `HEIGHT`, `RADIUS`, `WAVE_SECONDS`, `buildCharacter` and `assemble` (the pieces `createCharacter` uses: the geometry, the skinned meshes on the island's 28-bone skeleton), `BASE_IDS`, the lists, and everything of `/character/record`.
95
102
 
@@ -167,15 +174,62 @@ a player's choice of body never changes a rule. nano133.com's island and room do
167
174
  - The ink line is a second draw of the same skinned mesh, with its back faces, pushed out along its normal in screen space, so it keeps one width in pixels (2.6). Use `WebGLRenderer`: the ink is a GLSL `ShaderMaterial`.
168
175
  - Frame rate has not been measured on a real phone by this package. nano133.com's island draws a room of twelve of these characters at 60 frames a second on a throttled phone profile.
169
176
 
170
- ## Worn items (the next version)
177
+ ## Worn things and sockets
171
178
 
172
- **Version 0.1.0 draws the body only**, as `@nano133/character` 0.1.0 did. The island's 14 shop items (hats, scarves, packs, tops, a buddy, a blade) are
173
- fitted to every base by nano133.com's own code, and are not in this package yet. The builder takes them through **one small interface**,
174
- `WornAdapter` (what it needs of a worn thing: its shapes and bones through `add(ctx)`, what it does to the hair, `hidesBadge`, `hideTail`), so a game
175
- may bring its own, and the island brings its adapter the same way.
179
+ A worn thing reaches the builder through **one small interface**, `WornAdapter`. It has two ways, and a thing may use both:
176
180
 
177
- A player's item ids come in the sign-in answer (`ticket.items` here, `character.items` in the code exchange). A later version (0.2.0) adds the item art so
178
- that a game can draw them with one call. Until then a game may ignore the ids or draw its own things.
181
+ - **Merged shapes** (`add(ctx)`, and what it does to the hair `fit`, `hidesBadge`, `hideTail`, `hideWings`): the thing's shapes go into the body's own buffers, so it
182
+ **adds no draw**. nano133.com fits the island's 14 shop items (hats, scarves, packs, tops, a buddy, a blade) to every base this way, with its own code.
183
+ - **A model on a socket** (`socket` and `object`, since 0.3.0): your own `THREE.Object3D` (a `.glb` you loaded, say) hangs on one of the character's three
184
+ attach points and follows its bone. It costs **its own draws** (1 or 2 for each thing). The character never frees it: you load it and you free it.
185
+
186
+ ```ts
187
+ const c = createCharacter({ record });
188
+ const shovel = (await new GLTFLoader().loadAsync("/looks/shovel.glb")).scene;
189
+ c.setWorn([{ socket: "hand", object: shovel }]); // or: c.socket("hand").add(shovel)
190
+ c.setLook(otherRecord); // another base: the shovel moves to the new hand
191
+ c.setWorn([]); // the shovel comes off; free its geometry and materials yourself
192
+ ```
193
+
194
+ | Socket | Bone | Where | Its axes |
195
+ |---|---|---|---|
196
+ | `"head"` | `head` | the base's hat frame (`BASE_SPECS[base].hat`: about the middle of the head), scaled by the hat frame and the head's size | the character's: +Y up, +Z forward. A thing made round the middle of the Classic's head (radius 0.42 m) fits every head |
197
+ | `"hand"` | `grip_R` | the middle of the drawn right hand (a wide build or base draws its hands further out than the bone) | `HAND_HOLD`: a thing modelled standing up (+Y) with its grip at the origin is held pointing forward, its front (+Z) up |
198
+ | `"back"` | `chest` | on the back of the torso at the chest's height (`BASE_SPECS[base].back`, else the torso's radius at 1.12) | the character's: the side that touches the body at the origin, the bulk towards -Z. Give the thing `hideTail: true` and `hideWings: true` to take a Critter's tail and a Bee's wings off under it |
199
+
200
+ `socketFrame(spec, look, name)` gives a socket's bone, place, turn and scale for a base and a look, and `placeSocket(object, bones, spec, look, name)`
201
+ puts an object there: a host that uses `buildCharacter` and `assemble` (not `createCharacter`) makes its own sockets with them. Check a socket model
202
+ with the presets of `@nano133/nanoterra/model` (`HEAD_ITEM`, `HAND_ITEM`, `BACK_ITEM`) before you ship it.
203
+
204
+ A player's item ids come in the sign-in answer (`ticket.items` here, `character.items` in the code exchange). The island's item art is not in this package;
205
+ a game may ignore the ids or draw its own things.
206
+
207
+ ## Model check (`@nano133/nanoterra/model`; since 0.3.0)
208
+
209
+ `checkModel(bytes, limits)` reads a `.glb` (binary glTF 2.0) from its bytes and answers `{ ok, problems, facts, advice }`. It is pure (no Three.js, no
210
+ network, no DOM) and it never throws: a file that cannot be read answers a problem. The file comes from a stranger, so every read is bounded (no size in the
211
+ file is trusted, the work has a cap and a time limit).
212
+
213
+ - `problems`: each `{ rule, fix }`, the fix in one or two plain sentences for the creator. Rules: `file`, `size`, `embedded`, `extensions`, `lights`,
214
+ `motion`, `triangles`, `textures`, `draws`, `zone`, `markers`, `time`.
215
+ - `facts`: the bytes, the triangles, the **draws** (each part of each drawn mesh instance), the **materials** the parts use, the textures (their type and size,
216
+ read from the picture itself), the box and the reach, the markers, the anchor.
217
+ - `advice`: lines that are never a failure: a large PNG or JPEG (KTX2 makes it smaller), 32-bit positions (`KHR_mesh_quantization` halves them),
218
+ parts that share a material (joining them saves draws).
219
+ - `modelSummary(facts)`: one short line ("646 triangles, 0 textures, 49 KB, 0.5 x 0.4 x 1.0 m").
220
+
221
+ The presets (`limits.ts`; a game may make its own `ModelLimits`). The cosmetic numbers are Wes's of 2026-10-28 (the hand zone his second choice that day, so that a shovel held by its T-handle fits); the zone is in metres for a character 2.29 m
222
+ tall, its origin the socket's point (a hand item's `grip_R` marker when it has one):
223
+
224
+ | Preset | Zone (w x d x h) | Triangles | Textures | Draws | File |
225
+ |---|---|---|---|---|---|
226
+ | `HEAD_ITEM` | 0.9 x 0.9 x 0.8, from 0.1 below the socket | 3,000 | 1 of 512 px | 2 | 300 KB |
227
+ | `HAND_ITEM` | 0.96 x 0.68 x 1.6, from 1.17 below the grip to 0.43 above (a tool held near its top) | 3,000 | 1 of 512 px | 2 | 300 KB |
228
+ | `BACK_ITEM` | 1.0 x 0.6 x 1.2, from 0.6 below the socket | 4,000 | 1 of 512 px | 2 | 400 KB |
229
+ | `ISLAND_MODEL` | a disc of 10 m radius, 18 m high, from the ground | 50,000 | 8 of 1,024 px | no limit | 5 MB |
230
+
231
+ Every preset refuses skins, animations, morph targets, lights, external files and compressed meshes (Draco, meshopt): a worn thing is rigid on its socket.
232
+ `COSMETIC_LIMITS` gives the preset of each socket by its name. The island preset is nano133.com's (its "Launch my game" check uses this same code).
179
233
 
180
234
  ## The sign-in ticket
181
235
 
@@ -296,9 +350,36 @@ type Look = {
296
350
  name: string; // 1 to 40 characters
297
351
  priceUsd: number; // US dollars, at most 2 decimals; 0 is the free look
298
352
  change: { body: string; hat?: "cap" | "beanie" | "crown" | "tophat"; hatColor?: string }; // colours are "#rrggbb"
353
+ slots?: Record<string, string>; // what YOUR game defines for itself: see "Slots" below. Optional.
299
354
  };
300
355
  ```
301
356
 
357
+ ### Slots: what your game defines on a look
358
+
359
+ A game with its own parts (a pair of wings, a trail colour) needs a place for them on a look. That place is `slots`, an optional object of names and values:
360
+
361
+ ```json
362
+ { "id": "ember-wings", "name": "Ember wings", "priceUsd": 0.25, "change": { "body": "#ff7a3d" }, "slots": { "wings": "ember", "trail": "#ffcf5a" } }
363
+ ```
364
+
365
+ - A **name** is 1 to 24 lowercase letters, digits or dashes, starting with a letter (`SLOT_NAME_PATTERN`).
366
+ - A **value** is a **word** (1 to 40 lowercase letters, digits or dashes, `SLOT_WORD_PATTERN`) or a **colour** (`#rrggbb`). Nothing else: no number, no list, no nested object. A game that needs
367
+ a number writes it as a word it understands (`"size": "large"`).
368
+ - **At most 8 slots** on a look (`SLOTS_MAX`), so at most 512 characters of names and values. The names `constructor` and `prototype` are reserved (`RESERVED_SLOT_NAMES`).
369
+ - **A colour is written in lower case** by `checkLooks`, `cleanSlots` and `looksJson` (`#FFCF5A` becomes `#ffcf5a`), so the game reads one text for one colour and a change of only the case is no change.
370
+ - A look with no `slots`, or with an empty one, is **the same look**. A file from before slots reads and writes exactly as it did. Any other unknown field of a look is still dropped by `checkLooks`.
371
+ - Your game reads `look.slots` from the checked looks; the package gives them no meaning.
372
+ - `lookProblems` names a bad slot with the look, the field (`slots` or `slots.wings`) and plain words. `checkLooks` keeps the slots (a clean copy). `looksJson` writes them after `change`, on the
373
+ look's own line, in your order of names. `diffLooks` lists a change as the field `"slots"` (the same names and values in any order are not a change; a colour in upper or lower case is the same).
374
+ - **Players own a look by its id, so the slots of a look that a player owns must not change:** the game reads the slots of the id, and a new slot would change what they own. `removalProblems`
375
+ reports a changed slot on an owned look as a mistake with the field `"slots"` ("Add a new look with the new slots"), as it reports a removed owned id. A price, a name, a body colour or a hat of an
376
+ owned look may still change. The free look is every player's and no order owns it, so its slots may change; a look that nobody owns may change its slots too. The Shelf never gives an id that a
377
+ player owns to a NEW look (when an owned look is no longer in the file, a new look named like it gets the next free id, such as `mint-cap-2`).
378
+ - The Shelf (`/admin/panel`) keeps the slots in its draft, shows them on the look's card, and has a "Slots" editor on each look: rows of a name and a value, Add and Remove, with the count of 8.
379
+ A name used twice, a bad name and a bad value are shown before anything can be copied.
380
+
381
+ A game on version 0.1.0 of this package drops `slots` when it checks a file: use the version that has them (a minor change, 0.2.0) in the game, the server and the Shelf.
382
+
302
383
  The rules (the same as the starter's `AGENTS.md`):
303
384
 
304
385
  - A look only changes how the character looks. **It never changes a score, a speed or a rule.**
@@ -310,12 +391,13 @@ The rules (the same as the starter's `AGENTS.md`):
310
391
  |---|---|
311
392
  | `lookProblems(raw)` | Every mistake in a list of looks, as `{ look, field, message }`. An empty array is a good list. |
312
393
  | `checkLooks(raw)` | The looks, or an `Error` with the first mistake in plain words. |
313
- | `removalProblems(before, after, owned)` | The looks in `before` that a player owns (`owned` is a set of ids) and that `after` leaves out. |
394
+ | `removalProblems(before, after, owned)` | The looks in `before` that a player owns (`owned` is a set of ids) and that `after` leaves out, or whose slots `after` changes. |
314
395
  | `diffLooks(before, after)` | `{ added, removed, changed }` by id. A new order is not a change. |
315
396
  | `looksJson(looks)` | The text of `looks.json`, one look on each line, as the starter writes it. A change of one look is a change of one line. |
316
397
  | `slugId(name, taken)` | A free id for a name: `"Mint Cap"` gives `mint-cap`, then `mint-cap-2`. |
317
398
  | `PRICE_LADDER`, `rungOf(usd)`, `priceText(usd)` | The six steps nano133.com uses, from 1¢ to $1, and a price as a page writes it (`25¢`, `$1.10`). |
318
399
  | `usdText(usd)`, `wholeCents(usd)`, `HATS` | `"$0.25"`; whether a price has no fraction of a cent; the hats. |
400
+ | `sameSlots(a, b)`, `cleanSlots(slots)`, `slotValueOk(v)`, `SLOTS_MAX` | Whether two looks have the same slots; a clean copy (none when empty); whether a value is a word or a colour; 8. |
319
401
 
320
402
  | Step | Price | For |
321
403
  |---|---|---|
@@ -329,6 +411,180 @@ The rules (the same as the starter's `AGENTS.md`):
329
411
  The ladder is a suggestion. Any price with at most 2 decimals, up to $1000, is good. `wholeCents` accepts every price in whole cents (the starter's old check,
330
412
  `Math.round(x * 100) === x * 100`, refused $0.07, $0.29, $0.57 and $1.10 because of floating point).
331
413
 
414
+ ## The admin (`/admin`; since 0.2.0)
415
+
416
+ The admin of a game that sells Nanoterra looks for Nano, in the package: one panel for the game, its looks and its orders. It has two parts that never load each other:
417
+ the **server part** (`/admin`: the guard, the routes and a test suite) and the **panel** (`/admin/panel`: the page, drawn in the browser). Neither has Three.js. The package has no
418
+ database and reads no cookie: **the game gives a session and a store**.
419
+
420
+ ```ts
421
+ import { createAdminHandler } from "@nano133/nanoterra/admin";
422
+
423
+ const admin = createAdminHandler({
424
+ store, // your database: readSettings(), saveSettings(patch, now), summary(now) (a memory store is in the package, for tests)
425
+ defaults: { character: "nanoterra", signin: "exchange" }, // what the two switches are until /admin sets them (your game.config.json)
426
+ owner: ownerAddress, // "nano_..." lowercase, its checksum checked by you; null when none is set
427
+ session: (req, now) => readAdminSession(req, now), // YOUR admin login: its own cookie; return { address, createdAt, via: "wallet" } or null
428
+ status: (now, summary) => ({ address: ownerAddress, testMode, warnings }), // what only your game knows (the store's numbers come too, for a warning like "2 payments came late")
429
+ lookName: (id) => looks.find((l) => l.id === id)?.name,
430
+ check: (now) => checkPayments(now), // optional: the payment check
431
+ });
432
+
433
+ const answer = await admin.handle(request); // null when the request is not for /api/admin
434
+ if (answer) return answer;
435
+ ```
436
+
437
+ **The guard** is `adminAccess({ owner, session, now, maxAgeMs? })`, one pure function with the same five checks as the game starter's `adminGuard`, in the same order: the
438
+ game has a real owner address; there is a login; it was made with a wallet signature (a sign-in from Nanoterra names an address but proves no key, so it never opens the
439
+ admin); it is the owner's address; it is not more than a day old. **It only refuses more than the starter does, never less**: a bad owner, a time that is not a number
440
+ or lies more than five minutes in the future, a login word that is not exactly `"wallet"` and a limit that is not a real number are refusals, a limit above one day is cut
441
+ to one day, and a fault inside the guard is a refusal. A test runs the starter's guard and this one over 10,000 inputs and proves that this one never allows what the
442
+ starter refuses.
443
+
444
+ **The handler** refuses a request that says it comes from another page, for a read as for a write: an `Origin` that is not the game's (also `"null"`), or `Sec-Fetch-Site` `cross-site` or `same-site`
445
+ (a sibling subdomain has the game's cookies). The admin API is called only by its own page, so whatever CORS headers a game's router might add, the answer is not for another page. A tool with
446
+ neither header (curl) and the browser's own address bar (`Sec-Fetch-Site: none`) are not foreign.
447
+
448
+ **The handler** sends every request under the base path (`/api/admin` by default) through the guard first, before the route is looked up: a visitor who is not the owner
449
+ gets 401 or 403 for any path, so nothing about the routes leaks. A POST must come from the game's own page (`Origin`, else `Sec-Fetch-Site: same-origin`). It sets no CORS
450
+ header, always sends `Cache-Control: no-store`, reads at most 512 bytes of a press, and limits presses to 30 a minute. A fault in your store gives a plain 500 that shows
451
+ nothing of the fault (`onError` hears it). The only writes are the two settings (and, when the game turns on the sign-in list, the deletion of the admin sessions), **never a look, a price or an order**: a change of looks stays a change of your repository.
452
+
453
+ **Your own routes read the switches** with `loadGameSettings(store, defaults)` (for the listing file's `signin` word and for the character): the stored word, else your default,
454
+ and your defaults when the store fails.
455
+
456
+ **The conformance suite** proves your whole chain, not only the guard: the cookie you read, the session lookup, the owner you pass.
457
+
458
+ ```ts
459
+ import { adminConformance } from "@nano133/nanoterra/admin/conformance";
460
+
461
+ const host = { routes: admin.routes, basePath: admin.basePath, origin: "https://game.example", request: (who, req) => sendAs(who, req) };
462
+ for (const c of adminConformance(host)) it(c.name, c.run); // vitest, node:test or any runner
463
+ ```
464
+
465
+ `sendAs(who, req)` makes a request as one of eleven visitors from YOUR sessions: `signed_out`; `game_login` (a login made for your players, which must NOT open the admin);
466
+ `game_token_as_admin` (the token of that player login put in the ADMIN cookie: your store must keep the two scopes apart, not only the cookie names); `nanoterra_login`;
467
+ `unknown_via_login` (an admin login whose `via` is a word you do not know, like `"ticket"`: it must fail closed); `other_wallet`; `no_address`;
468
+ `expired_login` (made LESS than a day ago and expired by your own store, so only your store can refuse it: a game that ignores its own expiry fails); `signed_out_login` (a login that your own
469
+ sign-out ended); `old_valid_row` (an admin login more than a day old whose stored row is still valid, so only the guard's age check can refuse it); and `owner`. **Support `req.again`:** when it
470
+ is true, send the SAME login as the last request of that kind and do not make a new one. The suite sends `old_valid_row` three times, the last two with `again`, and requires 401 each time: a
471
+ session that renews its creation time on every read is a rolling session, and a stolen cookie would then live for ever.
472
+ The suite checks every route in `admin.routes` for each, and also: an unknown path gets the same refusal; **OPTIONS and HEAD** on every route are refused like any other method and no
473
+ preflight is ever answered; other spellings of EVERY admin path (upper case, a doubled slash, a percent code, a backslash, a dot segment) open nothing with any method; **a request from another
474
+ page** (a foreign Origin, `"null"`, a sibling subdomain, Sec-Fetch-Site cross-site or same-site) is refused on every route with every method, for the owner too, and never gets a CORS header; a POST
475
+ from another site is refused; a method that a route does not have is refused; a body that is too big is refused; and **every answer is `no-store` with no CORS header**, for every method and
476
+ status. A route that someone adds later cannot skip the guard, because the suite walks the route list.
477
+
478
+ **Three more things a host should support**, so that the suite can catch the wirings that a security read invented against it: `host.endLogin(who)` (end the login of the last request of that kind with
479
+ your REAL sign-out route: the suite then sends the same login again and requires a refusal, so a game that keeps sessions in a cache fails); `req.tokenAt` (send a player's login token as a Bearer
480
+ header, in the query or in another header instead of the cookie: a game that also reads a player's token from there fails); and the suite sends every refused visitor again right after the owner was
481
+ served on the same URL (a game that caches answers by URL fails) and uses the login that did "Sign out everywhere" again (it must be refused at once).
482
+
483
+ **The sign-in chain contract** (`adminSigninConformance(host, { signOutEverywhere })`, in the same path) tests the code that MAKES the logins, which the suite above cannot see. Give it a host that drives your own sign-in:
484
+ `start(scope)` (a new browser starts a request for the game or for the admin), `sign(request)` (the owner's wallet signs it; it MUST throw when the signature is refused, so a signature that stops working
485
+ cannot make the contract pass), `finish(request, as, credentials)` (a browser asks to finish it as a scope with its own credentials, as another browser, or with its credentials "retyped" for the other
486
+ scope: a cookie copied to the other scope's name, a value that the other scope's hashing would match), optionally `advance(ms)` (moves the game's clock; the cross-scope asks then repeat 10 and 55 minutes after the start, after the admin's
487
+ request life has ended and inside the game's hour of delivery) and optionally `signOutEverywhere()`. It requires: a request started for the game never makes an admin login, a request started for the admin never makes a game login (a cross-scope
488
+ ask gives NO login of either kind, and the request still signs in its own browser afterwards), each signs in once in its own scope (a request that was collected gives no other browser a login either), a request signs in only the browser that started it (also inside the same
489
+ scope), and Sign out everywhere ends the admin's pending requests, those that are signed and those still waiting for a signature, and leaves a player's request alone. Pass `{ signOutEverywhere: true, advance: true }`
490
+ when your store has the call and your host can move its clock: a host that then has none fails the contract, so the case cannot be skipped by leaving the call out.
491
+
492
+ **List the other routes that your own router serves under the admin base** in `host.otherRoutes` (`{ method, path }`: an old `/api/admin/orders`, a debug route, a route kept for an old page).
493
+ The suite sends each one as every visitor who is not the owner and requires a refusal. It cannot find a route that your router hides from it, so a route that answers BEFORE the handler with no
494
+ guard is a fault only if you list it. **What the suite cannot see:** the code that CREATES admin logins (your wallet sign-in). Test that separately: only a wallet signature started from the
495
+ admin may make an admin login, and never from another sign-in. A security read found these gaps in the first version of this suite, and each one is a test now.
496
+
497
+ ### The store (an adapter for your database)
498
+
499
+ `createAdminHandler` needs a store: three calls, `readSettings()`, `saveSettings(patch, now)` and `summary(now)`. Two adapters come with the package, and **both write only the two settings** (the D1 store also deletes the admin sessions, but only when the game turns on the sign-in list: see below)
500
+ (`setting:character` and `setting:signin`); every other call they make is a read. Both must pass the same **store contract**, `adminStoreConformance(make)` in
501
+ `@nano133/nanoterra/admin/conformance`: the settings are kept and accumulate, saving changes no order, no owned look and no other row, an open order past its expiry counts as expired,
502
+ the money is totalled right, the newest 500 orders come first, and a bad rate row is ignored. A game with another database writes its own store and runs the same contract.
503
+
504
+ ```ts
505
+ import { createD1Store } from "@nano133/nanoterra/admin/d1"; // the game starter's tables: meta, orders, owned (migrations/0001_init.sql)
506
+ const store = createD1Store(env.DB); // Cloudflare's D1Database fits
507
+
508
+ import { createFirestoreStore } from "@nano133/nanoterra/admin/firestore"; // Firestore (the Admin SDK's `Firestore` fits the small interface)
509
+ const store = createFirestoreStore(db); // meta/{key}, orders/{id}, owned/{player__lookId}; the three names can be changed
510
+ ```
511
+
512
+ The D1 adapter uses the starter's own queries (the counts and the totals over all orders; the list is the newest 500). The Firestore adapter reads the newest 5000 orders and every owned look,
513
+ so it is for a small game; its data model is in the file's top comment. **The Firestore adapter was tested against an in-memory fake of the interface, not against real Firestore.** The D1 adapter
514
+ was tested on SQLite with the starter's tables, not on Cloudflare's D1.
515
+
516
+ ### Who signed in, and "Sign out everywhere" (optional)
517
+
518
+ A creator can see who opened the admin lately, and end every admin login at once. Both are optional: they exist only when the game's store has `recentLogins(limit)` and `signOutEverywhere()`.
519
+ With them the panel has a **Sign-ins** card (the last 10 admin sign-ins, newest first: the time, the rough country and a short browser name such as "Chrome on macOS") and a **Sign out
520
+ everywhere** button (two presses: "This ends every admin sign-in, in every browser, this one too"). The handler gets the route `POST {base}/signout-all` (behind the same guard, the same
521
+ origin rule and a limit of 5 a minute). It deletes the admin sessions and **no player session**, and says how many.
522
+
523
+ **What is kept, and for how long.** For each admin sign-in the game keeps three things: the **time**, the **country** that the request's country header gave (two letters, or none) and a **short
524
+ browser name** that the game makes from the browser text (a family and a system from fixed lists: "Chrome on macOS"). It keeps **no address, no IP and no browser text**. The handler sends only those
525
+ three things to the page, whatever the store gives. How long the game keeps them is the game's choice: **30 days is the suggestion**, and the game's `loginsNote` option says it on the card
526
+ (the starter deletes a row older than 30 days). The package cannot write a sign-in: the game writes one when it makes an admin login.
527
+
528
+ With D1: `createD1Store(db, { logins: true })` needs the table `admin_logins (id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, country TEXT, browser TEXT NOT NULL)`, a `scope` column on
529
+ `sessions` (`'admin'` for an admin login) and a `scope` column and a `delivered` column on `signin_requests` ("Sign out everywhere" also marks every admin request that was started and not delivered as
530
+ delivered and ends it (`expires_at = 0`, so the wallet app does not show "Signed in" for it either), so a request that the owner signed a moment ago, or signs a moment later, cannot make a new admin login afterwards). The panel shows a browser name only when it is words (letters and spaces, 40 at most): anything else
531
+ a store gives, such as a raw browser text or an address, is shown as "Unknown browser". Without the option the store has neither call, so a game without those tables is not broken. The store contract has cases for both calls.
532
+ The Firestore store does not have them yet (a game's sessions are its own): a game with Firestore writes its own `recentLogins` and `signOutEverywhere`.
533
+
534
+ ### Later ideas (not built)
535
+
536
+ - **A second signature for a write.** The only server write the admin makes is the sign-in switch (and the Shelf never writes), so asking the owner to sign again for each press adds friction for
537
+ little gain. A later version could ask for it if the admin ever writes more.
538
+ - **An optional passkey for the admin, bound to the domain.** A passkey (WebAuthn) belongs to the domain it was made for, so a sign-in request that a stranger relays to the owner from another
539
+ page or link **cannot be answered with it**: the browser will not use the passkey on another domain. That is the strongest fix for the relay of an admin wallet sign-in. The wallet text names
540
+ the admin now, but a wallet cannot tell where a request started; a passkey can. Later, not now: the owner's wallet signature would stay as the way to make the first admin login or to
541
+ recover, and the passkey would be a second, optional way that cannot be relayed.
542
+
543
+ ### The panel
544
+
545
+ ```ts
546
+ import { mountAdmin } from "@nano133/nanoterra/admin/panel";
547
+ import "@nano133/nanoterra/admin/style.css";
548
+
549
+ mountAdmin(document.getElementById("admin")!, {
550
+ looks, // your looks.json as the game has it now: the Shelf starts from it
551
+ signIn: (into, done) => showWalletSignIn(into, done), // YOUR wallet sign-in for the ADMIN login; call done() when the owner has signed in
552
+ signOut: () => signOutOfAdmin(), // sign out of the ADMIN login only (the game's own login stays)
553
+ defaultsFile: "game.config.json", // optional: where your switches' defaults are written
554
+ });
555
+ ```
556
+
557
+ The panel draws: the **Game** card (the two switches), what is still off (the status warnings), the totals, the **Shelf** (the looks with what each has sold, and a draft you can change),
558
+ **Orders** (with "Paid late" and a "Check payments now" button when the game gave a check), the looks that players own, and Sign out. A visitor who is signed out gets the game's
559
+ sign-in; a wrong wallet, a Nanoterra sign-in and a missing owner address each get their own screen and see no data. Options: `api` (the handler's base path), `noAddressHelp`,
560
+ `lookPicture(look)` (your own picture of a look for the Shelf), `repoHelp`, and `fetch`.
561
+
562
+ **The Shelf's pictures on the real character** (since 0.3.0). The panel has no Three.js, so it takes the pictures from you, lazily:
563
+
564
+ ```ts
565
+ // admin.ts
566
+ import { mountAdmin, previewPicture } from "@nano133/nanoterra/admin/panel";
567
+ mountAdmin(el, { looks, signIn, signOut, lookPicture: previewPicture(() => import("./lookPreview").then((m) => m.render)) });
568
+
569
+ // lookPreview.ts (loaded only when the Shelf shows a look)
570
+ import { createLookPreview } from "@nano133/nanoterra/character";
571
+ const preview = createLookPreview({ body: () => yourDressedCharacter() }); // left out: the package's character, its shirt in the look's colour
572
+ export const render = (look) => preview.render(look);
573
+ ```
574
+
575
+ `previewPicture(load)` shows the flat picture at once and puts the drawn one in its place when it is ready; with no WebGL, or when `load` fails, the
576
+ flat picture stays. `createLookPreview({ size?, body?, record?, background?, key? })` draws each look once with ONE shared offscreen WebGL renderer
577
+ (one context for the whole Shelf; a page may have about 16), one picture at a time, and keeps each picture (a PNG data URL) by the look's text.
578
+ `body()` gives `{ object, dress(look), dispose() }`: your own character dressed in the look (a hat, a model that loads: `dress` may return a promise).
579
+ `dispose()` frees the renderer and its context. The page's Content-Security-Policy must allow `img-src data:`.
580
+
581
+ **The Shelf never writes.** It edits a draft in the owner's browser and gives text to copy (`looks.json`, or a message for an AI assistant): there is no call in the panel that
582
+ changes a look, a price or an order, and a test proves that editing the Shelf sends no request. A change of looks is a change of your repository. The **Game** card writes only the
583
+ two settings. The stylesheet is scoped under `.n133-admin`, so it cannot leak into your page; to theme it set `--n133-ink`, `--n133-ink-soft`, `--n133-paper`, `--n133-line`,
584
+ `--n133-gem`, `--n133-gem-dark`, `--n133-sea`, `--n133-radius` or `--n133-font` on the element that holds it. Every control is at least 40 px high (the order tabs); the switches, the price chips, the inputs and the buttons are 44 px or more. One column on a phone, two from 640 px.
585
+
586
+ The wallet sign-in stays yours (`signIn`), because only your game can start a request in the ADMIN scope with its own cookie. The panel never signs anyone in.
587
+
332
588
  ## Moving from the three old packages
333
589
 
334
590
  | Before | Now |
@@ -344,7 +600,8 @@ The ladder is a suggestion. Any price with at most 2 decimals, up to $1000, is g
344
600
 
345
601
  ## What it does NOT do yet
346
602
 
347
- - Worn items (see above): 0.2.0.
603
+ - The island's item art (see "Worn things and sockets"): a game brings its own things.
604
+ - A pose for a held thing: the arms swing free, so a two-handed thing (a `grip_L` as well as a `grip_R`) is held in the right hand only.
348
605
  - The katana, the saya, the headband and the dojo look of the island's Ananos game. (The skeleton keeps their bones, with no vertices.)
349
606
  - First-person arms, swimming, climbing, sitting and the room's other moves: only idle, walk, run, jump and wave.
350
607
  - Shared geometry for many characters at once, and a react-three-fiber wrapper.
package/ai/SPEC.md CHANGED
@@ -99,12 +99,17 @@ export const LOOKS = checkLooks(rawLooksJson); // throws with the first mistake
99
99
  - Before a change goes live: `[...lookProblems(next), ...removalProblems(current, next, ownedLookIds)]`; show `diffLooks(current, next)`; write the file with `looksJson(next)`.
100
100
  - One free look (`priceUsd: 0`) at least. Never change or remove the id of a look that someone owns. Prices in US dollars with at most 2 decimals; the server reads the price from the file.
101
101
  - A look only changes how the character looks. No random look, no loot box, no look that changes a score.
102
+ - `slots` (optional, at most 8: a name and a word or a `#rrggbb` colour) is where the game keeps its own parts of a look. Read `look.slots` from `checkLooks`. Never change the slots of a look that someone owns: add a new look.
102
103
  - The game is the seller. Never send looks, prices or sales to another site.
103
104
 
104
- **Check:** `checkLooks` passes at start; a removed look that is owned is refused.
105
+ **Check:** `checkLooks` passes at start; a removed look that is owned is refused; a changed slot on an owned look is refused.
105
106
 
106
107
  ## 8. Do not
107
108
 
108
109
  - Do not edit the geometry, the skeleton or the toon and ink materials.
109
- - Do not add worn items, the katana, the headband or other brands' art: 0.1.0 has none of them (a later version adds the item art).
110
+ - Do not add the katana, the headband or other brands' art. A game's own worn model goes on a socket (`c.socket("head" | "hand" | "back")` or
111
+ `c.setWorn([{ socket, object }])`), and is checked first with `checkModel(bytes, HAND_ITEM)` (or `HEAD_ITEM`, `BACK_ITEM`) from `@nano133/nanoterra/model`.
112
+ A hand thing has an empty `grip_R` node where the hand holds it and stays inside 0.96 x 0.68 m, from 1.17 m below that grip to 0.43 m above. A back
113
+ thing gives `hideTail: true` and `hideWings: true`, so a tail or wings do not poke through it.
114
+ The game loads the model and frees it; the character never does.
110
115
  - Do not use a look or a record for a game's rules, a score or a payment: it is display only.
@@ -0,0 +1,27 @@
1
+ import type { AdminStore } from "../store.js";
2
+ /** The part of D1 that the adapter uses. Cloudflare's `D1Database` fits it. */
3
+ export interface D1StatementLike {
4
+ bind(...values: unknown[]): D1StatementLike;
5
+ all<T = unknown>(): Promise<{
6
+ results: T[];
7
+ }>;
8
+ first<T = unknown>(): Promise<T | null>;
9
+ run(): Promise<unknown>;
10
+ }
11
+ export interface D1Like {
12
+ prepare(query: string): D1StatementLike;
13
+ batch(statements: D1StatementLike[]): Promise<unknown[]>;
14
+ }
15
+ export declare const D1_KEYS: {
16
+ readonly character: "setting:character";
17
+ readonly signin: "setting:signin";
18
+ };
19
+ /**
20
+ * The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
21
+ * (`DELETE FROM sessions WHERE scope = 'admin'`, and `UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0` so that a request signed a moment ago
22
+ * cannot make a new login afterwards, and a wallet app that is answered later is told that the request has ended): the game's `sessions` and `signin_requests` tables must have a `scope` column ('admin' for an admin login or request) and the game must write
23
+ * `admin_logins` when it makes an admin login. Without the option the store has neither call, so a game that has not made those tables is not broken by it.
24
+ */
25
+ export declare function createD1Store(db: D1Like, options?: {
26
+ logins?: boolean;
27
+ }): AdminStore;
@@ -0,0 +1,73 @@
1
+ // The admin store for a game on Cloudflare D1, with the tables of the game starter (migrations/0001_init.sql): `meta`, `orders` and `owned`.
2
+ // It writes ONLY the two settings (`setting:character`, `setting:signin` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
3
+ // (`logins: true`): then the one other write is `DELETE FROM sessions WHERE scope = 'admin'` ("sign out everywhere").
4
+ //
5
+ // import { createD1Store } from "@nano133/nanoterra/admin/d1";
6
+ // const store = createD1Store(env.DB);
7
+ import { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers } from "../summary.js";
8
+ export const D1_KEYS = { character: "setting:character", signin: "setting:signin" };
9
+ /**
10
+ * The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
11
+ * (`DELETE FROM sessions WHERE scope = 'admin'`, and `UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0` so that a request signed a moment ago
12
+ * cannot make a new login afterwards, and a wallet app that is answered later is told that the request has ended): the game's `sessions` and `signin_requests` tables must have a `scope` column ('admin' for an admin login or request) and the game must write
13
+ * `admin_logins` when it makes an admin login. Without the option the store has neither call, so a game that has not made those tables is not broken by it.
14
+ */
15
+ export function createD1Store(db, options = {}) {
16
+ const store = {
17
+ async readSettings() {
18
+ const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?)").bind(D1_KEYS.character, D1_KEYS.signin).all()).results;
19
+ const row = (key) => rows.find((r) => r.key === key)?.value;
20
+ return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin) };
21
+ },
22
+ async saveSettings(patch, now) {
23
+ const write = (key, value) => db.prepare("INSERT INTO meta (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at").bind(key, value, now);
24
+ const batch = [];
25
+ if (patch.character)
26
+ batch.push(write(D1_KEYS.character, patch.character));
27
+ if (patch.signin)
28
+ batch.push(write(D1_KEYS.signin, patch.signin));
29
+ if (batch.length)
30
+ await db.batch(batch);
31
+ },
32
+ async summary(now) {
33
+ // The game starter's own queries: the counts and the totals are over ALL orders, the list is the newest ORDERS_SHOWN.
34
+ const orders = (await db
35
+ .prepare("SELECT id, player, look_id, usd, amount, status, created_at, expires_at, paid_at, payer, hash FROM orders ORDER BY created_at DESC LIMIT ?")
36
+ .bind(ORDERS_SHOWN)
37
+ .all()).results;
38
+ const owned = (await db.prepare("SELECT player, look_id, granted_at FROM owned ORDER BY player, granted_at").all()).results;
39
+ const sums = (await db.prepare("SELECT status, amount, usd FROM orders WHERE status IN ('paid', 'paid_late')").all()).results;
40
+ const grouped = (await db.prepare("SELECT status, expires_at < ? AS past, COUNT(*) AS n FROM orders GROUP BY status, past").bind(now).all()).results;
41
+ const rate = await db.prepare("SELECT value, at FROM meta WHERE key = 'rate'").first();
42
+ const counts = { open: 0, paid: 0, expired: 0, paid_late: 0 };
43
+ for (const c of grouped) {
44
+ const key = c.status === "open" && c.past ? "expired" : c.status;
45
+ if (Object.hasOwn(counts, key))
46
+ counts[key] += c.n;
47
+ }
48
+ const money = (status) => moneyOf(sums.filter((s) => s.status === status).map((s) => ({ amountRaw: s.amount, usd: s.usd })));
49
+ const rows = orders.map((o) => ({ id: o.id, player: o.player, lookId: o.look_id, usd: o.usd, amountRaw: o.amount, status: o.status, createdAt: o.created_at, expiresAt: o.expires_at, paidAt: o.paid_at, payer: o.payer, hash: o.hash }));
50
+ return {
51
+ counts,
52
+ totals: { paid: money("paid"), paidLate: money("paid_late") },
53
+ orders: shapeOrders(rows, now),
54
+ players: shapePlayers(owned.map((o) => ({ player: o.player, lookId: o.look_id, at: o.granted_at }))),
55
+ rate: parseRate(rate?.value, rate?.at),
56
+ };
57
+ },
58
+ };
59
+ if (options.logins) {
60
+ store.recentLogins = async (limit) => {
61
+ const n = Math.min(Math.max(Math.floor(Number(limit)) || 0, 0), 50);
62
+ const rows = (await db.prepare("SELECT at, country, browser FROM admin_logins ORDER BY at DESC, id DESC LIMIT ?").bind(n).all()).results;
63
+ return rows.map((r) => ({ at: r.at, country: r.country, browser: r.browser }));
64
+ };
65
+ // One batch: the admin sessions are deleted, AND every admin sign-in request that was started but not delivered is marked delivered, so a request that the owner signed a moment
66
+ // ago cannot make a new admin login after "Sign out everywhere" (its check would answer "used").
67
+ store.signOutEverywhere = async () => {
68
+ const done = (await db.batch([db.prepare("DELETE FROM sessions WHERE scope = 'admin'"), db.prepare("UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0")]));
69
+ return done[0]?.meta?.changes ?? 0;
70
+ };
71
+ }
72
+ return store;
73
+ }