@nano133/nanoterra 0.8.0 → 0.9.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 +59 -0
- package/LICENSE +3 -0
- package/README.md +363 -25
- package/ai/SPEC.md +65 -3
- package/dist/admin/panel/dom.d.ts +2 -0
- package/dist/admin/panel/dom.js +6 -0
- package/dist/admin/panel/mount.d.ts +3 -0
- package/dist/admin/panel/mount.js +4 -2
- package/dist/emotes/panel/mount.d.ts +5 -0
- package/dist/emotes/panel/mount.js +7 -3
- package/dist/friends/panel/mount.d.ts +5 -0
- package/dist/friends/panel/mount.js +5 -2
- package/dist/options/panel/mount.d.ts +5 -0
- package/dist/options/panel/mount.js +5 -3
- package/dist/player-signin/adapters/d1.d.ts +10 -0
- package/dist/player-signin/adapters/d1.js +108 -0
- package/dist/player-signin/adapters/firestore.d.ts +45 -0
- package/dist/player-signin/adapters/firestore.js +168 -0
- package/dist/player-signin/conformance.d.ts +11 -0
- package/dist/player-signin/conformance.js +160 -0
- package/dist/player-signin/handler.d.ts +64 -0
- package/dist/player-signin/handler.js +364 -0
- package/dist/player-signin/host.d.ts +4 -0
- package/dist/player-signin/host.js +10 -0
- package/dist/player-signin/index.d.ts +9 -0
- package/dist/player-signin/index.js +15 -0
- package/dist/player-signin/memory.d.ts +15 -0
- package/dist/player-signin/memory.js +101 -0
- package/dist/player-signin/panel/index.d.ts +9 -0
- package/dist/player-signin/panel/index.js +7 -0
- package/dist/player-signin/panel/memo.d.ts +12 -0
- package/dist/player-signin/panel/memo.js +41 -0
- package/dist/player-signin/panel/mount.d.ts +21 -0
- package/dist/player-signin/panel/mount.js +105 -0
- package/dist/player-signin/panel/view.d.ts +45 -0
- package/dist/player-signin/panel/view.js +360 -0
- package/dist/player-signin/panel/words.d.ts +8 -0
- package/dist/player-signin/panel/words.js +26 -0
- package/dist/player-signin/scheme-check.d.ts +3 -0
- package/dist/player-signin/scheme-check.js +14 -0
- package/dist/player-signin/scheme.d.ts +13 -0
- package/dist/player-signin/scheme.js +28 -0
- package/dist/player-signin/types.d.ts +116 -0
- package/dist/player-signin/types.js +33 -0
- package/dist/shop/adapters/d1.d.ts +25 -0
- package/dist/shop/adapters/d1.js +200 -0
- package/dist/shop/adapters/firestore.d.ts +53 -0
- package/dist/shop/adapters/firestore.js +245 -0
- package/dist/shop/address.d.ts +14 -0
- package/dist/shop/address.js +104 -0
- package/dist/shop/amount.d.ts +26 -0
- package/dist/shop/amount.js +59 -0
- package/dist/shop/conformance.d.ts +13 -0
- package/dist/shop/conformance.js +203 -0
- package/dist/shop/handler.d.ts +76 -0
- package/dist/shop/handler.js +156 -0
- package/dist/shop/index.d.ts +19 -0
- package/dist/shop/index.js +21 -0
- package/dist/shop/match.d.ts +23 -0
- package/dist/shop/match.js +58 -0
- package/dist/shop/memory.d.ts +13 -0
- package/dist/shop/memory.js +106 -0
- package/dist/shop/nodes.d.ts +47 -0
- package/dist/shop/nodes.js +106 -0
- package/dist/shop/orders.d.ts +47 -0
- package/dist/shop/orders.js +82 -0
- package/dist/shop/panel/index.d.ts +7 -0
- package/dist/shop/panel/index.js +11 -0
- package/dist/shop/panel/looks.d.ts +30 -0
- package/dist/shop/panel/looks.js +61 -0
- package/dist/shop/panel/mount.d.ts +52 -0
- package/dist/shop/panel/mount.js +194 -0
- package/dist/shop/panel/pay.d.ts +24 -0
- package/dist/shop/panel/pay.js +115 -0
- package/dist/shop/panel/qr.d.ts +9 -0
- package/dist/shop/panel/qr.js +281 -0
- package/dist/shop/price.d.ts +23 -0
- package/dist/shop/price.js +43 -0
- package/dist/shop/scan.d.ts +24 -0
- package/dist/shop/scan.js +80 -0
- package/dist/shop/security.d.ts +10 -0
- package/dist/shop/security.js +56 -0
- package/dist/shop/store.d.ts +101 -0
- package/dist/shop/store.js +4 -0
- package/dist/shop/worker.d.ts +43 -0
- package/dist/shop/worker.js +76 -0
- package/package.json +21 -6
- package/src/admin/panel/dom.ts +6 -0
- package/src/admin/panel/mount.ts +7 -2
- package/src/emotes/panel/mount.ts +9 -2
- package/src/friends/panel/mount.ts +8 -2
- package/src/options/panel/mount.ts +8 -3
- package/src/player-signin/adapters/d1.sql +45 -0
- package/src/player-signin/adapters/d1.ts +120 -0
- package/src/player-signin/adapters/firestore.ts +194 -0
- package/src/player-signin/conformance.ts +184 -0
- package/src/player-signin/handler.ts +404 -0
- package/src/player-signin/host.ts +12 -0
- package/src/player-signin/index.ts +20 -0
- package/src/player-signin/memory.ts +94 -0
- package/src/player-signin/panel/index.ts +12 -0
- package/src/player-signin/panel/memo.ts +49 -0
- package/src/player-signin/panel/mount.ts +127 -0
- package/src/player-signin/panel/style.css +473 -0
- package/src/player-signin/panel/view.ts +428 -0
- package/src/player-signin/panel/words.ts +32 -0
- package/src/player-signin/scheme-check.ts +14 -0
- package/src/player-signin/scheme.ts +32 -0
- package/src/player-signin/types.ts +103 -0
- package/src/shop/adapters/d1.sql +52 -0
- package/src/shop/adapters/d1.ts +245 -0
- package/src/shop/adapters/firestore.ts +281 -0
- package/src/shop/address.ts +102 -0
- package/src/shop/amount.ts +66 -0
- package/src/shop/conformance.ts +232 -0
- package/src/shop/handler.ts +202 -0
- package/src/shop/index.ts +30 -0
- package/src/shop/match.ts +63 -0
- package/src/shop/memory.ts +102 -0
- package/src/shop/nodes.ts +115 -0
- package/src/shop/orders.ts +93 -0
- package/src/shop/panel/index.ts +15 -0
- package/src/shop/panel/looks.ts +106 -0
- package/src/shop/panel/mount.ts +241 -0
- package/src/shop/panel/pay.ts +171 -0
- package/src/shop/panel/qr.ts +256 -0
- package/src/shop/panel/style.css +534 -0
- package/src/shop/price.ts +52 -0
- package/src/shop/scan.ts +90 -0
- package/src/shop/security.ts +50 -0
- package/src/shop/store.ts +99 -0
- package/src/shop/worker.ts +107 -0
package/ai/SPEC.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
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
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`), the friends list (`/friends`, with its panel), the options menu (`/options`)
|
|
5
|
+
looks ladder (`/looks`), held input that does not stick (`/input`), the friends list (`/friends`, with its panel), the options menu (`/options`), the emotes (`/emotes`, with the wheel), the player's sign-in (`/player-signin`: a passkey or a wallet app, with
|
|
6
|
+
`@nano133/signin`) and the shop (`/shop`, with its panel). Use only the parts the game needs. Only the player's sign-in and the shop have Nano code; the shop holds no key and sends nothing: it reads the ledger. Read the package README first.
|
|
6
7
|
|
|
7
8
|
## 0. What the owner decides (ask)
|
|
8
9
|
|
|
@@ -12,11 +13,16 @@ looks ladder (`/looks`), held input that does not stick (`/input`), the friends
|
|
|
12
13
|
in the game starter it is `"character": "nanoterra" | "own"` in `game.config.json` (nothing else reads it).
|
|
13
14
|
3. The quality: `"high"` (toon body and ink outline, 2 draws) or `"low"` (no outline, 1 draw, for slow phones).
|
|
14
15
|
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
|
-
5. Does the game sell looks? If yes, the game is the seller (
|
|
16
|
+
5. Does the game sell looks? If yes, the game is the seller; see step 7 (the looks file), step 12 (the player's sign-in) and step 13 (the shop). Ask for the creator's own Nano address (it
|
|
17
|
+
receives every payment), and whether the game runs on a Cloudflare Worker with D1 (the starter's way) or another server with Firestore or its own database.
|
|
16
18
|
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.
|
|
17
19
|
7. The options menu (step 10): which settings the game can really honour (a game with no music offers no Music slider), and whether the game opens it from its own
|
|
18
20
|
pause menu or shows the kit's gear key.
|
|
19
21
|
|
|
22
|
+
8. The player's sign-in (step 12), for a game with a server: which ways (a passkey and the three wallet apps by default), the game's DOMAIN for good (a
|
|
23
|
+
passkey belongs to one domain), the game's frozen passkey SCHEME (the owner approves its texts: they can never change), and whether the sheet offers the
|
|
24
|
+
spare key (yes by default; "no" for a game for small children).
|
|
25
|
+
|
|
20
26
|
**Check:** the owner has named the server (yes or no), the source of the character and the quality.
|
|
21
27
|
|
|
22
28
|
## 1. Install
|
|
@@ -188,7 +194,62 @@ import "@nano133/nanoterra/emotes/style.css";
|
|
|
188
194
|
the middle; a hold on the key and a slide onto a place plays it; Edit refuses a ninth with "Your wheel is full. Take one off first."; the wheel holds after a
|
|
189
195
|
reload. A walk ends Dance; Clap goes on over the walk. On a keyboard: B opens it, 2 plays the second place, Escape closes it.
|
|
190
196
|
|
|
191
|
-
## 12.
|
|
197
|
+
## 12. The player's sign-in (a game with a server)
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
npm install @nano133/nanoterra @nano133/signin
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
1. **The scheme, frozen.** Write it once in one file of the game, with the game's domain in its texts:
|
|
204
|
+
`export const SCHEME = { input: "<domain> player v1", salt: "<domain>", info: "<domain> player seed v1", versionByte: 1 }`. Add a test with two fixed vectors:
|
|
205
|
+
`assert.deepEqual(await schemeVector(SCHEME, "<64 hex>"), { seed, address })` (run it once to read the values, write them in, never change them). Never edit
|
|
206
|
+
the scheme after the first player: a new scheme is a new `versionByte` with new texts. Never take a default from anywhere.
|
|
207
|
+
2. **The store.** D1: add `@nano133/nanoterra/player-signin/d1.sql` to the migrations (on the game starter it keeps every table and session and adds
|
|
208
|
+
`signin_once`) and `createD1PlayerSignin(env.DB)`. Firestore: `createFirestorePlayerSignin(db)` and a TTL policy on `deleteAt` for the three collections.
|
|
209
|
+
Another database: a store that passes `playerSigninStoreConformance`.
|
|
210
|
+
3. **The handler.** `createPlayerSignin({ store, siteAddress, limit, visitor, personCheck })`; call `handle(request)` first for every request under `/api/`
|
|
211
|
+
(null: not its route). Give `limit` a limiter that counts for the whole game (D1), and `visitor` a keyed hash of the IP address. A game whose admin signs in
|
|
212
|
+
on the same paths (the starter) passes `sharedPaths: true`. Set `domain` once the game has its own domain.
|
|
213
|
+
4. **The player.** Everything that needs the player reads `signin.player(request)` (the shop's and the friends' `player` hook): never an address from the
|
|
214
|
+
body, and never the admin's login. The game's code exchange makes its session with `signin.signIn(request, address, "nanoterra")`.
|
|
215
|
+
5. **"Delete my data"** calls `signin.forget(player)` with the other stores' `forget`. A cron calls `signin.clean()`.
|
|
216
|
+
6. **The sheet.** `mountPlayerSignin(document.body, { name, scheme: SCHEME, qr, personCheck, onSignedIn })` and the stylesheet; the account key and the
|
|
217
|
+
shop's "sign in first" call `open()`. On `*.workers.dev` the sheet offers no passkey (it would be lost at the move to the game's own
|
|
218
|
+
domain); give `mountAdmin` `playerPasskeys: true` so the admin says so. Do not write your own passkey or wallet code, and never store, log or send the seed.
|
|
219
|
+
|
|
220
|
+
**Check:** on the game's real https address, with a phone (iOS 18 or a recent Android): Make a passkey, see "Keep a spare key", sign in, reload: still
|
|
221
|
+
signed in; sign out, "Use my passkey": the same address. A desktop browser with no PRF shows the passkey last and its plain words. A wallet sign-in works only
|
|
222
|
+
on a public https address (a wallet cannot reach localhost). The tests hold the scheme's vectors.
|
|
223
|
+
|
|
224
|
+
## 13. The shop (only if the game sells looks)
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { createShop } from "@nano133/nanoterra/shop/worker"; // a Worker with D1; else createShopHandler from "@nano133/nanoterra/shop"
|
|
228
|
+
import { mountShop } from "@nano133/nanoterra/shop/panel";
|
|
229
|
+
import "@nano133/nanoterra/shop/style.css";
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- The money goes straight from the player's wallet to the creator's address. The server holds **no key**: never add a seed, a private key or a send. The
|
|
233
|
+
shop reads the ledger and matches a unique, clean amount exactly. Do not copy the starter's `orders`, `match`, `sweep`, `price` or `nodes` files: install them.
|
|
234
|
+
- D1: add `@nano133/nanoterra/shop/d1.sql` to the migrations (a game made from the starter has the tables already: no migration). Firestore:
|
|
235
|
+
`createFirestoreShop(db)`. Another database: write a `ShopStore` and pass `shopStoreConformance` (each case its own test, `SHOP_CASE_TIMEOUT_MS`).
|
|
236
|
+
- `player(req)` reads the game's own PLAYER sign-in (never the admin's). The shop draws no sign-in: give the panel `signIn()`, which opens the game's
|
|
237
|
+
sign-in and answers true when a player is signed in.
|
|
238
|
+
- Settings (a Worker): `RECEIVE_ADDRESS` (the creator's address), `TEST_MODE` (`"on"` until the owner says sell), `NODE_API_KEY` (a secret, optional),
|
|
239
|
+
`TURNSTILE_SITE_KEY` and the secret `TURNSTILE_SECRET` (optional, both). Secrets go in the platform's secret store, never in code or a log.
|
|
240
|
+
- **Test mode stays on until the owner's own address is set AND test mode is off.** In test mode the pay sheet says "TEST MODE: DO NOT PAY", with no QR
|
|
241
|
+
code, no address and the wallet key off. Never show a pay screen to an address nobody holds the key for without that stamp.
|
|
242
|
+
- Add the cron (`*/10 * * * *`) that calls `shop.scan()`, and give the admin `check: (at) => shop.scan(at)`.
|
|
243
|
+
- Mount the panel with `onOwned(lookId)` (the game adds the look and wears it); `key: false` and `open()` when the game has its own Looks key.
|
|
244
|
+
- Merge `SHOP_CSP` into the game's Content Security Policy with `mergeCsp`; never replace the game's policy.
|
|
245
|
+
- "Delete my data" calls `store.forget(player, now)`; with `waitUntil`, ask the player to try again then.
|
|
246
|
+
- Tests: the shop's money tests are the package's. The game's tests read the game's own looks file and test only the game's own routes and wiring.
|
|
247
|
+
|
|
248
|
+
**Check:** with `TEST_MODE` on, Buy shows the stamp and no QR code, and the answer of `POST /api/orders` holds no `address` and no `uri`. With the owner's
|
|
249
|
+
address and `TEST_MODE` off, the pay sheet shows the QR code and the exact amount; a second player gets the next step (0.0001 XNO more); another player's
|
|
250
|
+
order id answers 404; a payment of one raw too little is not matched. Test with a small real payment only when the owner says so, to the owner's own address.
|
|
251
|
+
|
|
252
|
+
## 14. Do not
|
|
192
253
|
|
|
193
254
|
- Do not edit the geometry, the skeleton or the toon and ink materials.
|
|
194
255
|
- 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
|
|
@@ -197,3 +258,4 @@ reload. A walk ends Dance; Clap goes on over the walk. On a keyboard: B opens it
|
|
|
197
258
|
thing gives `hideTail: true` and `hideWings: true`, so a tail or wings do not poke through it.
|
|
198
259
|
The game loads the model and frees it; the character never does.
|
|
199
260
|
- Do not use a look or a record for a game's rules, a score or a payment: it is display only.
|
|
261
|
+
- Do not give a look on anything but a paid order (`"paid"`), and never give one on a sign-in or a ticket alone.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
type Attrs = Record<string, string | number | boolean | undefined | ((e: Event) => void)>;
|
|
2
2
|
type Child = Node | string | null | undefined | false;
|
|
3
3
|
export declare function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs?: Attrs, ...children: Child[]): HTMLElementTagNameMap[K];
|
|
4
|
+
/** Takes the keyboard focus out of `within` (to the page), so a game's own keys reach the game again. */
|
|
5
|
+
export declare function releaseFocus(within: Element): void;
|
|
4
6
|
/** "nano_1abc…wxyz" */
|
|
5
7
|
export declare const shortAddress: (a: string) => string;
|
|
6
8
|
export declare function copy(text: string, button: HTMLButtonElement): Promise<void>;
|
package/dist/admin/panel/dom.js
CHANGED
|
@@ -18,6 +18,12 @@ export function h(tag, attrs = {}, ...children) {
|
|
|
18
18
|
el.append(c);
|
|
19
19
|
return el;
|
|
20
20
|
}
|
|
21
|
+
/** Takes the keyboard focus out of `within` (to the page), so a game's own keys reach the game again. */
|
|
22
|
+
export function releaseFocus(within) {
|
|
23
|
+
const now = document.activeElement;
|
|
24
|
+
if (now instanceof HTMLElement && within.contains(now))
|
|
25
|
+
now.blur();
|
|
26
|
+
}
|
|
21
27
|
/** "nano_1abc…wxyz" */
|
|
22
28
|
export const shortAddress = (a) => `${a.slice(0, 10)}…${a.slice(-6)}`;
|
|
23
29
|
export async function copy(text, button) {
|
|
@@ -19,6 +19,9 @@ export type MountOptions = {
|
|
|
19
19
|
repoHelp?: string;
|
|
20
20
|
/** What the Sign-ins card says about what the game keeps and for how long ("30 days"). Default: only the time, the rough country and a short browser name. */
|
|
21
21
|
loginsNote?: string;
|
|
22
|
+
/** The game offers its players a passkey (`@nano133/nanoterra/player-signin`): on a preview address (`*.workers.dev`) the banner says that passkeys turn
|
|
23
|
+
* on with the game's own domain. */
|
|
24
|
+
playerPasskeys?: boolean;
|
|
22
25
|
/** The `fetch` to use (default: the page's). */
|
|
23
26
|
fetch?: typeof fetch;
|
|
24
27
|
};
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// The page: a top bar (who is signed in, Live or Test mode, the light/dark theme key, Sign out), what is still off (one banner), four numbers (earned, open now, to send back, players;
|
|
5
5
|
// each opens its list), then five tabs: Overview, Cosmetics (the Shelf), Orders, Players and Settings (the Game card, the sign-ins, the facts). Every tab is
|
|
6
6
|
// made once; a new read of the summary fills them again and keeps the chosen tab, the Shelf's draft, a search and the Game card.
|
|
7
|
+
import { PASSKEY_PREVIEW_NOTE, previewHost } from "../../player-signin/host.js";
|
|
7
8
|
import { ApiError, createApi } from "./api.js";
|
|
8
9
|
import { copyKey, h, plural, shortAddress } from "./dom.js";
|
|
9
10
|
import { gameCard } from "./gameCard.js";
|
|
@@ -158,8 +159,9 @@ export function mountAdmin(root, options) {
|
|
|
158
159
|
update(s) {
|
|
159
160
|
who.replaceChildren(h("span", { class: "who-label" }, "Signed in as"), s.address ? h("span", { class: "who-address" }, h("code", { title: s.address }, shortAddress(s.address)), copyKey(s.address, "Copy your address", icon("copy", 14))) : h("span", {}, "the game's owner"));
|
|
160
161
|
chips.replaceChildren(h("span", { class: `chip ${s.testMode ? "chip-warn" : "chip-ok"}`, title: s.testMode ? "Payments are not real" : "Payments are real" }, h("span", { class: "chip-dot", "aria-hidden": "true" }), s.testMode ? "Test mode" : "Live"), s.turnstile === undefined ? "" : h("span", { class: `chip ${s.turnstile ? "chip-ok" : "chip-warn"}` }, h("span", { class: "chip-dot", "aria-hidden": "true" }), s.turnstile ? "Bot check on" : "Bot check off"));
|
|
161
|
-
|
|
162
|
-
banner.
|
|
162
|
+
const notes = options.playerPasskeys && previewHost(location.hostname) ? [...s.warnings, PASSKEY_PREVIEW_NOTE] : s.warnings;
|
|
163
|
+
banner.hidden = !notes.length;
|
|
164
|
+
banner.replaceChildren(h("div", { class: "banner-icon", "aria-hidden": "true" }, icon("alert", 20)), h("div", {}, h("h2", {}, "Check these"), h("ul", {}, ...notes.map((w) => h("li", {}, w)))));
|
|
163
165
|
const ownedLooks = s.players.reduce((n, p) => n + p.looks.length, 0);
|
|
164
166
|
const late = s.totals.paidLate.count;
|
|
165
167
|
kpis.replaceChildren(kpi("earned", "coin", "Earned", `${s.totals.paid.xno} XNO`, `$${s.totals.paid.usd} · ${plural(s.totals.paid.count, "order")}`, () => go("orders", "paid")), kpi("open", "clock", "Open now", String(s.counts.open), `${s.counts.expired} expired`, () => go("orders", "open")), kpi("late", "back", "To send back", `${s.totals.paidLate.xno} XNO`, late ? `$${s.totals.paidLate.usd} · ${plural(late, "order")}` : "Nothing to send back", () => go("orders", "paid_late"), late > 0), kpi("players", "players", "Players", String(s.players.length), `${plural(ownedLooks, "look")} owned`, () => go("players")));
|
|
@@ -34,6 +34,11 @@ export type EmotesMountOptions = {
|
|
|
34
34
|
theme?: "light" | "dark" | "device";
|
|
35
35
|
/** Where the wheel is kept (default: the page's localStorage). null keeps it only for the life of the page. */
|
|
36
36
|
storage?: Pick<Storage, "getItem" | "setItem"> | null;
|
|
37
|
+
/**
|
|
38
|
+
* Where the keyboard focus goes when the wheel closes. "game" (the default): out of the key and the wheel, to the page, so Space and the game's
|
|
39
|
+
* other keys reach the game again (a focused key would take Space and open the wheel again). "key": back to the key, for a page with no keys of its own.
|
|
40
|
+
*/
|
|
41
|
+
focusAfterClose?: "game" | "key";
|
|
37
42
|
};
|
|
38
43
|
export type MountedEmotes = {
|
|
39
44
|
open(): void;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// The list is Nanoterra's (each id names one of EMOTES: its name and picture come from the kit), the game's own ({ id, label, icon }), or both. The
|
|
11
11
|
// wheel only calls `onPlay(id)`: the game plays it (Nanoterra's on the kit's character: createEmotes(...).play, from "@nano133/nanoterra/emotes/player").
|
|
12
|
-
import { h } from "../../admin/panel/dom.js";
|
|
12
|
+
import { h, releaseFocus } from "../../admin/panel/dom.js";
|
|
13
13
|
import { icon } from "../../admin/panel/icons.js";
|
|
14
14
|
import { EMOTE_ICONS_URL } from "../icons.js";
|
|
15
15
|
import { EMOTES, emoteOf, isEmote } from "../list.js";
|
|
@@ -242,8 +242,12 @@ export function mountEmotes(root, options) {
|
|
|
242
242
|
layer.hidden = true;
|
|
243
243
|
layer.dataset.open = "false";
|
|
244
244
|
key.setAttribute("aria-expanded", "false");
|
|
245
|
-
if (
|
|
246
|
-
key.
|
|
245
|
+
if (options.focusAfterClose === "key") {
|
|
246
|
+
if (focusKey && !key.hidden)
|
|
247
|
+
key.focus();
|
|
248
|
+
}
|
|
249
|
+
else
|
|
250
|
+
releaseFocus(wrap);
|
|
247
251
|
}
|
|
248
252
|
editKey.addEventListener("click", () => {
|
|
249
253
|
edit = !edit;
|
|
@@ -21,6 +21,11 @@ export type FriendsMountOptions = {
|
|
|
21
21
|
fetch?: typeof fetch;
|
|
22
22
|
/** Asks the player a yes or no question (default: a question in the panel, with a key for each answer; never a browser dialog). */
|
|
23
23
|
confirm?: (question: string) => boolean | Promise<boolean>;
|
|
24
|
+
/**
|
|
25
|
+
* Where the keyboard focus goes when the panel closes. "game" (the default): out of the key and the panel, to the page, so Space and the game's
|
|
26
|
+
* other keys reach the game again (a focused key would take Space and open the panel again). "key": back to the key, for a page with no keys of its own.
|
|
27
|
+
*/
|
|
28
|
+
focusAfterClose?: "game" | "key";
|
|
24
29
|
};
|
|
25
30
|
export type MountedFriends = {
|
|
26
31
|
/** Sets where the player is (friends see it). Null: no place. */
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// The panel is phone first: a sheet from the bottom on a phone held upright, from the side on a wide or sideways screen; every key is 44 px.
|
|
13
13
|
import { ApiError, createApi } from "../../admin/panel/api.js";
|
|
14
|
-
import { h } from "../../admin/panel/dom.js";
|
|
14
|
+
import { h, releaseFocus } from "../../admin/panel/dom.js";
|
|
15
15
|
import { icon } from "../../admin/panel/icons.js";
|
|
16
16
|
import { readWindowMessage, toWindow, whereText, windowUrl } from "./window.js";
|
|
17
17
|
export const FRIENDS_TIMING = { viewOpenMs: 20_000, viewClosedMs: 60_000, pulseMs: 45_000 };
|
|
@@ -69,7 +69,10 @@ export function mountFriends(root, options) {
|
|
|
69
69
|
panel.dataset.open = "false";
|
|
70
70
|
key.setAttribute("aria-expanded", "false");
|
|
71
71
|
onClose();
|
|
72
|
-
key
|
|
72
|
+
if (options.focusAfterClose === "key")
|
|
73
|
+
key.focus();
|
|
74
|
+
else
|
|
75
|
+
releaseFocus(wrap);
|
|
73
76
|
};
|
|
74
77
|
key.addEventListener("click", () => (isOpen ? close() : open()));
|
|
75
78
|
closeKey.addEventListener("click", close);
|
|
@@ -11,6 +11,11 @@ export type OptionsMountOptions = {
|
|
|
11
11
|
onRangeDone?: (key: string, value: number) => void;
|
|
12
12
|
/** Asks the player a yes or no question (default: a question in the panel, with a key for each answer; never a browser dialog). */
|
|
13
13
|
confirm?: (question: string) => boolean | Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Where the keyboard focus goes when the panel closes. "game" (the default): out of the key and the panel, to the page, so Space and the game's
|
|
16
|
+
* other keys reach the game again (a focused key would take Space and open the panel again). "key": back to the key, for a page with no keys of its own.
|
|
17
|
+
*/
|
|
18
|
+
focusAfterClose?: "game" | "key";
|
|
14
19
|
};
|
|
15
20
|
export type MountedOptions = {
|
|
16
21
|
open(): void;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// "Reset to defaults" asks in the panel (a question and a key for each answer), never in a browser dialog. Escape answers the question first, then closes.
|
|
12
12
|
// The panel is phone first: a sheet from the bottom on a phone held upright, from the right on a wide or sideways screen; every key is 44 px or more.
|
|
13
13
|
// `key: false` draws no key, for a game that opens the panel from its own pause menu (`open()`).
|
|
14
|
-
import { h } from "../../admin/panel/dom.js";
|
|
14
|
+
import { h, releaseFocus } from "../../admin/panel/dom.js";
|
|
15
15
|
import { icon } from "../../admin/panel/icons.js";
|
|
16
16
|
import { keyLabel } from "../core.js";
|
|
17
17
|
export const TAB_WORDS = { basic: "Basic", advanced: "Advanced" };
|
|
@@ -273,8 +273,10 @@ export function mountOptions(root, store, options = {}) {
|
|
|
273
273
|
panel.dataset.open = scrim.dataset.open = "false";
|
|
274
274
|
key?.setAttribute("aria-expanded", "false");
|
|
275
275
|
options.onClose?.();
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
if (options.focusAfterClose === "key")
|
|
277
|
+
(key ?? (returnTo?.isConnected ? returnTo : null))?.focus();
|
|
278
|
+
else
|
|
279
|
+
releaseFocus(wrap);
|
|
278
280
|
};
|
|
279
281
|
key?.addEventListener("click", () => (isOpen ? close() : open()));
|
|
280
282
|
closeKey.addEventListener("click", close);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { D1Like } from "../../admin/adapters/d1.js";
|
|
2
|
+
import { type PlayerSigninStore } from "../types.js";
|
|
3
|
+
export type { D1Like, D1StatementLike } from "../../admin/adapters/d1.js";
|
|
4
|
+
/** The tables' names. Default: the game starter's. A game with other names changes d1.sql to match. */
|
|
5
|
+
export type D1PlayerSigninTables = {
|
|
6
|
+
requests?: string;
|
|
7
|
+
sessions?: string;
|
|
8
|
+
once?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function createD1PlayerSignin(db: D1Like, tables?: D1PlayerSigninTables): PlayerSigninStore;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The player sign-in's store for a game on Cloudflare D1. The tables are in ./d1.sql (the package ships it: "@nano133/nanoterra/player-signin/d1.sql");
|
|
2
|
+
// add it to the game's migrations. They are the game starter's own `signin_requests` and `sessions` (so a starter's data carries over: every
|
|
3
|
+
// player stays signed in) and one more, `signin_once`.
|
|
4
|
+
//
|
|
5
|
+
// import { createD1PlayerSignin } from "@nano133/nanoterra/player-signin/d1";
|
|
6
|
+
// const store = createD1PlayerSignin(env.DB);
|
|
7
|
+
//
|
|
8
|
+
// It reads and writes ONLY the player's rows (scope = 'play'): a game whose admin signs in through the same tables (the starter's scope 'admin')
|
|
9
|
+
// keeps its admin rows, and this adapter never reads, delivers, ends or deletes one. D1 has no transaction that reads and then writes, so each change
|
|
10
|
+
// is ONE statement that holds its own check, and a delivery and its session are one batch (the session is written only when the delivery changed a row).
|
|
11
|
+
import { WAYS } from "../types.js";
|
|
12
|
+
const toRequest = (r) => ({
|
|
13
|
+
id: r.rid ?? r.id,
|
|
14
|
+
starter: r.starter,
|
|
15
|
+
status: r.status === "signedin" ? "signedin" : "waiting",
|
|
16
|
+
expiresAt: Number(r.expires_at),
|
|
17
|
+
...(r.label ? { label: r.label } : {}),
|
|
18
|
+
...(r.nonce ? { nonce: r.nonce } : {}),
|
|
19
|
+
...(r.issued_at ? { issuedAt: Number(r.issued_at) } : {}),
|
|
20
|
+
...(r.account ? { account: r.account } : {}),
|
|
21
|
+
});
|
|
22
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
23
|
+
const changes = (res) => Number(res?.meta?.changes ?? 0);
|
|
24
|
+
export function createD1PlayerSignin(db, tables = {}) {
|
|
25
|
+
const T = { requests: tables.requests ?? "signin_requests", sessions: tables.sessions ?? "sessions", once: tables.once ?? "signin_once" };
|
|
26
|
+
for (const n of Object.values(T))
|
|
27
|
+
if (!NAME.test(n))
|
|
28
|
+
throw new Error(`not a table name: ${n}`);
|
|
29
|
+
const COLS = "id, rid, starter, status, expires_at, nonce, issued_at, label, account";
|
|
30
|
+
const stores = new Map();
|
|
31
|
+
for (const way of WAYS) {
|
|
32
|
+
stores.set(way, {
|
|
33
|
+
async create(key, r) {
|
|
34
|
+
await db
|
|
35
|
+
.prepare(`INSERT INTO ${T.requests} (id, rid, way, starter, status, expires_at, nonce, issued_at, label, scope) VALUES (?, ?, ?, ?, 'waiting', ?, ?, ?, ?, 'play')`)
|
|
36
|
+
.bind(key, r.id, way, r.starter, r.expiresAt, r.nonce ?? null, r.issuedAt ?? null, r.label ?? null)
|
|
37
|
+
.run();
|
|
38
|
+
},
|
|
39
|
+
async get(key) {
|
|
40
|
+
const r = await db.prepare(`SELECT ${COLS} FROM ${T.requests} WHERE id = ? AND way = ? AND scope = 'play'`).bind(key, way).first();
|
|
41
|
+
return r ? toRequest(r) : null;
|
|
42
|
+
},
|
|
43
|
+
async find(id) {
|
|
44
|
+
const r = await db.prepare(`SELECT ${COLS} FROM ${T.requests} WHERE rid = ? AND way = ? AND scope = 'play'`).bind(id, way).first();
|
|
45
|
+
return r ? { key: r.id, request: toRequest(r) } : null;
|
|
46
|
+
},
|
|
47
|
+
async finish(key, account) {
|
|
48
|
+
return changes(await db.prepare(`UPDATE ${T.requests} SET status = 'signedin', account = ? WHERE id = ? AND way = ? AND status = 'waiting' AND scope = 'play'`).bind(account, key, way).run()) === 1;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const insertSession = (s, afterChange) => {
|
|
53
|
+
const cols = `INSERT INTO ${T.sessions} (id_hash, address, created_at, expires_at, via, scope)`;
|
|
54
|
+
return db.prepare(afterChange ? `${cols} SELECT ?, ?, ?, ?, ?, 'play' WHERE changes() = 1` : `${cols} VALUES (?, ?, ?, ?, ?, 'play')`).bind(s.idHash, s.address, s.createdAt, s.expiresAt, s.via);
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
requests: (way) => {
|
|
58
|
+
const s = stores.get(way);
|
|
59
|
+
if (!s)
|
|
60
|
+
throw new Error(`no such way: ${String(way)}`);
|
|
61
|
+
return s;
|
|
62
|
+
},
|
|
63
|
+
async deliver(way, key, session) {
|
|
64
|
+
const done = await db.batch([
|
|
65
|
+
db.prepare(`UPDATE ${T.requests} SET delivered = 1 WHERE id = ? AND way = ? AND delivered = 0 AND status = 'signedin' AND scope = 'play'`).bind(key, way),
|
|
66
|
+
insertSession(session, true),
|
|
67
|
+
]);
|
|
68
|
+
return changes(done[0]) === 1;
|
|
69
|
+
},
|
|
70
|
+
async createSession(session) {
|
|
71
|
+
await insertSession(session, false).run();
|
|
72
|
+
},
|
|
73
|
+
async session(idHash, now) {
|
|
74
|
+
const r = await db
|
|
75
|
+
.prepare(`SELECT id_hash, address, created_at, expires_at, via FROM ${T.sessions} WHERE id_hash = ? AND expires_at > ? AND scope = 'play'`)
|
|
76
|
+
.bind(idHash, now)
|
|
77
|
+
.first();
|
|
78
|
+
return r ? { idHash: r.id_hash, address: r.address, createdAt: Number(r.created_at), expiresAt: Number(r.expires_at), via: r.via } : null;
|
|
79
|
+
},
|
|
80
|
+
async endSession(idHash) {
|
|
81
|
+
await db.prepare(`DELETE FROM ${T.sessions} WHERE id_hash = ? AND scope = 'play'`).bind(idHash).run();
|
|
82
|
+
},
|
|
83
|
+
async useOnce(key, ttlMs, address) {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
// A row past its time may be taken again (its signature's 5 minutes are long over); a live one may not.
|
|
86
|
+
const res = await db
|
|
87
|
+
.prepare(`INSERT INTO ${T.once} (key, address, until) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET address = excluded.address, until = excluded.until WHERE ${T.once}.until <= ?`)
|
|
88
|
+
.bind(key, address, now + ttlMs, now)
|
|
89
|
+
.run();
|
|
90
|
+
return changes(res) === 1;
|
|
91
|
+
},
|
|
92
|
+
async forget(address) {
|
|
93
|
+
const done = await db.batch([
|
|
94
|
+
db.prepare(`DELETE FROM ${T.sessions} WHERE address = ? AND scope = 'play'`).bind(address),
|
|
95
|
+
db.prepare(`DELETE FROM ${T.requests} WHERE account = ? AND scope = 'play'`).bind(address),
|
|
96
|
+
db.prepare(`DELETE FROM ${T.once} WHERE address = ?`).bind(address),
|
|
97
|
+
]);
|
|
98
|
+
return done.reduce((n, r) => n + changes(r), 0);
|
|
99
|
+
},
|
|
100
|
+
async clean(now) {
|
|
101
|
+
await db.batch([
|
|
102
|
+
db.prepare(`DELETE FROM ${T.requests} WHERE expires_at < ? AND scope = 'play'`).bind(now - 2 * 3_600_000),
|
|
103
|
+
db.prepare(`DELETE FROM ${T.sessions} WHERE expires_at <= ? AND scope = 'play'`).bind(now),
|
|
104
|
+
db.prepare(`DELETE FROM ${T.once} WHERE until <= ?`).bind(now),
|
|
105
|
+
]);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type PlayerSigninStore } from "../types.js";
|
|
2
|
+
export type PlayerSigninDocSnap = {
|
|
3
|
+
exists: boolean;
|
|
4
|
+
data(): Record<string, unknown> | undefined;
|
|
5
|
+
};
|
|
6
|
+
export interface PlayerSigninDocRef {
|
|
7
|
+
get(): Promise<PlayerSigninDocSnap>;
|
|
8
|
+
}
|
|
9
|
+
export type PlayerSigninQuerySnap = {
|
|
10
|
+
docs: {
|
|
11
|
+
id: string;
|
|
12
|
+
ref: PlayerSigninDocRef;
|
|
13
|
+
data(): Record<string, unknown>;
|
|
14
|
+
}[];
|
|
15
|
+
};
|
|
16
|
+
export interface PlayerSigninQuery {
|
|
17
|
+
where(field: string, op: "==" | "<" | "<=", value: unknown): PlayerSigninQuery;
|
|
18
|
+
limit(n: number): PlayerSigninQuery;
|
|
19
|
+
get(): Promise<PlayerSigninQuerySnap>;
|
|
20
|
+
}
|
|
21
|
+
export interface PlayerSigninCollection extends PlayerSigninQuery {
|
|
22
|
+
doc(id: string): PlayerSigninDocRef;
|
|
23
|
+
}
|
|
24
|
+
/** A transaction: every read before the first write, as Firestore asks. */
|
|
25
|
+
export interface PlayerSigninTransaction {
|
|
26
|
+
get(ref: PlayerSigninDocRef): Promise<PlayerSigninDocSnap>;
|
|
27
|
+
set(ref: PlayerSigninDocRef, data: Record<string, unknown>, options?: {
|
|
28
|
+
merge?: boolean;
|
|
29
|
+
}): unknown;
|
|
30
|
+
delete(ref: PlayerSigninDocRef): unknown;
|
|
31
|
+
}
|
|
32
|
+
export interface PlayerSigninBatch {
|
|
33
|
+
delete(ref: PlayerSigninDocRef): unknown;
|
|
34
|
+
commit(): Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
export interface FirestorePlayerSigninLike {
|
|
37
|
+
collection(name: string): PlayerSigninCollection;
|
|
38
|
+
runTransaction<T>(fn: (tx: PlayerSigninTransaction) => Promise<T>): Promise<T>;
|
|
39
|
+
batch(): PlayerSigninBatch;
|
|
40
|
+
}
|
|
41
|
+
export declare function createFirestorePlayerSignin(db: FirestorePlayerSigninLike, collections?: {
|
|
42
|
+
requests?: string;
|
|
43
|
+
sessions?: string;
|
|
44
|
+
once?: string;
|
|
45
|
+
}): PlayerSigninStore;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// The player sign-in's store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface
|
|
2
|
+
// below, which the Admin SDK's `Firestore` fits. It reads and writes ONLY its three collections, and only the player's documents (scope "play").
|
|
3
|
+
//
|
|
4
|
+
// import { createFirestorePlayerSignin } from "@nano133/nanoterra/player-signin/firestore";
|
|
5
|
+
// const store = createFirestorePlayerSignin(getFirestore());
|
|
6
|
+
//
|
|
7
|
+
// The data model (the three collection names can be changed):
|
|
8
|
+
// signin_requests/{key} { id, way, starter, status, expiresAt, nonce?, issuedAt?, label?, account?, delivered, scope: "play", deleteAt }
|
|
9
|
+
// sessions/{idHash} { address, createdAt, expiresAt, via, scope: "play", deleteAt }
|
|
10
|
+
// signin_once/{key} { address, until, deleteAt }
|
|
11
|
+
// Times are ms numbers; `deleteAt` is a Date (a Timestamp in Firestore): set a TTL policy on `deleteAt` for each collection. `{key}` is the request's
|
|
12
|
+
// id (Berry Wallet, Nautilus) or the SHA-256 of its nonce (NanChat); `{idHash}` the SHA-256 of the session's token. Every change that reads first is a
|
|
13
|
+
// transaction. Indexes: Firestore's own single-field indexes are enough (`id`, `address`, `account`, `expiresAt`, `until`).
|
|
14
|
+
import { WAYS } from "../types.js";
|
|
15
|
+
const DAY = 86_400_000;
|
|
16
|
+
/** At most this many documents go in one batch (Firestore's limit is 500). */
|
|
17
|
+
const BATCH = 400;
|
|
18
|
+
const text = (v) => (typeof v === "string" ? v : undefined);
|
|
19
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
20
|
+
function requestOf(d, key, way) {
|
|
21
|
+
if (!d || d.scope !== "play" || d.way !== way || typeof d.starter !== "string" || num(d.expiresAt) === undefined)
|
|
22
|
+
return null;
|
|
23
|
+
const nonce = text(d.nonce);
|
|
24
|
+
const issuedAt = num(d.issuedAt);
|
|
25
|
+
const label = text(d.label);
|
|
26
|
+
const account = text(d.account);
|
|
27
|
+
return {
|
|
28
|
+
id: text(d.id) ?? key,
|
|
29
|
+
starter: d.starter,
|
|
30
|
+
status: d.status === "signedin" ? "signedin" : "waiting",
|
|
31
|
+
expiresAt: num(d.expiresAt),
|
|
32
|
+
...(label !== undefined ? { label } : {}),
|
|
33
|
+
...(nonce !== undefined ? { nonce } : {}),
|
|
34
|
+
...(issuedAt !== undefined ? { issuedAt } : {}),
|
|
35
|
+
...(account !== undefined ? { account } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function createFirestorePlayerSignin(db, collections = {}) {
|
|
39
|
+
const requestsCol = () => db.collection(collections.requests ?? "signin_requests");
|
|
40
|
+
const sessionsCol = () => db.collection(collections.sessions ?? "sessions");
|
|
41
|
+
const onceCol = () => db.collection(collections.once ?? "signin_once");
|
|
42
|
+
const sessionData = (s) => ({ address: s.address, createdAt: s.createdAt, expiresAt: s.expiresAt, via: s.via, scope: "play", deleteAt: new Date(s.expiresAt) });
|
|
43
|
+
const stores = new Map();
|
|
44
|
+
for (const way of WAYS) {
|
|
45
|
+
stores.set(way, {
|
|
46
|
+
async create(key, r) {
|
|
47
|
+
const ref = requestsCol().doc(key);
|
|
48
|
+
await db.runTransaction(async (tx) => {
|
|
49
|
+
if ((await tx.get(ref)).exists)
|
|
50
|
+
throw new Error("a request with this key exists");
|
|
51
|
+
tx.set(ref, {
|
|
52
|
+
id: r.id,
|
|
53
|
+
way,
|
|
54
|
+
starter: r.starter,
|
|
55
|
+
status: "waiting",
|
|
56
|
+
expiresAt: r.expiresAt,
|
|
57
|
+
...(r.nonce !== undefined ? { nonce: r.nonce } : {}),
|
|
58
|
+
...(r.issuedAt !== undefined ? { issuedAt: r.issuedAt } : {}),
|
|
59
|
+
...(r.label !== undefined ? { label: r.label } : {}),
|
|
60
|
+
delivered: false,
|
|
61
|
+
scope: "play",
|
|
62
|
+
deleteAt: new Date(r.expiresAt + DAY),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
async get(key) {
|
|
67
|
+
return requestOf((await requestsCol().doc(key).get()).data(), key, way);
|
|
68
|
+
},
|
|
69
|
+
async find(id) {
|
|
70
|
+
for (const d of (await requestsCol().where("id", "==", id).limit(10).get()).docs) {
|
|
71
|
+
const request = requestOf(d.data(), d.id, way);
|
|
72
|
+
if (request)
|
|
73
|
+
return { key: d.id, request };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
},
|
|
77
|
+
async finish(key, account) {
|
|
78
|
+
const ref = requestsCol().doc(key);
|
|
79
|
+
return db.runTransaction(async (tx) => {
|
|
80
|
+
const cur = requestOf((await tx.get(ref)).data(), key, way);
|
|
81
|
+
if (!cur || cur.status !== "waiting")
|
|
82
|
+
return false;
|
|
83
|
+
tx.set(ref, { status: "signedin", account }, { merge: true });
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async function deleteAll(docs) {
|
|
90
|
+
for (let i = 0; i < docs.length; i += BATCH) {
|
|
91
|
+
const b = db.batch();
|
|
92
|
+
for (const d of docs.slice(i, i + BATCH))
|
|
93
|
+
b.delete(d.ref);
|
|
94
|
+
await b.commit();
|
|
95
|
+
}
|
|
96
|
+
return docs.length;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
requests: (way) => {
|
|
100
|
+
const s = stores.get(way);
|
|
101
|
+
if (!s)
|
|
102
|
+
throw new Error(`no such way: ${String(way)}`);
|
|
103
|
+
return s;
|
|
104
|
+
},
|
|
105
|
+
async deliver(way, key, session) {
|
|
106
|
+
const ref = requestsCol().doc(key);
|
|
107
|
+
const sref = sessionsCol().doc(session.idHash);
|
|
108
|
+
return db.runTransaction(async (tx) => {
|
|
109
|
+
const raw = (await tx.get(ref)).data();
|
|
110
|
+
const cur = requestOf(raw, key, way);
|
|
111
|
+
await tx.get(sref);
|
|
112
|
+
if (!cur || cur.status !== "signedin" || raw?.delivered === true)
|
|
113
|
+
return false;
|
|
114
|
+
tx.set(ref, { delivered: true }, { merge: true });
|
|
115
|
+
tx.set(sref, sessionData(session));
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
async createSession(session) {
|
|
120
|
+
const ref = sessionsCol().doc(session.idHash);
|
|
121
|
+
await db.runTransaction(async (tx) => {
|
|
122
|
+
if ((await tx.get(ref)).exists)
|
|
123
|
+
throw new Error("a session with this id exists");
|
|
124
|
+
tx.set(ref, sessionData(session));
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
async session(idHash, now) {
|
|
128
|
+
const d = (await sessionsCol().doc(idHash).get()).data();
|
|
129
|
+
if (!d || d.scope !== "play" || typeof d.address !== "string")
|
|
130
|
+
return null;
|
|
131
|
+
const expiresAt = num(d.expiresAt);
|
|
132
|
+
if (expiresAt === undefined || expiresAt <= now)
|
|
133
|
+
return null;
|
|
134
|
+
return { idHash, address: d.address, createdAt: num(d.createdAt) ?? 0, expiresAt, via: d.via };
|
|
135
|
+
},
|
|
136
|
+
async endSession(idHash) {
|
|
137
|
+
const ref = sessionsCol().doc(idHash);
|
|
138
|
+
await db.runTransaction(async (tx) => {
|
|
139
|
+
const d = (await tx.get(ref)).data();
|
|
140
|
+
if (d && d.scope === "play")
|
|
141
|
+
tx.delete(ref);
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
async useOnce(key, ttlMs, address) {
|
|
145
|
+
const ref = onceCol().doc(key);
|
|
146
|
+
return db.runTransaction(async (tx) => {
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
const d = (await tx.get(ref)).data();
|
|
149
|
+
if (d && (num(d.until) ?? Infinity) > now)
|
|
150
|
+
return false;
|
|
151
|
+
tx.set(ref, { address, until: now + ttlMs, deleteAt: new Date(now + ttlMs) });
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
async forget(address) {
|
|
156
|
+
const sessions = (await sessionsCol().where("address", "==", address).get()).docs.filter((d) => d.data().scope === "play");
|
|
157
|
+
const requests = (await requestsCol().where("account", "==", address).get()).docs.filter((d) => d.data().scope === "play");
|
|
158
|
+
const once = (await onceCol().where("address", "==", address).get()).docs;
|
|
159
|
+
return deleteAll([...sessions, ...requests, ...once]);
|
|
160
|
+
},
|
|
161
|
+
async clean(now) {
|
|
162
|
+
const old = (await requestsCol().where("expiresAt", "<", now - 2 * 3_600_000).limit(BATCH).get()).docs.filter((d) => d.data().scope === "play");
|
|
163
|
+
const ended = (await sessionsCol().where("expiresAt", "<=", now).limit(BATCH).get()).docs.filter((d) => d.data().scope === "play");
|
|
164
|
+
const used = (await onceCol().where("until", "<=", now).limit(BATCH).get()).docs;
|
|
165
|
+
await deleteAll([...old, ...ended, ...used]);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PlayerSigninStore } from "./types.js";
|
|
2
|
+
export type PlayerSigninHarness = {
|
|
3
|
+
store: PlayerSigninStore;
|
|
4
|
+
/** Every row the store keeps (requests, sessions, once-only keys) as one text. `forget` must leave none that holds the player's address. */
|
|
5
|
+
dump(): Promise<string>;
|
|
6
|
+
};
|
|
7
|
+
export type PlayerSigninConformanceCase = {
|
|
8
|
+
name: string;
|
|
9
|
+
run: () => Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export declare function playerSigninStoreConformance(make: () => Promise<PlayerSigninHarness>): PlayerSigninConformanceCase[];
|