@nano133/nanoterra 0.4.0 → 0.6.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 +32 -0
- package/README.md +161 -9
- package/ai/SPEC.md +27 -4
- package/dist/admin/adapters/d1.d.ts +1 -0
- package/dist/admin/adapters/d1.js +6 -4
- package/dist/admin/adapters/firestore.d.ts +1 -0
- package/dist/admin/adapters/firestore.js +7 -5
- package/dist/admin/conformance.d.ts +1 -1
- package/dist/admin/conformance.js +13 -5
- package/dist/admin/handler.d.ts +1 -1
- package/dist/admin/handler.js +3 -3
- package/dist/admin/index.d.ts +2 -2
- package/dist/admin/index.js +2 -2
- package/dist/admin/memory.js +3 -1
- package/dist/admin/panel/gameCard.d.ts +1 -1
- package/dist/admin/panel/gameCard.js +56 -6
- package/dist/admin/panel/mount.js +3 -3
- package/dist/admin/panel/overview.d.ts +1 -1
- package/dist/admin/panel/overview.js +4 -2
- package/dist/admin/panel/shelf.d.ts +4 -1
- package/dist/admin/panel/shelf.js +84 -29
- package/dist/admin/panel/shelfData.js +1 -1
- package/dist/admin/store.d.ts +2 -1
- package/dist/admin/switches.d.ts +23 -1
- package/dist/admin/switches.js +26 -5
- package/dist/friends/adapters/d1.d.ts +4 -0
- package/dist/friends/adapters/d1.js +131 -0
- package/dist/friends/adapters/firestore.d.ts +49 -0
- package/dist/friends/adapters/firestore.js +163 -0
- package/dist/friends/conformance.d.ts +11 -0
- package/dist/friends/conformance.js +229 -0
- package/dist/friends/handler.d.ts +31 -0
- package/dist/friends/handler.js +139 -0
- package/dist/friends/index.d.ts +8 -0
- package/dist/friends/index.js +12 -0
- package/dist/friends/memory.d.ts +9 -0
- package/dist/friends/memory.js +79 -0
- package/dist/friends/panel/index.d.ts +4 -0
- package/dist/friends/panel/index.js +10 -0
- package/dist/friends/panel/mount.d.ts +38 -0
- package/dist/friends/panel/mount.js +321 -0
- package/dist/friends/panel/window.d.ts +52 -0
- package/dist/friends/panel/window.js +72 -0
- package/dist/friends/rules.d.ts +92 -0
- package/dist/friends/rules.js +180 -0
- package/dist/friends/types.d.ts +58 -0
- package/dist/friends/types.js +24 -0
- package/dist/looks/change.d.ts +5 -5
- package/dist/looks/change.js +12 -7
- package/dist/looks/index.d.ts +2 -2
- package/dist/looks/index.js +1 -1
- package/dist/looks/json.d.ts +3 -2
- package/dist/looks/json.js +16 -9
- package/dist/looks/look.d.ts +26 -6
- package/dist/looks/look.js +37 -12
- package/package.json +11 -4
- package/src/admin/adapters/d1.ts +5 -4
- package/src/admin/adapters/firestore.ts +6 -5
- package/src/admin/conformance.ts +15 -6
- package/src/admin/handler.ts +3 -3
- package/src/admin/index.ts +3 -3
- package/src/admin/memory.ts +2 -1
- package/src/admin/panel/gameCard.ts +52 -6
- package/src/admin/panel/mount.ts +3 -3
- package/src/admin/panel/overview.ts +4 -3
- package/src/admin/panel/shelf.ts +135 -65
- package/src/admin/panel/shelfData.ts +1 -1
- package/src/admin/panel/style.css +10 -1
- package/src/admin/store.ts +2 -2
- package/src/admin/switches.ts +35 -9
- package/src/friends/adapters/d1.sql +33 -0
- package/src/friends/adapters/d1.ts +146 -0
- package/src/friends/adapters/firestore.ts +197 -0
- package/src/friends/conformance.ts +260 -0
- package/src/friends/handler.ts +144 -0
- package/src/friends/index.ts +17 -0
- package/src/friends/memory.ts +83 -0
- package/src/friends/panel/index.ts +13 -0
- package/src/friends/panel/mount.ts +346 -0
- package/src/friends/panel/style.css +303 -0
- package/src/friends/panel/window.ts +79 -0
- package/src/friends/rules.ts +196 -0
- package/src/friends/types.ts +57 -0
- package/src/looks/change.ts +12 -9
- package/src/looks/index.ts +2 -2
- package/src/looks/json.ts +12 -7
- package/src/looks/look.ts +47 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
- **A friends list for the game's players** (`@nano133/nanoterra/friends`, `/friends/d1`, `/friends/firestore`, `/friends/conformance`, `/friends/panel` and
|
|
6
|
+
`/friends/style.css`). The admin's Game card has a third choice, **"Friends list: Nanoterra / The game's own / Off"** (Nanoterra by default):
|
|
7
|
+
- **Nanoterra:** `mountFriends(el, { mode: "nanoterra", game })` puts a Friends key in the game and holds nano133's friends window
|
|
8
|
+
(`https://nano133.com/embed/friends?game=<id>&v=1`) in its panel. The friends stay on nano133; the game gets none of them, only the window's height, two
|
|
9
|
+
counts for the badge (requests, else friends online) and a friend's https address to join (the kit asks the player first). The window
|
|
10
|
+
is loaded at once and kept while the panel is closed, because it holds the player's live connection ("Playing <game>" for their friends). The messages
|
|
11
|
+
are contract v1 with the site, `{ source: "nanoterra-friends", v: 1, type }` (README, "The nano133 window").
|
|
12
|
+
- **The game's own:** a full friends list of the game's own players on the game's own database: requests (by name, through the game's `PlayerDirectory`),
|
|
13
|
+
accept, decline, remove, block and unblock, invites ("come to Level 2"), and online with a place. `createFriendsHandler` gives the routes (GET the view,
|
|
14
|
+
POST an op; same origin, 30 calls a minute for each player; no answer holds a player id). The stores: `createMemoryFriends`, `createD1Friends` (with the
|
|
15
|
+
migration `@nano133/nanoterra/friends/d1.sql`) and `createFirestoreFriends` (a small interface the Admin SDK fits). `friendsStoreConformance` proves the
|
|
16
|
+
rules on any store; the package runs it on all three (and on the Firestore emulator through the Admin SDK). The panel polls the view every 20 s while open
|
|
17
|
+
and pulses every 45 s with a lease for each tab.
|
|
18
|
+
- **Off:** nothing is drawn.
|
|
19
|
+
- **The admin's settings have a third word, `friends`** (`"nanoterra" | "own" | "off"`): `GameState` has `friends` and `fromAdmin.friends`, a press may send
|
|
20
|
+
`{ friends }`, the stores keep `setting:friends` (D1 and Firestore beside the two settings), and `GameSettings.friends` is optional (Nanoterra when left out).
|
|
21
|
+
A game that compares the whole `GameState` (a test with `deepEqual`) adds the two new fields.
|
|
22
|
+
|
|
23
|
+
## 0.5.0 (2026-10-30)
|
|
24
|
+
|
|
25
|
+
- **Wear slots** (`@nano133/nanoterra/looks`, and the Shelf of `/admin/panel`): a look fills one wear slot, and a player wears one look in each slot at once. An outfit (no
|
|
26
|
+
`wear`, or `"outfit"`: `OUTFIT`) is the character's own look, with its `change` (the body colour and the hat), as before. Any other `wear` is the game's own slot (a tool's
|
|
27
|
+
skin, a charm: `"wear": "shovel"`): an item there has no `change`. `lookProblems` refuses a bad slot name (field `wear`) and a `change` on an item (field `change`), and
|
|
28
|
+
now asks for one free OUTFIT (a list of outfits only keeps the starter's words); `checkLooks` leaves `wear` out for an outfit and `change` out for an item; `looksJson`
|
|
29
|
+
writes `wear` after `priceUsd`; `removalProblems` refuses a changed wear slot on an owned look (field `wear`); `diffLooks` lists `"wear"`. New: `OUTFIT`, `wearOf`,
|
|
30
|
+
`isOutfit`, the type `Change`. **`Look.change` is optional in the type now** (read an outfit's colours after `isOutfit`): a game that reads `look.change.body` directly
|
|
31
|
+
changes that line. The Shelf shows an item's slot and a tag picture, edits its wear slot (locked for an owned look and the last free outfit) in place of the body and hat,
|
|
32
|
+
and its message for an AI assistant names the wear rule. A file of outfits only reads and writes exactly as before. Echo Island sells shovel and Pinger skins and charms
|
|
33
|
+
with it (Wes, 2026-10-30).
|
|
34
|
+
|
|
3
35
|
## 0.4.0 (2026-10-30)
|
|
4
36
|
|
|
5
37
|
- **The admin panel (`@nano133/nanoterra/admin/panel`) has a new layout: the game at a glance, in tabs.** A top bar (who is signed in, Live or Test mode, the bot
|
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ themselves. This package gives your game four things:
|
|
|
10
10
|
- **The player's character** (`/character`): the same toon body that the player has on the island, drawn with Three.js. Six bases, faces, hair, clothes, sizes and colours.
|
|
11
11
|
- **The looks ladder** (`/looks`): the rules of `looks.json`, the price steps, and what a change does to the players who own a look.
|
|
12
12
|
- **Held input that does not stick** (`/input`, since 0.3.1): lets go of the keys and the stick when the player leaves the page, so the character never walks on by itself, and pauses a free round.
|
|
13
|
+
- **A friends list** (`/friends`, unreleased): a Friends key that holds the player's Nanoterra friends (a nano133 window), or a full friends list of the game's own players on the game's own database, as the admin's Game card chooses.
|
|
13
14
|
|
|
14
15
|
It replaces `@nano133/character` 0.1.0, `@nano133/ticket` 0.1.0 and `@nano133/looks` 0.1.0
|
|
15
16
|
(see "Moving from the three old packages"). Sign-in, checkout and late returns stay in their own packages
|
|
@@ -68,8 +69,12 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
|
|
|
68
69
|
| `@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 |
|
|
69
70
|
| `@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 | 18.9 kB with the tabbed layout (10.6 kB on 0.3.0, 8.5 kB on 0.2.0) + 5.9 kB of CSS (minified) |
|
|
70
71
|
| `@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 |
|
|
72
|
+
| `@nano133/nanoterra/friends` | the game's own friends list: the handler, the rules, a memory store (server code; unreleased) | no | 3.7 kB |
|
|
73
|
+
| `@nano133/nanoterra/friends/d1`, `/friends/firestore` | the friends store for D1 (with the migration `/friends/d1.sql`) or Firestore (a small interface, no Firebase dependency): `createD1Friends`, `createFirestoreFriends` (unreleased) | no | 3.0 kB each |
|
|
74
|
+
| `@nano133/nanoterra/friends/panel` | the Friends key and panel: the nano133 window, or the game's own list, and its stylesheet `@nano133/nanoterra/friends/style.css` (unreleased) | no | 5.0 kB + 4.8 kB of CSS (minified) |
|
|
75
|
+
| `@nano133/nanoterra/friends/conformance` | the friends rules as test cases, for a store a game writes itself (unreleased) | no | n/a |
|
|
71
76
|
|
|
72
|
-
Measured 2026-10-26: `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
|
|
77
|
+
Measured 2026-10-26 (the friends paths 2026-10-30): `esbuild --bundle --minify --format=esm --external:three`, then `gzip -9`, without Three.js.
|
|
73
78
|
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.
|
|
74
79
|
|
|
75
80
|
## The character
|
|
@@ -380,11 +385,38 @@ type Look = {
|
|
|
380
385
|
id: string; // 1 to 40 lowercase letters, digits or dashes: "mint-cap"
|
|
381
386
|
name: string; // 1 to 40 characters
|
|
382
387
|
priceUsd: number; // US dollars, at most 2 decimals; 0 is the free look
|
|
383
|
-
|
|
388
|
+
wear?: string; // the wear slot it fills: none (an outfit), or your game's own slot ("shovel"): see "Wear slots" below
|
|
389
|
+
change?: { body: string; hat?: "cap" | "beanie" | "crown" | "tophat"; hatColor?: string }; // an outfit's colours ("#rrggbb"); an item of another slot has none
|
|
384
390
|
slots?: Record<string, string>; // what YOUR game defines for itself: see "Slots" below. Optional.
|
|
385
391
|
};
|
|
386
392
|
```
|
|
387
393
|
|
|
394
|
+
### Wear slots: an outfit, and the items beside it
|
|
395
|
+
|
|
396
|
+
A look fills one **wear slot**, and a player wears one look in each slot at once. An **outfit** (no `wear`, or `"wear": "outfit"`) is the character's own look: its body colour and
|
|
397
|
+
its hat (`change`). Every other wear slot is **your game's own**: a tool's skin, a charm, a trail. An item there has **no `change`** (it does not change the body); your game knows
|
|
398
|
+
what it is from its id or its `slots`:
|
|
399
|
+
|
|
400
|
+
```json
|
|
401
|
+
{ "id": "classic", "name": "Classic", "priceUsd": 0, "change": { "body": "#4aa8ff" } },
|
|
402
|
+
{ "id": "toy-spade", "name": "Toy Spade", "priceUsd": 0.1, "wear": "shovel" },
|
|
403
|
+
{ "id": "echo-bat", "name": "Echo Bat", "priceUsd": 1, "wear": "pinger", "slots": { "ring": "ripples" } }
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
- A wear slot's name is like a slot's: 1 to 24 lowercase letters, digits or dashes, starting with a letter (`SLOT_NAME_PATTERN`); `outfit` (`OUTFIT`) is the outfit's.
|
|
407
|
+
- **Keep one free OUTFIT:** every player starts with it. A free item is fine beside it, but it does not count.
|
|
408
|
+
- `"change"` on an item is a mistake (`lookProblems`, field `change`): an item never changes the body. An outfit still needs its `change.body`.
|
|
409
|
+
- `checkLooks` leaves `wear` out for an outfit (no `wear` and `"wear": "outfit"` are the same look) and `change` out for an item; `looksJson` writes `wear` after `priceUsd`, on the
|
|
410
|
+
look's own line. `wearOf(look)` gives the slot (`OUTFIT` for an outfit); `isOutfit(look)` tells an outfit (with its `change`) from an item.
|
|
411
|
+
- **Players own a look by its id, so the wear slot of a look that a player owns must not change:** a shovel that a player owns stays a shovel. `removalProblems` reports it with the
|
|
412
|
+
field `"wear"`; `diffLooks` lists it as `"wear"`.
|
|
413
|
+
- The Shelf shows an item's slot on its card ("Worn in the shovel slot") and a tag in place of the character picture (give the Shelf your own `picture` to draw the real item), and
|
|
414
|
+
its editor has a **Wear slot** field (locked for an owned look and the last free outfit) in place of the body and hat of an outfit.
|
|
415
|
+
- What a player wears in each slot is your game's to keep (the starter keeps the outfit in the browser); ownership stays by id, as before.
|
|
416
|
+
|
|
417
|
+
A file of outfits only reads and writes exactly as it did. `change` is optional in the type now: read an outfit's colours after `isOutfit(look)` (or `look.change?.body`). This is a
|
|
418
|
+
minor change (0.5.0); a game on 0.4 or before drops `wear`, so a game with items must move to the new version in the game, the server and the Shelf.
|
|
419
|
+
|
|
388
420
|
### Slots: what your game defines on a look
|
|
389
421
|
|
|
390
422
|
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:
|
|
@@ -453,7 +485,7 @@ import { createAdminHandler } from "@nano133/nanoterra/admin";
|
|
|
453
485
|
|
|
454
486
|
const admin = createAdminHandler({
|
|
455
487
|
store, // your database: readSettings(), saveSettings(patch, now), summary(now) (a memory store is in the package, for tests)
|
|
456
|
-
defaults: { character: "nanoterra", signin: "exchange" }, // what the
|
|
488
|
+
defaults: { character: "nanoterra", signin: "exchange", friends: "nanoterra" }, // what the switches are until /admin sets them (your game.config.json; friends may be left out)
|
|
457
489
|
owner: ownerAddress, // "nano_..." lowercase, its checksum checked by you; null when none is set
|
|
458
490
|
session: (req, now) => readAdminSession(req, now), // YOUR admin login: its own cookie; return { address, createdAt, via: "wallet" } or null
|
|
459
491
|
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")
|
|
@@ -479,7 +511,7 @@ neither header (curl) and the browser's own address bar (`Sec-Fetch-Site: none`)
|
|
|
479
511
|
**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
|
|
480
512
|
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
|
|
481
513
|
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
|
|
482
|
-
nothing of the fault (`onError` hears it). The only writes are the
|
|
514
|
+
nothing of the fault (`onError` hears it). The only writes are the three settings (and, when the game turns on the sign-in list, the deletion of the admin sessions), **never a look, a price or an order**: a change of looks stays a change of your repository.
|
|
483
515
|
|
|
484
516
|
**Your own routes read the switches** with `loadGameSettings(store, defaults)` (for the listing file's `signin` word and for the character): the stored word, else your default,
|
|
485
517
|
and your defaults when the store fails.
|
|
@@ -527,8 +559,8 @@ admin may make an admin login, and never from another sign-in. A security read f
|
|
|
527
559
|
|
|
528
560
|
### The store (an adapter for your database)
|
|
529
561
|
|
|
530
|
-
`createAdminHandler` needs a store: three calls, `readSettings()`, `saveSettings(patch, now)` and `summary(now)`. Two adapters come with the package, and **both write only the
|
|
531
|
-
(`setting:character` and `setting:
|
|
562
|
+
`createAdminHandler` needs a store: three calls, `readSettings()`, `saveSettings(patch, now)` and `summary(now)`. Two adapters come with the package, and **both write only the three settings** (the D1 store also deletes the admin sessions, but only when the game turns on the sign-in list: see below)
|
|
563
|
+
(`setting:character`, `setting:signin` and `setting:friends`); every other call they make is a read. Both must pass the same **store contract**, `adminStoreConformance(make)` in
|
|
532
564
|
`@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,
|
|
533
565
|
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.
|
|
534
566
|
|
|
@@ -587,10 +619,10 @@ mountAdmin(document.getElementById("admin")!, {
|
|
|
587
619
|
|
|
588
620
|
The panel draws, from the top: a bar with who is signed in (the short address and a copy key), Live or Test mode, the bot check and Sign out; what is still off (the
|
|
589
621
|
status warnings, one banner); four numbers (Earned, Open now, To send back, Players: a press opens the list behind each); then five tabs. **Overview**: the best-selling
|
|
590
|
-
looks with their share of the money, the last five orders, the
|
|
622
|
+
looks with their share of the money, the last five orders, the switches as On or Off (and the friends list's choice) and the shelf in numbers (a game with no order yet gets three first steps).
|
|
591
623
|
**Cosmetics**: the **Shelf** (below). **Orders**: a filter by status with its count, a search (a look, an address or a block), "Check payments now" when the game gave a
|
|
592
624
|
check, "Paid late" with the address to send the money back to, 20 orders at a time; a card each on a phone, a table on a wide panel. **Players**: each player who owns a
|
|
593
|
-
look, with their looks. **Settings**: the **Game** card (the two switches), the Sign-ins card and the facts (the address, the mode, the nodes, the last price). The tabs are
|
|
625
|
+
look, with their looks. **Settings**: the **Game** card (the two switches and the friends list's three-way choice), the Sign-ins card and the facts (the address, the mode, the nodes, the last price). The tabs are
|
|
594
626
|
real tabs (the arrow keys, Home and End work); every tab is made once, so a draft, a search and a switch keep their state, and the panel never writes the page's address.
|
|
595
627
|
A visitor who is signed out gets the game's sign-in; a wrong wallet, a Nanoterra sign-in and a missing owner address each get their own screen and see no data (a sign-out
|
|
596
628
|
takes the drawn data off the page). Options: `api` (the handler's base path), `noAddressHelp`, `lookPicture(look)` (your own picture of a look for the Shelf), `repoHelp`,
|
|
@@ -625,7 +657,7 @@ flat picture stays. `createLookPreview({ size?, body?, record?, background?, key
|
|
|
625
657
|
|
|
626
658
|
**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
|
|
627
659
|
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
|
|
628
|
-
|
|
660
|
+
three 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`,
|
|
629
661
|
`--n133-gem`, `--n133-gem-dark`, `--n133-sea`, `--n133-radius`, `--n133-font`, `--n133-ground` (the wells inside a card), `--n133-warn`, `--n133-bad` or `--n133-shadow` on the
|
|
630
662
|
element that holds it. The keys, inputs, switches, swatches and price steps are 44 px or more; the tabs, filter keys and copy keys 40 px. The layout follows the panel's own
|
|
631
663
|
width (a container query), not the window's: on a phone two cards a row and the five tabs as icons over their words; three cards a row and the orders as a table from
|
|
@@ -633,6 +665,126 @@ width (a container query), not the window's: on a phone two cards a row and the
|
|
|
633
665
|
|
|
634
666
|
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.
|
|
635
667
|
|
|
668
|
+
## The friends list (`/friends`; unreleased)
|
|
669
|
+
|
|
670
|
+
A Friends key for the game, and a list behind it. The admin's **Game** card chooses which list (`friends` in the settings; Nanoterra by default):
|
|
671
|
+
|
|
672
|
+
| Choice | The word | What the player gets |
|
|
673
|
+
|---|---|---|
|
|
674
|
+
| Nanoterra | `"nanoterra"` | their **Nanoterra friends**, in a small nano133 window in the game. Friends on Nano Island see "Playing <game>" with Join. The friends stay on nano133: the game gets none of them. |
|
|
675
|
+
| The game's own | `"own"` | a friends list of **the game's own players**, on the game's own database (this package's handler and stores). Nanoterra friends do not show. |
|
|
676
|
+
| Off | `"off"` | nothing. |
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
import { mountFriends } from "@nano133/nanoterra/friends/panel";
|
|
680
|
+
import "@nano133/nanoterra/friends/style.css";
|
|
681
|
+
|
|
682
|
+
const settings = await (await fetch("/api/settings")).json(); // your route: loadGameSettings(store, defaults) from "@nano133/nanoterra/admin"
|
|
683
|
+
const friends = mountFriends(document.getElementById("friends")!, {
|
|
684
|
+
mode: settings.friends, // "nanoterra" | "own" | "off"
|
|
685
|
+
game: "echo-island", // your game's id on Nanoterra (its listing's id)
|
|
686
|
+
where: "Lobby", // where the player is, in your words: friends see it
|
|
687
|
+
onJoin: (target) => go(target), // optional: "nanoterra" gives a friend's https address, "own" an invite's place (and the friend's name)
|
|
688
|
+
});
|
|
689
|
+
friends.setWhere("Level 2"); // when the player moves; null for no place
|
|
690
|
+
// friends.open(), friends.close(), friends.destroy()
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
The key is 44 px, with a badge (the requests, or else the friends online). The panel is a sheet from the bottom on a phone held upright and from the right on a wide or sideways screen;
|
|
694
|
+
Escape and its close key close it. Theme it with the same `--n133-*` properties as the admin panel, and `--n133-friends-z` for its z-index (default 1000).
|
|
695
|
+
|
|
696
|
+
### The nano133 window (contract v1 with the site)
|
|
697
|
+
|
|
698
|
+
In `"nanoterra"` mode the panel holds an `<iframe>` of `https://nano133.com/embed/friends?game=<gameId>&v=1` (`origin` changes the host, for a test). The site lets
|
|
699
|
+
only a **listed** game's own address frame it. This is the contract between the kit and the site, version 1 (accepted by the site on 2026-10-30):
|
|
700
|
+
|
|
701
|
+
- **Loaded at once, kept while closed.** The window is made when `mountFriends` runs (no lazy loading) and stays while the panel is closed: it holds the player's
|
|
702
|
+
live connection to nano133's room server, which shows "Playing <game>" to their friends and keeps the badge true. Closed, it is hidden (`visibility: hidden`, moved
|
|
703
|
+
off the screen, `aria-hidden="true"` and `tabindex="-1"`), never `display: none` and never removed. `open` and `closed` change only the window's own view.
|
|
704
|
+
`destroy()` removes it.
|
|
705
|
+
- **The frame:** `title="Friends"`, `referrerpolicy="origin"`, `allow="storage-access"`, and
|
|
706
|
+
`sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation"`: no top navigation,
|
|
707
|
+
so the window can never move the game's page.
|
|
708
|
+
- **Messages** are objects `{ source: "nanoterra-friends", v: 1, type, ... }`, both ways. The window sends with `parent.postMessage(msg, <the game's origin>)`; the kit
|
|
709
|
+
sends with `iframe.contentWindow.postMessage(msg, <nano133's origin>)`. Both sides check `event.origin` and `event.source`. A message with another `source`, another
|
|
710
|
+
`v`, another type or a bad field is ignored. **No message carries an address, a pass or a player id.**
|
|
711
|
+
|
|
712
|
+
| Direction | `type` | Fields | What the other side does |
|
|
713
|
+
|---|---|---|---|
|
|
714
|
+
| window → game | `ready` | | the window has loaded. The kit answers at once with `where`, then `open` or `closed` (it sends nothing before `ready`). |
|
|
715
|
+
| window → game | `size` | `height` (CSS pixels its content needs) | the kit sets the frame's height to it, at most the panel's own height |
|
|
716
|
+
| window → game | `badge` | `online`, `requests` (each 0 to 99) | the key's badge shows the requests (the thing to do) when there are any, else the friends online in the gem colour; "9+" from 10. The key's name says both ("Friends, 2 requests, 5 online"). |
|
|
717
|
+
| window → game | `join` | `url` | the player chose Join on a friend. The kit asks "Leave this game to join your friend?" (the window sends no name), then calls `onJoin(url)`, or goes to the address when there is no `onJoin`. |
|
|
718
|
+
| window → game | `signin` | | the window needs a tap for Storage Access, inside itself. The panel stays as it is; the kit does nothing else. |
|
|
719
|
+
| game → window | `where` | `where` (text or null) | after `ready`, and again on each `setWhere` |
|
|
720
|
+
| game → window | `open`, `closed` | | after `ready` (after `where`), and when the panel opens or closes |
|
|
721
|
+
|
|
722
|
+
- **`where` is plain text, and friends see it.** The kit removes control characters, trims it and cuts it to 64 characters before it sends it; it may send null (no
|
|
723
|
+
place). The site does the same again and shows it as text only.
|
|
724
|
+
- **Join:** `url` is **https only** (no user name or password in it): an island friend gives `https://nano133.com/nanoterra?room=<code>`, a friend in a game gives that
|
|
725
|
+
game's listed address.
|
|
726
|
+
- **Safari, and any browser that blocks third-party cookies:** the window needs one "Show my friends" tap (the Storage Access API) on each page load of the game
|
|
727
|
+
before the player shows online there (the window says `signin` when it shows that tap). Chrome with third-party cookies on needs none.
|
|
728
|
+
|
|
729
|
+
### The game's own list
|
|
730
|
+
|
|
731
|
+
The server part, `@nano133/nanoterra/friends`, has no database and reads no cookie: **the game gives a store, a directory of its players and its signed-in player**.
|
|
732
|
+
|
|
733
|
+
```ts
|
|
734
|
+
import { createFriendsHandler } from "@nano133/nanoterra/friends";
|
|
735
|
+
import { createD1Friends } from "@nano133/nanoterra/friends/d1";
|
|
736
|
+
|
|
737
|
+
const friends = createFriendsHandler({
|
|
738
|
+
store: createD1Friends(env.DB), // or createFirestoreFriends(getFirestore()), or createMemoryFriends() for a test
|
|
739
|
+
directory: {
|
|
740
|
+
byName: (name) => findPlayerIdByName(name), // your players: an id for a name, or null
|
|
741
|
+
names: (ids) => namesOf(ids), // a Map of id -> the name to show
|
|
742
|
+
},
|
|
743
|
+
player: (req) => signedInPlayerId(req), // YOUR player's sign-in (not the admin's): an id, or null
|
|
744
|
+
base: "/friends", // the default
|
|
745
|
+
});
|
|
746
|
+
const answer = await friends.handle(request); // null when the request is not for /friends
|
|
747
|
+
if (answer) return answer;
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
- `GET /friends` gives `{ showOnline, people }`: one row a person, `{ edge, name, state, online, where, invite }`, `state` one of `"friend"`, `"incoming"`,
|
|
751
|
+
`"outgoing"`, `"blocked"`. **No answer holds a player id:** a row names a player by the directory's name and an opaque `edge`.
|
|
752
|
+
- `POST /friends` takes one `{ op, ... }`: `request { name }`, `accept`, `decline`, `remove`, `block`, `unblock { edge }`, `invite { edge, where }`, `pulse { where, lease }`
|
|
753
|
+
(`where` null: this tab leaves) and `visibility { showOnline }`. It answers the new view (a pulse answers `{ ok: true }`), or `{ error }` with words a player can read.
|
|
754
|
+
- A visitor who is not signed in gets 401. A POST must come from the game's own page and a read must not come from another page (403). Each player has 30 calls a
|
|
755
|
+
minute (429); a body is at most 1 KB. A fault in the store is a plain 500 that shows nothing of it (`onError` hears it).
|
|
756
|
+
|
|
757
|
+
**The rules** (every store keeps them; `friendsStoreConformance` proves it):
|
|
758
|
+
|
|
759
|
+
- A pair of players has at most one edge: a request (from one of them), a friendship or a block. A request to a player who already asked you is an accept.
|
|
760
|
+
- **A block wins** over a friendship or a request, and hides the pair from the player who is blocked, both ways. The player who blocked sees a "blocked" row, to
|
|
761
|
+
unblock it; unblock leaves no edge (not friends again). Neither can ask while a block stands.
|
|
762
|
+
- `FRIEND_LIMIT` (100) live rows a player (friends, requests both ways, blocks): a full list refuses a new request, on either side.
|
|
763
|
+
- A request nobody answers goes after `FRIEND_REQUEST_MS` (30 days): not shown, not counted.
|
|
764
|
+
- An invite is only between friends, and the friend sees it for `FRIEND_INVITE_MS` (10 minutes). Its place is plain text, cut to 64 characters.
|
|
765
|
+
- A friend is online only for `FRIEND_LIVE_MS` (2 minutes) after a pulse, and only while they show online. A pulse carries a lease for each tab, so an old tab that
|
|
766
|
+
leaves cannot clear a newer tab's place.
|
|
767
|
+
- `forget(player)` (for "Delete my data") leaves no row of the player.
|
|
768
|
+
|
|
769
|
+
**The stores.** Each writes ONLY its friend rows, presence rows and the show-online choice:
|
|
770
|
+
|
|
771
|
+
- **D1:** add `@nano133/nanoterra/friends/d1.sql` to your migrations (the tables `friend_edges`, `friend_presence`, `friend_prefs`). D1 has no transaction that reads and
|
|
772
|
+
then writes, so each write is one statement with its own check: a change says which `rev` it read (a row that changed in between is refused, 409, "Try again"), and a
|
|
773
|
+
new edge counts both players' rows in the same INSERT.
|
|
774
|
+
- **Firestore:** `createFirestoreFriends(db, { edges, presence, prefs })` (the default names `friend_edges`, `friend_presence`, `friend_prefs`). A pair is one document named
|
|
775
|
+
by a SHA-256 digest, so two requests at the same moment meet in their transactions; the indexes Firestore makes by itself are enough.
|
|
776
|
+
- **Your own database:** write a `FriendsStore` and run the rules on it:
|
|
777
|
+
|
|
778
|
+
```ts
|
|
779
|
+
import { friendsStoreConformance } from "@nano133/nanoterra/friends/conformance";
|
|
780
|
+
for (const c of friendsStoreConformance(async () => ({ store: newEmptyStore(), dump: () => allRowsAsText() }))) it(c.name, c.run);
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
**The panel in `"own"` mode** reads the view at once, every 20 s while open and every 60 s while closed (for the badge), pulses every 45 s while it has a `where`, and
|
|
784
|
+
sends a leaving pulse when the page goes away. It draws Requests (Accept, Decline), Friends (online and the place, Invite to your `where`, Join an invite when you
|
|
785
|
+
give `onJoin`, Remove, Block), Sent (Cancel) and Blocked (Unblock), an add-by-name field and the "show friends when I am online" choice. The game's own friends have
|
|
786
|
+
no live connection: only Nanoterra's do.
|
|
787
|
+
|
|
636
788
|
## Moving from the three old packages
|
|
637
789
|
|
|
638
790
|
| Before | Now |
|
package/ai/SPEC.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Nanoterra for a game: implementation spec
|
|
2
2
|
|
|
3
3
|
Package: `@nano133/nanoterra` (0.1.0 or later). Follow these steps in order. Each step ends with a check. Ask the owner when a step says
|
|
4
|
-
"ask". The package has
|
|
5
|
-
looks ladder (`/looks`)
|
|
4
|
+
"ask". The package has five parts: the sign-in ticket (`/ticket`), the player's character (`/character`, Three.js), the
|
|
5
|
+
looks ladder (`/looks`), held input that does not stick (`/input`) and the friends list (`/friends`, with its panel). Use only the parts the game needs. It has no network, wallet or Nano code. Read the package README first.
|
|
6
6
|
|
|
7
7
|
## 0. What the owner decides (ask)
|
|
8
8
|
|
|
@@ -13,6 +13,7 @@ looks ladder (`/looks`) and held input that does not stick (`/input`). Use only
|
|
|
13
13
|
3. The quality: `"high"` (toon body and ink outline, 2 draws) or `"low"` (no outline, 1 draw, for slow phones).
|
|
14
14
|
4. How the game moves the character: speed in metres a second, gravity and jump. The package animates; it does not move the object.
|
|
15
15
|
5. Does the game sell looks? If yes, the game is the seller (its own checkout); see step 7.
|
|
16
|
+
6. The friends list (step 9): Nanoterra's (the default), the game's own, or none. It is the admin's `friends` setting; ask which one the game starts with.
|
|
16
17
|
|
|
17
18
|
**Check:** the owner has named the server (yes or no), the source of the character and the quality.
|
|
18
19
|
|
|
@@ -100,9 +101,10 @@ export const LOOKS = checkLooks(rawLooksJson); // throws with the first mistake
|
|
|
100
101
|
- 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
102
|
- A look only changes how the character looks. No random look, no loot box, no look that changes a score.
|
|
102
103
|
- `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.
|
|
104
|
+
- `wear` (optional) is the wear slot a look fills: none for an outfit (the character's colours and hat, `change`), or the game's own slot (`"shovel"`, `"charm"`) for an item, which has no `change`. A player wears one look in each slot. Keep one free outfit. Never change the wear slot of a look that someone owns. Read outfits with `isOutfit(look)`.
|
|
103
105
|
- The game is the seller. Never send looks, prices or sales to another site.
|
|
104
106
|
|
|
105
|
-
**Check:** `checkLooks` passes at start; a removed look that is owned is refused; a changed slot on an owned look is refused.
|
|
107
|
+
**Check:** `checkLooks` passes at start; a removed look that is owned is refused; a changed slot or wear slot on an owned look is refused.
|
|
106
108
|
|
|
107
109
|
## 8. Leaving the page (any game that a player walks in with keys or a stick)
|
|
108
110
|
|
|
@@ -116,7 +118,28 @@ import { pauseWhenLeft, releaseWhenLost, swallowsKeyup } from "@nano133/nanoterr
|
|
|
116
118
|
|
|
117
119
|
**Check:** hold a move key, hide the tab or switch windows, come back: the character stands still until the key is pressed again.
|
|
118
120
|
|
|
119
|
-
## 9.
|
|
121
|
+
## 9. The friends list (the admin's `friends` setting)
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { mountFriends } from "@nano133/nanoterra/friends/panel";
|
|
125
|
+
import "@nano133/nanoterra/friends/style.css";
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- Read the setting on the server with `loadGameSettings(store, defaults)` (`friends` is `"nanoterra"`, `"own"` or `"off"`) and pass it as `mode`. Do not read it in a
|
|
129
|
+
second place.
|
|
130
|
+
- `"nanoterra"`: `mountFriends(el, { mode, game })` with the game's id on Nanoterra. Mount it when the game starts, not when the key is pressed: the window holds the
|
|
131
|
+
player's live connection. Never hide its element with `display: none` or remove it; `destroy()` is for the end of the game. Call `setWhere(text)` when the player moves
|
|
132
|
+
(plain text, friends see it). Give `onJoin` only to save the game before it leaves; the kit asks the player first and accepts only https addresses.
|
|
133
|
+
- `"own"`: add `createFriendsHandler({ store, directory, player })` from `@nano133/nanoterra/friends` to the server, with `createD1Friends(env.DB)` (add
|
|
134
|
+
`@nano133/nanoterra/friends/d1.sql` to the migrations) or `createFirestoreFriends(db)`. `player(req)` reads the game's own PLAYER sign-in, never the admin's.
|
|
135
|
+
`directory.byName` and `directory.names` read the game's own player names. Call `store.forget(player)` from the game's "Delete my data".
|
|
136
|
+
- A store of the game's own (another database) passes `friendsStoreConformance` from `@nano133/nanoterra/friends/conformance` before it is used.
|
|
137
|
+
|
|
138
|
+
**Check:** with `"nanoterra"`, the key shows and the window loads at once (the network tab shows `/embed/friends` before the key is pressed). With `"own"`, two players
|
|
139
|
+
in two browsers: one adds the other by name, the other accepts, both show online with their place, an invite shows, a block hides the blocker from the other. With
|
|
140
|
+
`"off"`, no key.
|
|
141
|
+
|
|
142
|
+
## 10. Do not
|
|
120
143
|
|
|
121
144
|
- Do not edit the geometry, the skeleton or the toon and ink materials.
|
|
122
145
|
- 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
|
|
@@ -15,6 +15,7 @@ export interface D1Like {
|
|
|
15
15
|
export declare const D1_KEYS: {
|
|
16
16
|
readonly character: "setting:character";
|
|
17
17
|
readonly signin: "setting:signin";
|
|
18
|
+
readonly friends: "setting:friends";
|
|
18
19
|
};
|
|
19
20
|
/**
|
|
20
21
|
* The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// The admin store for a game on Cloudflare D1, with the tables of the game starter (migrations/0001_init.sql): `meta`, `orders` and `owned`.
|
|
2
|
-
// It writes ONLY the
|
|
2
|
+
// It writes ONLY the three settings (`setting:character`, `setting:signin`, `setting:friends` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
|
|
3
3
|
// (`logins: true`): then the one other write is `DELETE FROM sessions WHERE scope = 'admin'` ("sign out everywhere").
|
|
4
4
|
//
|
|
5
5
|
// import { createD1Store } from "@nano133/nanoterra/admin/d1";
|
|
6
6
|
// const store = createD1Store(env.DB);
|
|
7
7
|
import { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers } from "../summary.js";
|
|
8
|
-
export const D1_KEYS = { character: "setting:character", signin: "setting:signin" };
|
|
8
|
+
export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" };
|
|
9
9
|
/**
|
|
10
10
|
* The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
|
|
11
11
|
* (`DELETE FROM sessions WHERE scope = 'admin'`, and `UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0` so that a request signed a moment ago
|
|
@@ -15,9 +15,9 @@ export const D1_KEYS = { character: "setting:character", signin: "setting:signin
|
|
|
15
15
|
export function createD1Store(db, options = {}) {
|
|
16
16
|
const store = {
|
|
17
17
|
async readSettings() {
|
|
18
|
-
const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?)").bind(D1_KEYS.character, D1_KEYS.signin).all()).results;
|
|
18
|
+
const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?, ?)").bind(D1_KEYS.character, D1_KEYS.signin, D1_KEYS.friends).all()).results;
|
|
19
19
|
const row = (key) => rows.find((r) => r.key === key)?.value;
|
|
20
|
-
return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin) };
|
|
20
|
+
return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends) };
|
|
21
21
|
},
|
|
22
22
|
async saveSettings(patch, now) {
|
|
23
23
|
const write = (key, value) => db.prepare("INSERT INTO meta (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at").bind(key, value, now);
|
|
@@ -26,6 +26,8 @@ export function createD1Store(db, options = {}) {
|
|
|
26
26
|
batch.push(write(D1_KEYS.character, patch.character));
|
|
27
27
|
if (patch.signin)
|
|
28
28
|
batch.push(write(D1_KEYS.signin, patch.signin));
|
|
29
|
+
if (patch.friends)
|
|
30
|
+
batch.push(write(D1_KEYS.friends, patch.friends));
|
|
29
31
|
if (batch.length)
|
|
30
32
|
await db.batch(batch);
|
|
31
33
|
},
|
|
@@ -32,6 +32,7 @@ export declare const FIRESTORE_SCAN_LIMIT = 5000;
|
|
|
32
32
|
export declare const FIRESTORE_KEYS: {
|
|
33
33
|
readonly character: "setting:character";
|
|
34
34
|
readonly signin: "setting:signin";
|
|
35
|
+
readonly friends: "setting:friends";
|
|
35
36
|
};
|
|
36
37
|
export declare function createFirestoreStore(db: FirestoreLike, collections?: {
|
|
37
38
|
meta?: string;
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// The admin store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface below, which the
|
|
2
|
-
// Admin SDK's `Firestore` fits. It writes ONLY the
|
|
2
|
+
// Admin SDK's `Firestore` fits. It writes ONLY the three settings; everything else it does is a read.
|
|
3
3
|
//
|
|
4
4
|
// The data model (the names a game must have; only the three collection names can be changed):
|
|
5
|
-
// meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "rate" (value is JSON { usd, sources })
|
|
5
|
+
// meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "setting:friends", "rate" (value is JSON { usd, sources })
|
|
6
6
|
// orders/{id} { player, lookId, usd, amount, status, createdAt, expiresAt, paidAt, payer, hash } `amount` is the raw amount as text
|
|
7
7
|
// owned/{player__lookId} { player, lookId, orderId, grantedAt }
|
|
8
8
|
//
|
|
9
9
|
// The summary reads the newest 5000 orders (the counts and the totals are over those) and every owned look: for a small game. A big game should keep running totals.
|
|
10
10
|
import { parseRate, summarize } from "../summary.js";
|
|
11
11
|
export const FIRESTORE_SCAN_LIMIT = 5000;
|
|
12
|
-
export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin" };
|
|
12
|
+
export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" };
|
|
13
13
|
const text = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
14
14
|
const num = (v, fallback = 0) => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
|
|
15
15
|
const orNull = (v) => (typeof v === "string" ? v : null);
|
|
@@ -23,8 +23,8 @@ export function createFirestoreStore(db, collections = {}) {
|
|
|
23
23
|
};
|
|
24
24
|
return {
|
|
25
25
|
async readSettings() {
|
|
26
|
-
const [character, signin] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin)]);
|
|
27
|
-
return { character: character?.value, signin: signin?.value };
|
|
26
|
+
const [character, signin, friends] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin), valueOf(FIRESTORE_KEYS.friends)]);
|
|
27
|
+
return { character: character?.value, signin: signin?.value, friends: friends?.value };
|
|
28
28
|
},
|
|
29
29
|
async saveSettings(patch, now) {
|
|
30
30
|
const batch = db.batch();
|
|
@@ -33,6 +33,8 @@ export function createFirestoreStore(db, collections = {}) {
|
|
|
33
33
|
(batch.set(db.collection(meta).doc(FIRESTORE_KEYS.character), { value: patch.character, at: now }), n++);
|
|
34
34
|
if (patch.signin)
|
|
35
35
|
(batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
|
|
36
|
+
if (patch.friends)
|
|
37
|
+
(batch.set(db.collection(meta).doc(FIRESTORE_KEYS.friends), { value: patch.friends, at: now }), n++);
|
|
36
38
|
if (n)
|
|
37
39
|
await batch.commit();
|
|
38
40
|
},
|
|
@@ -71,7 +71,7 @@ export type StoreHarness = {
|
|
|
71
71
|
at: number;
|
|
72
72
|
};
|
|
73
73
|
}): Promise<void>;
|
|
74
|
-
/** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the
|
|
74
|
+
/** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the three settings. Same rows, same text. */
|
|
75
75
|
snapshot(): Promise<string>;
|
|
76
76
|
/** Only for a store with `recentLogins` and `signOutEverywhere`: put admin sign-ins in the game's database (the store only reads them). */
|
|
77
77
|
seedLogins?(rows: {
|
|
@@ -40,7 +40,7 @@ async function read(res) {
|
|
|
40
40
|
/** A refusal carries words and a reason and nothing else: no data of the game. */
|
|
41
41
|
const REFUSAL_KEYS = new Set(["error", "reason", "signedInAs"]);
|
|
42
42
|
/** Words of the game's data that a refusal or an answer to a visitor must never hold. */
|
|
43
|
-
const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"warnings"/;
|
|
43
|
+
const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"warnings"/;
|
|
44
44
|
export function adminConformance(host) {
|
|
45
45
|
const cases = [];
|
|
46
46
|
const add = (name, run) => cases.push({ name, run });
|
|
@@ -264,7 +264,15 @@ export function adminStoreConformance(make) {
|
|
|
264
264
|
const order = (id, over) => ({ id, player: A, lookId: "mint-cap", usd: "0.25", amountRaw: rawOf(0.25), status: "paid", createdAt: NOW - 1000, expiresAt: NOW + 1000, paidAt: NOW - 500, payer: A, hash: `H${id}`, ...over });
|
|
265
265
|
add("a new store has no settings of its own", async ({ store }) => {
|
|
266
266
|
const s = await store.readSettings();
|
|
267
|
-
check(s.character === undefined && s.signin === undefined, `expected no settings, got ${JSON.stringify(s)}`);
|
|
267
|
+
check(s.character === undefined && s.signin === undefined && s.friends === undefined, `expected no settings, got ${JSON.stringify(s)}`);
|
|
268
|
+
});
|
|
269
|
+
add("the friends list is kept as its own setting: each of its three words, and a patch of it leaves the other two", async ({ store }) => {
|
|
270
|
+
await store.saveSettings({ signin: "none", character: "own" }, NOW);
|
|
271
|
+
for (const [i, friends] of ["own", "off", "nanoterra"].entries()) {
|
|
272
|
+
await store.saveSettings({ friends }, NOW + 1 + i);
|
|
273
|
+
const s = await store.readSettings();
|
|
274
|
+
check(s.friends === friends && s.signin === "none" && s.character === "own", `after friends ${friends}: ${JSON.stringify(s)}`);
|
|
275
|
+
}
|
|
268
276
|
});
|
|
269
277
|
add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
|
|
270
278
|
await store.saveSettings({ signin: "none" }, NOW);
|
|
@@ -280,12 +288,12 @@ export function adminStoreConformance(make) {
|
|
|
280
288
|
add("an empty patch writes nothing and does not fail", async ({ store }) => {
|
|
281
289
|
await store.saveSettings({}, NOW);
|
|
282
290
|
const s = await store.readSettings();
|
|
283
|
-
check(s.character === undefined && s.signin === undefined, "an empty patch must save nothing");
|
|
291
|
+
check(s.character === undefined && s.signin === undefined && s.friends === undefined, "an empty patch must save nothing");
|
|
284
292
|
});
|
|
285
|
-
add("saving changes nothing but the
|
|
293
|
+
add("saving changes nothing but the settings: no order, no owned look, no other meta row", async (h) => {
|
|
286
294
|
await h.seed({ orders: [order("1", {}), order("2", { status: "open" })], owned: [{ player: A, lookId: "mint-cap", at: NOW }], rate: { value: JSON.stringify({ usd: 1.2, sources: ["x"] }), at: NOW } });
|
|
287
295
|
const before = await h.snapshot();
|
|
288
|
-
await h.store.saveSettings({ character: "own", signin: "none" }, NOW + 5);
|
|
296
|
+
await h.store.saveSettings({ character: "own", signin: "none", friends: "off" }, NOW + 5);
|
|
289
297
|
check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
|
|
290
298
|
});
|
|
291
299
|
add("the summary counts the orders by status, reads an open order past its expiry as expired, and totals the money", async ({ store, seed }) => {
|
package/dist/admin/handler.d.ts
CHANGED
|
@@ -59,5 +59,5 @@ export declare const ADMIN_LIMITS: {
|
|
|
59
59
|
readonly gameBodyMax: 512;
|
|
60
60
|
};
|
|
61
61
|
export declare function createAdminHandler(options: AdminHandlerOptions): AdminHandler;
|
|
62
|
-
/** The settings as the game's own routes need them (the listing file, the character): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
62
|
+
/** The settings as the game's own routes need them (the listing file, the character, the friends list): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
63
63
|
export declare function loadGameSettings(store: Pick<AdminStore, "readSettings">, defaults: GameSettings): Promise<GameState>;
|
package/dist/admin/handler.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The admin handler: the routes of /admin on the Web `Request` and `Response` types, so it fits a Worker, Firebase Functions and Node.
|
|
2
2
|
//
|
|
3
3
|
// GET {base}/summary the numbers: status, orders, the looks that players own
|
|
4
|
-
// GET {base}/game the
|
|
5
|
-
// POST {base}/game a press: { character } or {
|
|
4
|
+
// GET {base}/game the game switches
|
|
5
|
+
// POST {base}/game a press: { character }, { signin } or { friends } (only the known words)
|
|
6
6
|
// POST {base}/check the payment check, when the game gives one
|
|
7
7
|
//
|
|
8
8
|
// EVERY request under the base goes through `adminAccess` first, before the route is even looked up: a visitor who is not the owner gets 401 or 403
|
|
@@ -159,7 +159,7 @@ export function createAdminHandler(options) {
|
|
|
159
159
|
}
|
|
160
160
|
return { routes, basePath: options.basePath ?? "/api/admin", handle };
|
|
161
161
|
}
|
|
162
|
-
/** The settings as the game's own routes need them (the listing file, the character): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
162
|
+
/** The settings as the game's own routes need them (the listing file, the character, the friends list): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
163
163
|
export async function loadGameSettings(store, defaults) {
|
|
164
164
|
try {
|
|
165
165
|
return mergeSettings(await store.readSettings(), defaults);
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -9,5 +9,5 @@ export type { AdminLogin, AdminOrder, AdminPlayer, AdminStatus, AdminStore, Admi
|
|
|
9
9
|
export { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, statusOf, summarize } from "./summary.js";
|
|
10
10
|
export type { OrderRow } from "./summary.js";
|
|
11
11
|
export { RAW_PER_XNO, xnoText } from "./amount.js";
|
|
12
|
-
export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
|
|
13
|
-
export type { Character, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
|
|
12
|
+
export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_FRIENDS, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
|
|
13
|
+
export type { Character, Friends, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
|
package/dist/admin/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// adminAccess the guard: one pure function, who may open /admin
|
|
4
4
|
// createAdminHandler the routes, on Request and Response
|
|
5
|
-
// loadGameSettings the
|
|
5
|
+
// loadGameSettings the game switches for the game's own routes
|
|
6
6
|
// createMemoryStore a store in memory, for tests
|
|
7
7
|
// (the conformance suite is its own path: "@nano133/nanoterra/admin/conformance"; the adapters are "/admin/d1" and "/admin/firestore")
|
|
8
8
|
export { ADMIN_SESSION_MS, CLOCK_SKEW_MS, OWNER_PATTERN, adminAccess } from "./guard.js";
|
|
@@ -11,4 +11,4 @@ export { createMemoryStore } from "./memory.js";
|
|
|
11
11
|
export { ADMIN_LOGINS_SHOWN } from "./store.js";
|
|
12
12
|
export { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, statusOf, summarize } from "./summary.js";
|
|
13
13
|
export { RAW_PER_XNO, xnoText } from "./amount.js";
|
|
14
|
-
export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
|
|
14
|
+
export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_FRIENDS, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
|
package/dist/admin/memory.js
CHANGED
|
@@ -14,13 +14,15 @@ export function createMemoryStore(seed = {}) {
|
|
|
14
14
|
pendingAdminRequests: 0,
|
|
15
15
|
pendingGameRequests: 0,
|
|
16
16
|
async readSettings() {
|
|
17
|
-
return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin") };
|
|
17
|
+
return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin"), friends: store.rows.get("setting:friends") };
|
|
18
18
|
},
|
|
19
19
|
async saveSettings(patch) {
|
|
20
20
|
if (patch.character)
|
|
21
21
|
store.rows.set("setting:character", patch.character);
|
|
22
22
|
if (patch.signin)
|
|
23
23
|
store.rows.set("setting:signin", patch.signin);
|
|
24
|
+
if (patch.friends)
|
|
25
|
+
store.rows.set("setting:friends", patch.friends);
|
|
24
26
|
store.writes++;
|
|
25
27
|
},
|
|
26
28
|
async summary(now) {
|
|
@@ -2,6 +2,6 @@ import { type GameState } from "../switches.js";
|
|
|
2
2
|
import { type Api } from "./api.js";
|
|
3
3
|
/**
|
|
4
4
|
* The card for the settings `initial`. It keeps its own state: a press shows "Saving…", then "Saved." or the server's words. `defaultsFile` is where the game's defaults are
|
|
5
|
-
* written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the
|
|
5
|
+
* written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the switches too).
|
|
6
6
|
*/
|
|
7
7
|
export declare function gameCard(api: Api, initial: GameState, defaultsFile?: string, onChange?: (state: GameState) => void): HTMLElement;
|