@nano133/nanoterra 0.0.0-stage → 0.2.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.
- package/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +548 -2
- package/ai/PROMPT.md +16 -0
- package/ai/SPEC.md +111 -0
- package/dist/admin/adapters/d1.d.ts +27 -0
- package/dist/admin/adapters/d1.js +73 -0
- package/dist/admin/adapters/firestore.d.ts +40 -0
- package/dist/admin/adapters/firestore.js +53 -0
- package/dist/admin/amount.d.ts +5 -0
- package/dist/admin/amount.js +11 -0
- package/dist/admin/conformance.d.ts +132 -0
- package/dist/admin/conformance.js +467 -0
- package/dist/admin/guard.d.ts +38 -0
- package/dist/admin/guard.js +57 -0
- package/dist/admin/handler.d.ts +63 -0
- package/dist/admin/handler.js +170 -0
- package/dist/admin/http.d.ts +17 -0
- package/dist/admin/http.js +76 -0
- package/dist/admin/index.d.ts +13 -0
- package/dist/admin/index.js +14 -0
- package/dist/admin/memory.d.ts +32 -0
- package/dist/admin/memory.js +40 -0
- package/dist/admin/panel/api.d.ts +11 -0
- package/dist/admin/panel/api.js +33 -0
- package/dist/admin/panel/dom.d.ts +7 -0
- package/dist/admin/panel/dom.js +33 -0
- package/dist/admin/panel/gameCard.d.ts +4 -0
- package/dist/admin/panel/gameCard.js +56 -0
- package/dist/admin/panel/index.d.ts +10 -0
- package/dist/admin/panel/index.js +13 -0
- package/dist/admin/panel/mount.d.ts +30 -0
- package/dist/admin/panel/mount.js +147 -0
- package/dist/admin/panel/shelf.d.ts +17 -0
- package/dist/admin/panel/shelf.js +247 -0
- package/dist/admin/panel/shelfData.d.ts +37 -0
- package/dist/admin/panel/shelfData.js +68 -0
- package/dist/admin/panel/signins.d.ts +6 -0
- package/dist/admin/panel/signins.js +69 -0
- package/dist/admin/store.d.ts +121 -0
- package/dist/admin/store.js +3 -0
- package/dist/admin/summary.d.ts +39 -0
- package/dist/admin/summary.js +85 -0
- package/dist/admin/switches.d.ts +58 -0
- package/dist/admin/switches.js +61 -0
- package/dist/character/assemble.d.ts +17 -0
- package/dist/character/assemble.js +39 -0
- package/dist/character/bases/bee.d.ts +2 -0
- package/dist/character/bases/bee.js +39 -0
- package/dist/character/bases/classic.d.ts +4 -0
- package/dist/character/bases/classic.js +28 -0
- package/dist/character/bases/critter.d.ts +2 -0
- package/dist/character/bases/critter.js +43 -0
- package/dist/character/bases/index.d.ts +5 -0
- package/dist/character/bases/index.js +8 -0
- package/dist/character/bases/pineapple.d.ts +2 -0
- package/dist/character/bases/pineapple.js +43 -0
- package/dist/character/bases/robot.d.ts +2 -0
- package/dist/character/bases/robot.js +33 -0
- package/dist/character/bases/round.d.ts +2 -0
- package/dist/character/bases/round.js +65 -0
- package/dist/character/bases/shared.d.ts +17 -0
- package/dist/character/bases/shared.js +17 -0
- package/dist/character/bases/spec.d.ts +17 -0
- package/dist/character/bases/spec.js +1 -0
- package/dist/character/build.d.ts +65 -0
- package/dist/character/build.js +223 -0
- package/dist/character/clothes.d.ts +21 -0
- package/dist/character/clothes.js +193 -0
- package/dist/character/color.d.ts +2 -0
- package/dist/character/color.js +9 -0
- package/dist/character/create.d.ts +58 -0
- package/dist/character/create.js +164 -0
- package/dist/character/ctx.d.ts +97 -0
- package/dist/character/ctx.js +1 -0
- package/dist/character/edit.d.ts +11 -0
- package/dist/character/edit.js +73 -0
- package/dist/character/face.d.ts +15 -0
- package/dist/character/face.js +181 -0
- package/dist/character/fit.d.ts +12 -0
- package/dist/character/fit.js +66 -0
- package/dist/character/hair.d.ts +4 -0
- package/dist/character/hair.js +139 -0
- package/dist/character/index.d.ts +36 -0
- package/dist/character/index.js +25 -0
- package/dist/character/kit.d.ts +16 -0
- package/dist/character/kit.js +47 -0
- package/dist/character/limbs.d.ts +30 -0
- package/dist/character/limbs.js +45 -0
- package/dist/character/lists.d.ts +63 -0
- package/dist/character/lists.js +59 -0
- package/dist/character/look.d.ts +27 -0
- package/dist/character/look.js +75 -0
- package/dist/character/make.d.ts +53 -0
- package/dist/character/make.js +64 -0
- package/dist/character/motion.d.ts +7 -0
- package/dist/character/motion.js +95 -0
- package/dist/character/pose.d.ts +42 -0
- package/dist/character/pose.js +98 -0
- package/dist/character/pure.d.ts +4 -0
- package/dist/character/pure.js +7 -0
- package/dist/character/record.d.ts +137 -0
- package/dist/character/record.js +190 -0
- package/dist/character/skeleton.d.ts +15 -0
- package/dist/character/skeleton.js +77 -0
- package/dist/character/solver.d.ts +36 -0
- package/dist/character/solver.js +145 -0
- package/dist/character/toon.d.ts +16 -0
- package/dist/character/toon.js +95 -0
- package/dist/character/torso.d.ts +8 -0
- package/dist/character/torso.js +9 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +8 -0
- package/dist/looks/change.d.ts +24 -0
- package/dist/looks/change.js +63 -0
- package/dist/looks/index.d.ts +7 -0
- package/dist/looks/index.js +4 -0
- package/dist/looks/json.d.ts +8 -0
- package/dist/looks/json.js +37 -0
- package/dist/looks/ladder.d.ts +14 -0
- package/dist/looks/ladder.js +24 -0
- package/dist/looks/look.d.ts +62 -0
- package/dist/looks/look.js +113 -0
- package/dist/ticket/index.d.ts +99 -0
- package/dist/ticket/index.js +191 -0
- package/package.json +46 -4
- package/src/admin/adapters/d1.ts +94 -0
- package/src/admin/adapters/firestore.ts +78 -0
- package/src/admin/amount.ts +14 -0
- package/src/admin/conformance.ts +605 -0
- package/src/admin/guard.ts +86 -0
- package/src/admin/handler.ts +200 -0
- package/src/admin/http.ts +74 -0
- package/src/admin/index.ts +21 -0
- package/src/admin/memory.ts +59 -0
- package/src/admin/panel/api.ts +37 -0
- package/src/admin/panel/dom.ts +31 -0
- package/src/admin/panel/gameCard.ts +63 -0
- package/src/admin/panel/index.ts +18 -0
- package/src/admin/panel/mount.ts +227 -0
- package/src/admin/panel/shelf.ts +337 -0
- package/src/admin/panel/shelfData.ts +87 -0
- package/src/admin/panel/signins.ts +68 -0
- package/src/admin/panel/style.css +542 -0
- package/src/admin/store.ts +98 -0
- package/src/admin/summary.ts +103 -0
- package/src/admin/switches.ts +74 -0
- package/src/character/assemble.ts +44 -0
- package/src/character/bases/bee.ts +42 -0
- package/src/character/bases/classic.ts +30 -0
- package/src/character/bases/critter.ts +44 -0
- package/src/character/bases/index.ts +12 -0
- package/src/character/bases/pineapple.ts +45 -0
- package/src/character/bases/robot.ts +34 -0
- package/src/character/bases/round.ts +69 -0
- package/src/character/bases/shared.ts +25 -0
- package/src/character/bases/spec.ts +18 -0
- package/src/character/build.ts +244 -0
- package/src/character/clothes.ts +204 -0
- package/src/character/color.ts +10 -0
- package/src/character/create.ts +214 -0
- package/src/character/ctx.ts +95 -0
- package/src/character/edit.ts +78 -0
- package/src/character/face.ts +176 -0
- package/src/character/fit.ts +78 -0
- package/src/character/hair.ts +142 -0
- package/src/character/index.ts +29 -0
- package/src/character/kit.ts +51 -0
- package/src/character/limbs.ts +78 -0
- package/src/character/lists.ts +77 -0
- package/src/character/look.ts +85 -0
- package/src/character/make.ts +82 -0
- package/src/character/motion.ts +100 -0
- package/src/character/pose.ts +132 -0
- package/src/character/pure.ts +7 -0
- package/src/character/record.ts +265 -0
- package/src/character/skeleton.ts +82 -0
- package/src/character/solver.ts +160 -0
- package/src/character/toon.ts +104 -0
- package/src/character/torso.ts +13 -0
- package/src/index.ts +12 -0
- package/src/looks/change.ts +65 -0
- package/src/looks/index.ts +7 -0
- package/src/looks/json.ts +36 -0
- package/src/looks/ladder.ts +35 -0
- package/src/looks/look.ts +131 -0
- package/src/ticket/index.ts +241 -0
package/README.md
CHANGED
|
@@ -1,3 +1,549 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nano133/nanoterra
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Stage: Pre-alpha. What this means: https://nano133.com/stages
|
|
4
|
+
|
|
5
|
+
Nanoterra for your game, in **one package**. [Nanoterra](https://nano133.com/build/nanoterra) is
|
|
6
|
+
nano133.com's island. A player who is signed in there presses Play, and arrives at your game as
|
|
7
|
+
themselves. This package gives your game three things:
|
|
8
|
+
|
|
9
|
+
- **The sign-in ticket** (`/ticket`): the player's name, Nano address and character, read in the browser. No server, no secret.
|
|
10
|
+
- **The player's character** (`/character`): the same toon body that the player has on the island, drawn with Three.js. Six bases, faces, hair, clothes, sizes and colours.
|
|
11
|
+
- **The looks ladder** (`/looks`): the rules of `looks.json`, the price steps, and what a change does to the players who own a look.
|
|
12
|
+
|
|
13
|
+
It replaces `@nano133/character` 0.1.0, `@nano133/ticket` 0.1.0 and `@nano133/looks` 0.1.0
|
|
14
|
+
(see "Moving from the three old packages"). Sign-in, checkout and late returns stay in their own packages
|
|
15
|
+
([`@nano133/signin`](../signin), [`@nano133/checkout`](../checkout), [`@nano133/late-returns`](../late-returns)).
|
|
16
|
+
|
|
17
|
+
nano133.com's island and its 3D room draw the player with **this same code**: the geometry, the skeleton, the colours and the record
|
|
18
|
+
are one source. A game made with this package shows the character exactly as the island does.
|
|
19
|
+
|
|
20
|
+
Pre-alpha means: the shape of the API may still change, and the character has not been run on a real phone yet.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install @nano133/nanoterra three
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`three` is an **optional** peer dependency (`>=0.174.0`). A game that only reads a ticket does not need it.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
A page with a Three.js scene. The player arrives from Nanoterra, signed in; any other visitor is a guest.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { readTicket } from "@nano133/nanoterra/ticket";
|
|
36
|
+
import { createCharacter } from "@nano133/nanoterra/character";
|
|
37
|
+
|
|
38
|
+
const result = await readTicket(); // the ticket in the address, checked; { ok: false } is a guest (ask "Sign in as <name>? Continue / Not me" first)
|
|
39
|
+
const hero = createCharacter(result.ok ? { record: result.ticket.record, look: result.ticket.look } : {}); // their character, or the island's first look
|
|
40
|
+
scene.add(hero.object); // feet at y = 0, facing +Z, about 2.29 m tall; move it with position and rotation.y
|
|
41
|
+
renderer.setAnimationLoop(() => {
|
|
42
|
+
hero.update(clock.getDelta(), { speed: 3.75 }); // idle, walk (3.75 m/s), run (6.5), jump: your numbers; dt in seconds
|
|
43
|
+
renderer.render(scene, camera);
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That is the whole of it. Add `hero.wave()`, `hero.setLook(next)` and `hero.dispose()` as the game needs them.
|
|
48
|
+
|
|
49
|
+
**The one switch.** The [game starter](https://github.com/nano133-com/nano-game-starter) keeps one switch in one file:
|
|
50
|
+
`game.config.json` says `"character": "nanoterra"` (the player is their Nanoterra character: the default) or `"own"` (the game's own
|
|
51
|
+
character). Nothing else reads it. If you do not use the starter, make your own switch where it suits your game.
|
|
52
|
+
|
|
53
|
+
## The paths
|
|
54
|
+
|
|
55
|
+
Each path loads only what it needs. A page that reads a ticket loads no Three.js.
|
|
56
|
+
|
|
57
|
+
| Path | What it is | Loads Three.js | Size (minified, gzip) |
|
|
58
|
+
|---|---|---|---|
|
|
59
|
+
| `@nano133/nanoterra` | the types `Look` and `LookRecord`, and nothing that runs | no | 0 |
|
|
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
|
+
| `@nano133/nanoterra/ticket` | read and check a sign-in ticket | no | 4.7 kB (all), 3.1 kB (`readTicket` alone) |
|
|
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) |
|
|
64
|
+
| `@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 |
|
|
65
|
+
| `@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 |
|
|
66
|
+
| `@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 | 8.5 kB + 2.4 kB of CSS |
|
|
67
|
+
| `@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 |
|
|
68
|
+
|
|
69
|
+
Measured 2026-10-26: `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
## The character
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { createCharacter, HEIGHT, RADIUS } from "@nano133/nanoterra/character";
|
|
76
|
+
|
|
77
|
+
const c = createCharacter({ record, quality: "high" });
|
|
78
|
+
scene.add(c.object);
|
|
79
|
+
c.update(dt, { speed, airborne, verticalSpeed });
|
|
80
|
+
c.wave();
|
|
81
|
+
c.setLook(otherRecord); // or five colours
|
|
82
|
+
c.setColors({ shirt: "#c8461a" }); // any colour, over the record's
|
|
83
|
+
c.dispose();
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
| Call | What it does |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `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
|
+
| `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). |
|
|
90
|
+
| `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". |
|
|
91
|
+
| `c.wave()` | Waves the left arm for 1.8 s (`WAVE_SECONDS`), as the island does. |
|
|
92
|
+
| `c.setLook(next)` | `next` is a record or five colours. A record with the same body only repaints; another base, hair, face or clothes builds new geometry (the old is freed) and the walk carries on. Five colours go into the record that is there (its base, face and clothes stay). A bad value throws and changes nothing. |
|
|
93
|
+
| `c.setColors(colors \| null)` | Paints these colours over the record's; `null` gives the record's own back. No new geometry. |
|
|
94
|
+
| `c.record`, `c.look` | The record now (a frozen copy), and its five colours (the older view). |
|
|
95
|
+
| `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`. |
|
|
96
|
+
| `c.dispose()` | Frees the geometries, materials and textures that the character made. The other calls do nothing after it. |
|
|
97
|
+
|
|
98
|
+
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`.
|
|
99
|
+
|
|
100
|
+
### Motion
|
|
101
|
+
|
|
102
|
+
`speed` is the speed over the ground in metres a second (not a velocity: the direction is `object.rotation.y`, which you set). It is eased, as on the island.
|
|
103
|
+
|
|
104
|
+
| `speed` | What shows |
|
|
105
|
+
|---|---|
|
|
106
|
+
| 0 to 0.25 | Idle: arms 0.2 rad out, elbows a little bent. |
|
|
107
|
+
| 0.25 and more | A walk: legs and arms swing against each other, the hips bob and lean. The swing is full at 3 m/s. The island walks at 3.75 m/s. |
|
|
108
|
+
| 3.4 to 5 | The walk blends into a run: more swing, more bend at the elbows, more lean. The island runs at 6.5 m/s. |
|
|
109
|
+
| 9 and more | The same as 9. |
|
|
110
|
+
|
|
111
|
+
`airborne: true` shows the jump pose at once, whatever the speed: arms out, legs apart. `verticalSpeed` (metres a second, up is positive)
|
|
112
|
+
sizes the shadow: it adds up while the character is airborne to an estimate of the height over the take-off place. The shadow disc stays on the
|
|
113
|
+
ground (it moves down as the body rises, in the character's own space) and shrinks to 0.55 of its size, as on the island. A character made
|
|
114
|
+
smaller or larger by `object.scale` should be given `speed / scale` and `verticalSpeed / scale`. A base with a tail, wings or long hair moves them too.
|
|
115
|
+
|
|
116
|
+
### The record
|
|
117
|
+
|
|
118
|
+
A record is the whole character as whole numbers, each an index into a **closed list**. There is no free text, no URL and no colour string,
|
|
119
|
+
so there is nothing to moderate and nothing to inject.
|
|
120
|
+
|
|
121
|
+
| Field | What it picks |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `base` | one of six: `classic`, `round`, `critter`, `bee`, `robot`, `pineapple` |
|
|
124
|
+
| `skin`, `hair`, `shirt`, `pants`, `shoeColor`, `trim`, `visor` | a colour from that part's list (the body's list depends on the base) |
|
|
125
|
+
| `hairStyle`, `face`, `brows`, `blush`, `freckles`, `glasses` | the head |
|
|
126
|
+
| `top`, `bottom`, `shoes`, `pattern` | the clothes (a base with its own body, such as the Bee, wears none) |
|
|
127
|
+
| `height`, `build`, `head` | the sizes (a scale; the build and the head size change the shape) |
|
|
128
|
+
|
|
129
|
+
It packs into **two whole numbers** (`packRecord`: 28 and 30 bits) for a wire, a ticket or a database; `unpackRecord(a, b)` gives the record back, or
|
|
130
|
+
`null` when a number is not whole, is out of its bits or is off a list. `recordProblem(x)` says in one sentence what is wrong with a record,
|
|
131
|
+
`clean(x)` puts a wrong field back to its default, and `diceRecord(rand)` makes a random one. A record of zeros is the island's first avatar.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { fromLegacy, toLegacy, packRecord, unpackRecord, diceRecord, baseRecord } from "@nano133/nanoterra/character/record";
|
|
135
|
+
|
|
136
|
+
const rec = fromLegacy(ticket.look); // five colours (a ticket from before the record) to a record: a Classic in those colours
|
|
137
|
+
toLegacy(rec); // the record's five colours again: every one of the 8,192 old looks comes back whole (a test holds it)
|
|
138
|
+
const [a, b] = packRecord(rec); // two numbers
|
|
139
|
+
unpackRecord(a, b); // the record, or null
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The five colours, `Look`, are `{ skin, hair, shirt, pants, shoes }`, each `"#rrggbb"`. `LOOK_PARTS` lists the colours the island offers
|
|
143
|
+
(8 skins, 8 hairs, 8 shirts, 4 pants, 4 shoes), `defaultLook()` is what a new player starts with, and `isLook(x)`, `lookProblem(x)` and `assertLook(x)` check one.
|
|
144
|
+
|
|
145
|
+
### Units, size and the collider
|
|
146
|
+
|
|
147
|
+
The model is built in metres, at the size of the island's avatar.
|
|
148
|
+
|
|
149
|
+
| | |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `HEIGHT` = 2.29 m | From the soles (they dip 0.008 m under y = 0) to the top of the hair, for the default Classic at its normal size. Another base, hair or size is not this tall: measure with `new THREE.Box3().setFromObject(c.object)`. |
|
|
152
|
+
| `RADIUS` = 0.49 m | The Classic's trunk: the widest part of the body without the arms. Use it as the radius of a cylinder or capsule collider. |
|
|
153
|
+
| Facing | +Z. `object.rotation.y = Math.atan2(dx, dz)` turns it to walk along (dx, dz). |
|
|
154
|
+
| Its left | +X (the character's own left hand is at +X). |
|
|
155
|
+
|
|
156
|
+
The body is **display only**. A game decides its own collider, jump and reach: use the same numbers for every base and size, so that
|
|
157
|
+
a player's choice of body never changes a rule. nano133.com's island and room do the same. A size changes a scale of the drawing (`height`) and nothing else.
|
|
158
|
+
|
|
159
|
+
### Draws, quality, speed and size
|
|
160
|
+
|
|
161
|
+
| | `quality: "high"` | `quality: "low"` |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| Draws | 2 (toon body with its shadow disc, ink outline) | 1 (toon body with its shadow disc) |
|
|
164
|
+
| What it costs | | No outline: the character reads flatter against a background of its own colours. |
|
|
165
|
+
| What it saves | | One draw and the skinned ink vertices each frame. |
|
|
166
|
+
|
|
167
|
+
`"low"` keeps every shape and colour: only the ink pass goes. Choose it for slow phones. It is fixed when the character is made.
|
|
168
|
+
|
|
169
|
+
- Each character builds its own geometry. A scene with many characters of the same look should share it; this package does not yet.
|
|
170
|
+
- The meshes are not frustum-culled by their bones (they move every frame): the package gives each a fixed bound. Set `c.object.visible = false` for a character that is far from the camera or off screen.
|
|
171
|
+
- 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`.
|
|
172
|
+
- 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.
|
|
173
|
+
|
|
174
|
+
## Worn items (the next version)
|
|
175
|
+
|
|
176
|
+
**Versions 0.1.0 and 0.2.0 draw 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
|
|
177
|
+
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**,
|
|
178
|
+
`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
|
|
179
|
+
may bring its own, and the island brings its adapter the same way.
|
|
180
|
+
|
|
181
|
+
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
|
|
182
|
+
that a game can draw them with one call. Until then a game may ignore the ids or draw its own things.
|
|
183
|
+
|
|
184
|
+
## The sign-in ticket
|
|
185
|
+
|
|
186
|
+
`@nano133/nanoterra/ticket` reads and checks a sign-in ticket in the player's browser. It is for a game on a **static site**: no server, no secret.
|
|
187
|
+
A player who is signed in at nano133.com presses Play in Nanoterra, agrees once on a card, and arrives at your game with a signed ticket after
|
|
188
|
+
the `#` of its address. This package checks the ticket and gives you the player's **username**, **Nano address** and **character** (the five colours,
|
|
189
|
+
the whole record, and the cosmetics the player owns).
|
|
190
|
+
|
|
191
|
+
If your game has a server, use the code exchange instead (the [game starter](https://github.com/nano133-com/nano-game-starter) does): a server that
|
|
192
|
+
holds a secret can know who is at the other end. A ticket cannot.
|
|
193
|
+
|
|
194
|
+
`readTicket()` does four things, in this order:
|
|
195
|
+
|
|
196
|
+
1. Finds `#nanoterra=<ticket>` in the page's address. With none, it answers `{ ok: false, reason: "none" }` and does nothing else (no network call).
|
|
197
|
+
2. **Takes the ticket out of the address bar** (`history.replaceState`), so a reload, a copied link, a screenshot or a bookmark never carries it.
|
|
198
|
+
3. Reads the public keys from `https://nano133.com/api/nanoterra/keys` (once for the session, and again if a ticket names a key it does not know; it waits 5 seconds at most, then the player is a guest).
|
|
199
|
+
4. Checks the signature, the kind, that the ticket is **for this origin** and that it has not run out. It never throws.
|
|
200
|
+
|
|
201
|
+
Call it once, when the page loads.
|
|
202
|
+
|
|
203
|
+
### Rules
|
|
204
|
+
|
|
205
|
+
- **A ticket proves nothing to your server.** The ticket sits in an address bar: anyone who sees it can use it for two minutes. Use it to show the
|
|
206
|
+
player's name and character. **Never give a sale, a prize or a score on a ticket alone.**
|
|
207
|
+
- **Ask before you treat the player as that name.** Anyone can send a link that holds their *own* ticket (login CSRF). Show "Sign in as *name*? Continue / Not me"
|
|
208
|
+
and keep the player a guest until Continue.
|
|
209
|
+
- **Show the name** wherever the player is "signed in", and say whose account it is before a payment.
|
|
210
|
+
- **The username comes from another site.** Put it in the page as text, never as HTML.
|
|
211
|
+
- A guest is a normal visit. Every refusal (`reason`) means "play as a guest".
|
|
212
|
+
|
|
213
|
+
### What you get
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
type Ticket = {
|
|
217
|
+
username: string;
|
|
218
|
+
address: string; // the whole Nano address; also the player's id
|
|
219
|
+
look: { skin: string; hair: string; shirt: string; pants: string; shoes: string }; // "#rrggbb": every ticket has these
|
|
220
|
+
record?: LookRecord; // the whole character, when the ticket carries one (see below); a ticket from before it has no `record` at all
|
|
221
|
+
items: string[]; // the Nanoterra cosmetics the player owns, in the character's slots
|
|
222
|
+
game: string; // your game's id at nano133.com
|
|
223
|
+
audience: string; // the origin the ticket is for
|
|
224
|
+
issuedAt: number; // ms since 1970
|
|
225
|
+
expiresAt: number;
|
|
226
|
+
id: string;
|
|
227
|
+
};
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`record` is read with care: the ticket's claim is two numbers, and it is kept only when both are whole numbers on the closed lists (`unpackRecord`).
|
|
231
|
+
A ticket without a record, or with one that is not whole, is still a good ticket and reads **exactly as the `@nano133/ticket` 0.1.0 ticket did**: the field is not there.
|
|
232
|
+
`createCharacter({ record: ticket.record, look: ticket.look })` takes both.
|
|
233
|
+
|
|
234
|
+
### Refusals
|
|
235
|
+
|
|
236
|
+
| `reason` | Meaning |
|
|
237
|
+
|---|---|
|
|
238
|
+
| `none` | no ticket in the address: a plain visit |
|
|
239
|
+
| `unsupported` | this browser has no Ed25519 in Web Crypto (Chrome 137, Safari 17, Firefox 129 and later have it) |
|
|
240
|
+
| `malformed` | not a ticket |
|
|
241
|
+
| `keys` | the public keys could not be read (offline, or nano133.com did not answer) |
|
|
242
|
+
| `unknown-key`, `signature` | signed with a key that is not nano133.com's, or the signature does not hold |
|
|
243
|
+
| `wrong-kind` | signed right, but not a version 1 ticket from nano133.com |
|
|
244
|
+
| `audience` | a ticket for another origin |
|
|
245
|
+
| `expired`, `early`, `life` | the time does not hold. A player's clock may be a minute off, either way |
|
|
246
|
+
|
|
247
|
+
### Options
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
readTicket({
|
|
251
|
+
keys, // pin the public keys: no fetch at all
|
|
252
|
+
keysUrl, // another place for the keys (tests)
|
|
253
|
+
keysTimeoutMs, // how long to wait for the keys; default 5,000 (a slow nano133.com never holds your first screen)
|
|
254
|
+
audience, // the origin to expect; default: this page's own
|
|
255
|
+
});
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`verifyTicket(token, { audience, keys })` checks a ticket you already hold, and `fetchKeys()`, `ticketFromHash(hash)` and `hashWithoutTicket(hash)` are the parts.
|
|
259
|
+
|
|
260
|
+
### The format
|
|
261
|
+
|
|
262
|
+
For any language. A ticket is `base64url(JSON claims) "." base64url(signature)`: an Ed25519 signature over the ASCII of the first part, made with the key whose `kid` the claims name.
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
{ "v": 1, "typ": "ticket", "iss": "nano133.com", "kid": "fe812c12f3ab4ce6", "aud": "https://game.example",
|
|
266
|
+
"game": "gem-island", "sub": "nano_3i7m…", "name": "Ann",
|
|
267
|
+
"look": { "skin": "#f2c29b", "hair": "#3a2a1e", "shirt": "#2a9df4", "pants": "#1e2a4a", "shoes": "#ffffff" },
|
|
268
|
+
"record": { "v": 2, "k": [2359336, 1642] },
|
|
269
|
+
"items": ["hub.sun-hat"], "iat": 1800000000, "exp": 1800000120, "jti": "AAAAAAAAAAAAAAAA" }
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`record` is optional: `k` is the two numbers of `packRecord`. `GET https://nano133.com/api/nanoterra/keys` answers
|
|
273
|
+
`{ "keys": [{ "kid", "kty": "OKP", "crv": "Ed25519", "x", "alg": "EdDSA", "use": "sig" }] }` and may be read from any page.
|
|
274
|
+
A ticket lives two minutes (`exp - iat` is 120; a longer life is refused).
|
|
275
|
+
|
|
276
|
+
### Turn it on
|
|
277
|
+
|
|
278
|
+
Your `nano-game.json` says `"signin": "ticket"`. Nanoterra's Play key then sends signed-in players to your play address with a ticket.
|
|
279
|
+
A player who has not agreed, or who plays as a guest, arrives with no ticket.
|
|
280
|
+
|
|
281
|
+
## The looks ladder
|
|
282
|
+
|
|
283
|
+
`@nano133/nanoterra/looks` is the code behind the **Shelf** screen in the game starter's `/admin`. Use it in any game that sells looks for Nano (XNO).
|
|
284
|
+
It has no dependencies, runs in a Worker, a browser and Node, and never makes a network call. **It never takes part in a sale:** nothing here sends a price, a sale or a
|
|
285
|
+
player to nano133.com. Your game is the seller.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { checkLooks, removalProblems, looksJson, PRICE_LADDER } from "@nano133/nanoterra/looks";
|
|
289
|
+
|
|
290
|
+
const looks = checkLooks(JSON.parse(fileText)); // throws an Error with the first mistake in plain words
|
|
291
|
+
const problems = removalProblems(looks, newLooks, ownedLookIds); // a look that a player owns must stay
|
|
292
|
+
const text = looksJson(newLooks); // the new looks.json: one look on each line
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
(A shop look here, `Look` of `/looks`, is a thing a player can buy. It is not the five colours `Look` of the small part: they live on different paths.)
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
type Look = {
|
|
299
|
+
id: string; // 1 to 40 lowercase letters, digits or dashes: "mint-cap"
|
|
300
|
+
name: string; // 1 to 40 characters
|
|
301
|
+
priceUsd: number; // US dollars, at most 2 decimals; 0 is the free look
|
|
302
|
+
change: { body: string; hat?: "cap" | "beanie" | "crown" | "tophat"; hatColor?: string }; // colours are "#rrggbb"
|
|
303
|
+
slots?: Record<string, string>; // what YOUR game defines for itself: see "Slots" below. Optional.
|
|
304
|
+
};
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Slots: what your game defines on a look
|
|
308
|
+
|
|
309
|
+
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:
|
|
310
|
+
|
|
311
|
+
```json
|
|
312
|
+
{ "id": "ember-wings", "name": "Ember wings", "priceUsd": 0.25, "change": { "body": "#ff7a3d" }, "slots": { "wings": "ember", "trail": "#ffcf5a" } }
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
- A **name** is 1 to 24 lowercase letters, digits or dashes, starting with a letter (`SLOT_NAME_PATTERN`).
|
|
316
|
+
- 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
|
|
317
|
+
a number writes it as a word it understands (`"size": "large"`).
|
|
318
|
+
- **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`).
|
|
319
|
+
- **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.
|
|
320
|
+
- 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`.
|
|
321
|
+
- Your game reads `look.slots` from the checked looks; the package gives them no meaning.
|
|
322
|
+
- `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
|
|
323
|
+
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).
|
|
324
|
+
- **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`
|
|
325
|
+
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
|
|
326
|
+
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
|
|
327
|
+
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`).
|
|
328
|
+
- 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.
|
|
329
|
+
A name used twice, a bad name and a bad value are shown before anything can be copied.
|
|
330
|
+
|
|
331
|
+
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.
|
|
332
|
+
|
|
333
|
+
The rules (the same as the starter's `AGENTS.md`):
|
|
334
|
+
|
|
335
|
+
- A look only changes how the character looks. **It never changes a score, a speed or a rule.**
|
|
336
|
+
- Every look shows its price before the player buys. **Nothing is random: no loot boxes, no mystery looks.**
|
|
337
|
+
- The list has **at least one free look** (`priceUsd: 0`): every player starts with it.
|
|
338
|
+
- **Never change or remove the `id` of a look that someone owns.** Owned looks are kept by id. To stop selling a look, keep it in the file.
|
|
339
|
+
|
|
340
|
+
| | |
|
|
341
|
+
|---|---|
|
|
342
|
+
| `lookProblems(raw)` | Every mistake in a list of looks, as `{ look, field, message }`. An empty array is a good list. |
|
|
343
|
+
| `checkLooks(raw)` | The looks, or an `Error` with the first mistake in plain words. |
|
|
344
|
+
| `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. |
|
|
345
|
+
| `diffLooks(before, after)` | `{ added, removed, changed }` by id. A new order is not a change. |
|
|
346
|
+
| `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. |
|
|
347
|
+
| `slugId(name, taken)` | A free id for a name: `"Mint Cap"` gives `mint-cap`, then `mint-cap-2`. |
|
|
348
|
+
| `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`). |
|
|
349
|
+
| `usdText(usd)`, `wholeCents(usd)`, `HATS` | `"$0.25"`; whether a price has no fraction of a cent; the hats. |
|
|
350
|
+
| `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. |
|
|
351
|
+
|
|
352
|
+
| Step | Price | For |
|
|
353
|
+
|---|---|---|
|
|
354
|
+
| dye | 1¢ | a colour |
|
|
355
|
+
| charm | 2¢ | a small mark or a badge |
|
|
356
|
+
| piece | 5¢ | one worn thing, like a hat or a scarf |
|
|
357
|
+
| statement | 25¢ | a bigger piece that people notice |
|
|
358
|
+
| set | 50¢ | a few pieces that go together |
|
|
359
|
+
| outfit | $1 | a whole look |
|
|
360
|
+
|
|
361
|
+
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,
|
|
362
|
+
`Math.round(x * 100) === x * 100`, refused $0.07, $0.29, $0.57 and $1.10 because of floating point).
|
|
363
|
+
|
|
364
|
+
## The admin (`/admin`; since 0.2.0)
|
|
365
|
+
|
|
366
|
+
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:
|
|
367
|
+
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
|
|
368
|
+
database and reads no cookie: **the game gives a session and a store**.
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
import { createAdminHandler } from "@nano133/nanoterra/admin";
|
|
372
|
+
|
|
373
|
+
const admin = createAdminHandler({
|
|
374
|
+
store, // your database: readSettings(), saveSettings(patch, now), summary(now) (a memory store is in the package, for tests)
|
|
375
|
+
defaults: { character: "nanoterra", signin: "exchange" }, // what the two switches are until /admin sets them (your game.config.json)
|
|
376
|
+
owner: ownerAddress, // "nano_..." lowercase, its checksum checked by you; null when none is set
|
|
377
|
+
session: (req, now) => readAdminSession(req, now), // YOUR admin login: its own cookie; return { address, createdAt, via: "wallet" } or null
|
|
378
|
+
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")
|
|
379
|
+
lookName: (id) => looks.find((l) => l.id === id)?.name,
|
|
380
|
+
check: (now) => checkPayments(now), // optional: the payment check
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const answer = await admin.handle(request); // null when the request is not for /api/admin
|
|
384
|
+
if (answer) return answer;
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**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
|
|
388
|
+
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
|
|
389
|
+
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
|
|
390
|
+
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
|
|
391
|
+
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
|
|
392
|
+
starter refuses.
|
|
393
|
+
|
|
394
|
+
**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`
|
|
395
|
+
(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
|
|
396
|
+
neither header (curl) and the browser's own address bar (`Sec-Fetch-Site: none`) are not foreign.
|
|
397
|
+
|
|
398
|
+
**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
|
|
399
|
+
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
|
|
400
|
+
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
|
|
401
|
+
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.
|
|
402
|
+
|
|
403
|
+
**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,
|
|
404
|
+
and your defaults when the store fails.
|
|
405
|
+
|
|
406
|
+
**The conformance suite** proves your whole chain, not only the guard: the cookie you read, the session lookup, the owner you pass.
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
import { adminConformance } from "@nano133/nanoterra/admin/conformance";
|
|
410
|
+
|
|
411
|
+
const host = { routes: admin.routes, basePath: admin.basePath, origin: "https://game.example", request: (who, req) => sendAs(who, req) };
|
|
412
|
+
for (const c of adminConformance(host)) it(c.name, c.run); // vitest, node:test or any runner
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`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);
|
|
416
|
+
`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`;
|
|
417
|
+
`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`;
|
|
418
|
+
`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
|
|
419
|
+
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
|
|
420
|
+
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
|
|
421
|
+
session that renews its creation time on every read is a rolling session, and a stolen cookie would then live for ever.
|
|
422
|
+
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
|
|
423
|
+
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
|
|
424
|
+
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
|
|
425
|
+
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
|
|
426
|
+
status. A route that someone adds later cannot skip the guard, because the suite walks the route list.
|
|
427
|
+
|
|
428
|
+
**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
|
|
429
|
+
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
|
|
430
|
+
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
|
|
431
|
+
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).
|
|
432
|
+
|
|
433
|
+
**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:
|
|
434
|
+
`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
|
|
435
|
+
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
|
|
436
|
+
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
|
|
437
|
+
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
|
|
438
|
+
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
|
|
439
|
+
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 }`
|
|
440
|
+
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.
|
|
441
|
+
|
|
442
|
+
**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).
|
|
443
|
+
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
|
|
444
|
+
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
|
|
445
|
+
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.
|
|
446
|
+
|
|
447
|
+
### The store (an adapter for your database)
|
|
448
|
+
|
|
449
|
+
`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)
|
|
450
|
+
(`setting:character` and `setting:signin`); every other call they make is a read. Both must pass the same **store contract**, `adminStoreConformance(make)` in
|
|
451
|
+
`@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,
|
|
452
|
+
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.
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
import { createD1Store } from "@nano133/nanoterra/admin/d1"; // the game starter's tables: meta, orders, owned (migrations/0001_init.sql)
|
|
456
|
+
const store = createD1Store(env.DB); // Cloudflare's D1Database fits
|
|
457
|
+
|
|
458
|
+
import { createFirestoreStore } from "@nano133/nanoterra/admin/firestore"; // Firestore (the Admin SDK's `Firestore` fits the small interface)
|
|
459
|
+
const store = createFirestoreStore(db); // meta/{key}, orders/{id}, owned/{player__lookId}; the three names can be changed
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
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,
|
|
463
|
+
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
|
|
464
|
+
was tested on SQLite with the starter's tables, not on Cloudflare's D1.
|
|
465
|
+
|
|
466
|
+
### Who signed in, and "Sign out everywhere" (optional)
|
|
467
|
+
|
|
468
|
+
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()`.
|
|
469
|
+
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
|
|
470
|
+
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
|
|
471
|
+
origin rule and a limit of 5 a minute). It deletes the admin sessions and **no player session**, and says how many.
|
|
472
|
+
|
|
473
|
+
**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
|
|
474
|
+
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
|
|
475
|
+
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
|
|
476
|
+
(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.
|
|
477
|
+
|
|
478
|
+
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
|
|
479
|
+
`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
|
|
480
|
+
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
|
|
481
|
+
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.
|
|
482
|
+
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`.
|
|
483
|
+
|
|
484
|
+
### Later ideas (not built)
|
|
485
|
+
|
|
486
|
+
- **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
|
|
487
|
+
little gain. A later version could ask for it if the admin ever writes more.
|
|
488
|
+
- **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
|
|
489
|
+
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
|
|
490
|
+
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
|
|
491
|
+
recover, and the passkey would be a second, optional way that cannot be relayed.
|
|
492
|
+
|
|
493
|
+
### The panel
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
import { mountAdmin } from "@nano133/nanoterra/admin/panel";
|
|
497
|
+
import "@nano133/nanoterra/admin/style.css";
|
|
498
|
+
|
|
499
|
+
mountAdmin(document.getElementById("admin")!, {
|
|
500
|
+
looks, // your looks.json as the game has it now: the Shelf starts from it
|
|
501
|
+
signIn: (into, done) => showWalletSignIn(into, done), // YOUR wallet sign-in for the ADMIN login; call done() when the owner has signed in
|
|
502
|
+
signOut: () => signOutOfAdmin(), // sign out of the ADMIN login only (the game's own login stays)
|
|
503
|
+
defaultsFile: "game.config.json", // optional: where your switches' defaults are written
|
|
504
|
+
});
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
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),
|
|
508
|
+
**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
|
|
509
|
+
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`,
|
|
510
|
+
`lookPicture(look)` (your own picture of a look for the Shelf), `repoHelp`, and `fetch`.
|
|
511
|
+
|
|
512
|
+
**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
|
|
513
|
+
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
|
|
514
|
+
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`,
|
|
515
|
+
`--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.
|
|
516
|
+
|
|
517
|
+
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.
|
|
518
|
+
|
|
519
|
+
## Moving from the three old packages
|
|
520
|
+
|
|
521
|
+
| Before | Now |
|
|
522
|
+
|---|---|
|
|
523
|
+
| `npm i @nano133/character` | `npm i @nano133/nanoterra three` |
|
|
524
|
+
| `import { createCharacter } from "@nano133/character"` | `import { createCharacter } from "@nano133/nanoterra/character"` |
|
|
525
|
+
| `import { defaultLook, isLook, LOOK_PARTS } from "@nano133/character"` | `import { defaultLook, isLook, LOOK_PARTS } from "@nano133/nanoterra/character/record"` (no Three.js) |
|
|
526
|
+
| `createCharacter({ look })` | the same; or `createCharacter({ record })` for the whole character |
|
|
527
|
+
| `scene.add(c.object, c.shadow)` | `scene.add(c.object)`: the shadow is a disc in the body and stays on the ground by itself (`c.shadow` is gone) |
|
|
528
|
+
| `c.setLook({ ...look, shirt: "#c8461a" })` (a colour not on the island's lists) | `c.setColors({ shirt: "#c8461a" })`. `setLook` takes a record or the five colours of the lists |
|
|
529
|
+
| `import { readTicket } from "@nano133/ticket"` | `import { readTicket } from "@nano133/nanoterra/ticket"`: the same answer, and `ticket.record` when the ticket has one |
|
|
530
|
+
| `import { checkLooks } from "@nano133/looks"` | `import { checkLooks } from "@nano133/nanoterra/looks"` |
|
|
531
|
+
|
|
532
|
+
## What it does NOT do yet
|
|
533
|
+
|
|
534
|
+
- Worn items (see above): 0.2.0.
|
|
535
|
+
- The katana, the saya, the headband and the dojo look of the island's Ananos game. (The skeleton keeps their bones, with no vertices.)
|
|
536
|
+
- First-person arms, swimming, climbing, sitting and the room's other moves: only idle, walk, run, jump and wave.
|
|
537
|
+
- Shared geometry for many characters at once, and a react-three-fiber wrapper.
|
|
538
|
+
- `WebGPURenderer`: the ink is a GLSL `ShaderMaterial`, so use `WebGLRenderer`.
|
|
539
|
+
- Any server or Nano code. This package draws a character and reads a ticket; it moves no money.
|
|
540
|
+
|
|
541
|
+
## Build and test
|
|
542
|
+
|
|
543
|
+
```sh
|
|
544
|
+
npm install # at the kit's root
|
|
545
|
+
npm run build -w @nano133/nanoterra
|
|
546
|
+
npm test -w @nano133/nanoterra
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
MIT license.
|
package/ai/PROMPT.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Nanoterra for a game: the prompt
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Add Nanoterra to this game with the npm package @nano133/nanoterra and follow its ai/SPEC.md
|
|
5
|
+
step by step: https://nano133.com/build/nanoterra
|
|
6
|
+
Show the player's own Nanoterra character in the Three.js scene with createCharacter from
|
|
7
|
+
@nano133/nanoterra/character: add its object to the scene and call update(dt, { speed, airborne,
|
|
8
|
+
verticalSpeed }) once each frame with the game's own numbers (dt in seconds). Keep the choice
|
|
9
|
+
between the Nanoterra character and the game's own in ONE place (the starter's game.config.json,
|
|
10
|
+
"character": "nanoterra" or "own"). Use the same collider, jump and reach for every body.
|
|
11
|
+
If this game is a static site, read the sign-in ticket once with readTicket from
|
|
12
|
+
@nano133/nanoterra/ticket, ask "Sign in as <name>? Continue / Not me" first, and pass
|
|
13
|
+
ticket.record and ticket.look to createCharacter. Never give a sale, a prize or a score on a ticket
|
|
14
|
+
alone. Check every record with recordProblem and every look with isLook before it reaches the
|
|
15
|
+
character. Call dispose when a character leaves the game. Ask me which quality (high or low) to use.
|
|
16
|
+
```
|