@nano133/nanoterra 0.12.0 → 0.14.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.
@@ -1,3 +1,5 @@
1
+ export { createZoom, listenZoom, pinchedTo, wheelNotches, zoomedBy, PINCH, ZOOM_CURVE, ZOOM_EASE, ZOOM_STEP } from "./zoom.js";
2
+ export type { Zoom, ZoomRange } from "./zoom.js";
1
3
  type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
2
4
  /**
3
5
  * Calls `release` each time what is held may be wrong. In it, clear every held key, thumb stick and drag, and any jump or tool that a key press queued.
@@ -29,4 +31,3 @@ export declare function pauseWhenLeft(pause: () => void, win?: Listens, doc?: Li
29
31
  hidden?: boolean;
30
32
  hasFocus?: () => boolean;
31
33
  }): () => void;
32
- export {};
@@ -16,6 +16,9 @@
16
16
  // calls your `pause` when the player really left (a free round stops its clock and shows a Resume key). No package: plain browser events.
17
17
  //
18
18
  // A ROUND THAT COSTS MONEY must not pause: its deadline must not move. Keep `releaseWhenLost` (the character always stops) and leave `pauseWhenLeft` out.
19
+ //
20
+ // The camera's distance (the wheel, a pinch, the + and − keys, one curve) is `createZoom` and `listenZoom`, in ./zoom.ts.
21
+ export { createZoom, listenZoom, pinchedTo, wheelNotches, zoomedBy, PINCH, ZOOM_CURVE, ZOOM_EASE, ZOOM_STEP } from "./zoom.js";
19
22
  /**
20
23
  * Calls `release` each time what is held may be wrong. In it, clear every held key, thumb stick and drag, and any jump or tool that a key press queued.
21
24
  * `keysOnly`, when given, is called when a menu opens (a right click): a menu takes a keyup, but on a touch screen a long press also asks for a menu,
@@ -0,0 +1,81 @@
1
+ /** Where the camera may be (metres from the eyes): the farthest, the default, and the switch: nearer than `first` it is in the eyes. */
2
+ export type ZoomRange = {
3
+ max: number;
4
+ start: number;
5
+ first: number;
6
+ };
7
+ /** One notch: about one mouse-wheel click (100 px of scroll). The + and − keys move `key` notches. */
8
+ export declare const ZOOM_STEP: {
9
+ readonly perPx: 0.006;
10
+ readonly perPxPinch: 0.03;
11
+ readonly linePx: 16;
12
+ readonly pagePx: 400;
13
+ readonly key: 1.2;
14
+ };
15
+ /** The curve: a notch moves `base + scale × distance` metres. */
16
+ export declare const ZOOM_CURVE: {
17
+ readonly base: 0.35;
18
+ readonly scale: 0.14;
19
+ };
20
+ /** The touch pinch: twice the spread divides `distance + base/scale` by 2^power. */
21
+ export declare const PINCH: {
22
+ readonly power: 1.3;
23
+ };
24
+ /** How fast the camera eases to the distance asked for (1/s). */
25
+ export declare const ZOOM_EASE = 9;
26
+ /** One wheel event as notches (negative: in). A mouse notch arrives in lines or about 100 px, a trackpad in many small pixels, a pinch as a wheel with the ctrl key. */
27
+ export declare function wheelNotches(e: {
28
+ deltaY: number;
29
+ deltaMode?: number;
30
+ ctrlKey?: boolean;
31
+ }): number;
32
+ /** The distance after `notches` (negative: in) from `want`. */
33
+ export declare function zoomedBy(r: ZoomRange, want: number, notches: number): number;
34
+ /** The distance during a touch pinch: `from` at the start, the fingers `spread0` apart then and `spread` now (px). Apart brings it in. */
35
+ export declare function pinchedTo(r: ZoomRange, from: number, spread0: number, spread: number): number;
36
+ export type Zoom = {
37
+ /** The distance asked for (metres from the eyes). */
38
+ want(): number;
39
+ /** The distance now, as the last `step` eased it. */
40
+ now(): number;
41
+ /** True while the camera is in the eyes (the distance now below `first`). */
42
+ eyes(): boolean;
43
+ /** Asks for a distance (clamped), writes it and tells the listeners. */
44
+ set(d: number): void;
45
+ /** Moves the distance by notches (negative: in), along the curve. */
46
+ by(notches: number): void;
47
+ /** A wheel event (or one like it) moves the distance. */
48
+ wheel(e: {
49
+ deltaY: number;
50
+ deltaMode?: number;
51
+ ctrlKey?: boolean;
52
+ }): void;
53
+ /** A touch pinch starts with the fingers `spread` apart (px); `pinch` follows it; `pinchEnd` ends it. */
54
+ pinchStart(spread: number): void;
55
+ pinch(spread: number): void;
56
+ pinchEnd(): void;
57
+ /** One frame: the distance eases to the one asked for (at once when `calm`, reduced motion). Returns it. */
58
+ step(dt: number, calm: boolean): number;
59
+ /** Calls `on` when the distance asked for changes. Returns the function that stops it. */
60
+ subscribe(on: () => void): () => void;
61
+ };
62
+ /**
63
+ * The camera's distance of one game. `read` gives the kept distance (null: none, the default `start`), and is read the first time the distance is
64
+ * asked for, so the server never calls it; `write` keeps a new one (on every change: put a delay in it if your storage is slow).
65
+ */
66
+ export declare function createZoom(r: ZoomRange, keep?: {
67
+ read?: () => number | null;
68
+ write?: (d: number) => void;
69
+ }): Zoom;
70
+ type Listens = Pick<EventTarget, "addEventListener" | "removeEventListener">;
71
+ /**
72
+ * The wheel over `el` (the game's canvas: the page does not scroll or zoom there) and the + and − keys on the window (also the number pad's) move
73
+ * `zoom`. A key with ctrl, alt or the command key is the browser's own zoom and is left alone, and so is a key typed in a field. `skip(e)` returns true
74
+ * for an event the game wants to keep from the zoom (a key the player is binding, a game that is paused). Returns the function that takes the listeners away.
75
+ * The touch pinch is yours to wire (`pinchStart`, `pinch`, `pinchEnd`), because the fingers are also your thumb stick and your look.
76
+ */
77
+ export declare function listenZoom(zoom: Zoom, el: Listens, opts?: {
78
+ skip?: (e: Event) => boolean;
79
+ keys?: boolean;
80
+ }, win?: Listens): () => void;
81
+ export {};
@@ -0,0 +1,140 @@
1
+ // The camera's distance for a game the player walks in: the mouse wheel, a trackpad's two-finger scroll or pinch, a two-finger pinch on a touch
2
+ // screen, and the + and − keys move one number, the camera's distance from the eyes, along ONE curve. 0 is first person; below `first` the camera
3
+ // is in the eyes. It eases to the distance asked for (at once with reduced motion). No package: plain browser events.
4
+ //
5
+ // THE FEEL is nano133.com's island (app/island/zoom.ts). Each notch moves the distance in proportion to it (`0.35 + 0.14 × distance` metres a
6
+ // notch), so a notch feels the same near and far. Every bit of a scroll counts: a dead zone and a speed limit dropped most of a quick spin in
7
+ // Echo Island, and its wheel needed about five times the spins of the island's (Wes, 2026-11-01). Close in, the last notch goes all the way to
8
+ // the eyes; out of the eyes, the first notch goes past the switch, so the camera never rests half inside the head.
9
+ //
10
+ // THE PINCH follows the same curve: the curve is a constant ratio of `distance + 2.5`, so spreading the fingers to twice their start divides it
11
+ // by 2^PINCH.power, wherever the camera is.
12
+ /** One notch: about one mouse-wheel click (100 px of scroll). The + and − keys move `key` notches. */
13
+ export const ZOOM_STEP = { perPx: 0.006, perPxPinch: 0.03, linePx: 16, pagePx: 400, key: 1.2 };
14
+ /** The curve: a notch moves `base + scale × distance` metres. */
15
+ export const ZOOM_CURVE = { base: 0.35, scale: 0.14 };
16
+ /** The touch pinch: twice the spread divides `distance + base/scale` by 2^power. */
17
+ export const PINCH = { power: 1.3 };
18
+ /** How fast the camera eases to the distance asked for (1/s). */
19
+ export const ZOOM_EASE = 9;
20
+ const clamp = (r, d) => Math.min(r.max, Math.max(0, d));
21
+ /** One wheel event as notches (negative: in). A mouse notch arrives in lines or about 100 px, a trackpad in many small pixels, a pinch as a wheel with the ctrl key. */
22
+ export function wheelNotches(e) {
23
+ const px = e.deltaMode === 1 ? e.deltaY * ZOOM_STEP.linePx : e.deltaMode === 2 ? e.deltaY * ZOOM_STEP.pagePx : e.deltaY;
24
+ return px * (e.ctrlKey ? ZOOM_STEP.perPxPinch : ZOOM_STEP.perPx);
25
+ }
26
+ /** The snap: close in, a move into the switch goes to the eyes; out of the eyes, a move out goes past the switch. */
27
+ function snapped(r, from, to) {
28
+ if (to < from && to < r.first)
29
+ return 0;
30
+ if (to > from && from < r.first)
31
+ return clamp(r, Math.max(to, r.first + 1));
32
+ return clamp(r, to);
33
+ }
34
+ /** The distance after `notches` (negative: in) from `want`. */
35
+ export function zoomedBy(r, want, notches) {
36
+ return snapped(r, want, want + notches * (ZOOM_CURVE.base + want * ZOOM_CURVE.scale));
37
+ }
38
+ /** The distance during a touch pinch: `from` at the start, the fingers `spread0` apart then and `spread` now (px). Apart brings it in. */
39
+ export function pinchedTo(r, from, spread0, spread) {
40
+ if (!(spread0 > 0) || !(spread > 0))
41
+ return from;
42
+ const k = ZOOM_CURVE.base / ZOOM_CURVE.scale;
43
+ return snapped(r, from, (from + k) * (spread0 / spread) ** PINCH.power - k);
44
+ }
45
+ /**
46
+ * The camera's distance of one game. `read` gives the kept distance (null: none, the default `start`), and is read the first time the distance is
47
+ * asked for, so the server never calls it; `write` keeps a new one (on every change: put a delay in it if your storage is slow).
48
+ */
49
+ export function createZoom(r, keep = {}) {
50
+ let want = r.start;
51
+ let now = r.start;
52
+ let loaded = false;
53
+ let pinchFrom = null;
54
+ const listeners = new Set();
55
+ const load = () => {
56
+ if (loaded)
57
+ return;
58
+ loaded = true;
59
+ const v = keep.read?.();
60
+ if (typeof v === "number" && Number.isFinite(v))
61
+ want = now = clamp(r, v);
62
+ };
63
+ const set = (d) => {
64
+ load();
65
+ want = clamp(r, d);
66
+ keep.write?.(want);
67
+ for (const on of listeners)
68
+ on();
69
+ };
70
+ const by = (notches) => {
71
+ load();
72
+ if (notches !== 0)
73
+ set(zoomedBy(r, want, notches));
74
+ };
75
+ return {
76
+ want: () => (load(), want),
77
+ now: () => (load(), now),
78
+ eyes: () => (load(), now < r.first),
79
+ set,
80
+ by,
81
+ wheel: (e) => by(wheelNotches(e)),
82
+ pinchStart(spread) {
83
+ load();
84
+ pinchFrom = { d: want, spread };
85
+ },
86
+ pinch(spread) {
87
+ if (pinchFrom)
88
+ set(pinchedTo(r, pinchFrom.d, pinchFrom.spread, spread));
89
+ },
90
+ pinchEnd() {
91
+ pinchFrom = null;
92
+ },
93
+ step(dt, calm) {
94
+ load();
95
+ now = calm ? want : now + (want - now) * (1 - Math.exp(-dt * ZOOM_EASE));
96
+ if (Math.abs(now - want) < 0.002)
97
+ now = want;
98
+ return now;
99
+ },
100
+ subscribe(on) {
101
+ listeners.add(on);
102
+ return () => void listeners.delete(on);
103
+ },
104
+ };
105
+ }
106
+ /**
107
+ * The wheel over `el` (the game's canvas: the page does not scroll or zoom there) and the + and − keys on the window (also the number pad's) move
108
+ * `zoom`. A key with ctrl, alt or the command key is the browser's own zoom and is left alone, and so is a key typed in a field. `skip(e)` returns true
109
+ * for an event the game wants to keep from the zoom (a key the player is binding, a game that is paused). Returns the function that takes the listeners away.
110
+ * The touch pinch is yours to wire (`pinchStart`, `pinch`, `pinchEnd`), because the fingers are also your thumb stick and your look.
111
+ */
112
+ export function listenZoom(zoom, el, opts = {}, win = window) {
113
+ const wheel = (e) => {
114
+ e.preventDefault();
115
+ if (opts.skip?.(e))
116
+ return;
117
+ zoom.wheel(e);
118
+ };
119
+ const key = (e) => {
120
+ const k = e;
121
+ if (k.metaKey || k.altKey || k.ctrlKey || opts.skip?.(e))
122
+ return;
123
+ const t = k.target;
124
+ if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName ?? "")))
125
+ return;
126
+ if (k.code === "Equal" || k.code === "NumpadAdd")
127
+ zoom.by(-ZOOM_STEP.key);
128
+ else if (k.code === "Minus" || k.code === "NumpadSubtract")
129
+ zoom.by(ZOOM_STEP.key);
130
+ };
131
+ el.addEventListener("wheel", wheel, { passive: false });
132
+ const keys = opts.keys !== false;
133
+ if (keys)
134
+ win.addEventListener("keydown", key);
135
+ return () => {
136
+ el.removeEventListener("wheel", wheel);
137
+ if (keys)
138
+ win.removeEventListener("keydown", key);
139
+ };
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton), the looks ladder, the admin and its wallet sign-in, the friends list, an options menu, emotes, the player's sign-in (a passkey or a wallet app) and a shop that sells looks for Nano with no key on the server.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,5 +1,5 @@
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 four settings (`setting:character`, `setting:signin`, `setting:friends`, `setting:emotes` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
2
+ // It writes ONLY the five settings (`setting:character`, `setting:signin`, `setting:friends`, `setting:emotes`, `setting:freePlay` 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";
@@ -21,7 +21,7 @@ export interface D1Like {
21
21
  batch(statements: D1StatementLike[]): Promise<unknown[]>;
22
22
  }
23
23
 
24
- export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes" } as const;
24
+ export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes", freePlay: "setting:freePlay" } as const;
25
25
 
26
26
  type Row = { id: string; player: string; look_id: string; usd: string; amount: string; status: string; created_at: number; expires_at: number; paid_at: number | null; payer: string | null; hash: string | null };
27
27
 
@@ -34,9 +34,9 @@ type Row = { id: string; player: string; look_id: string; usd: string; amount: s
34
34
  export function createD1Store(db: D1Like, options: { logins?: boolean } = {}): AdminStore {
35
35
  const store: AdminStore = {
36
36
  async readSettings() {
37
- const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?, ?, ?)").bind(D1_KEYS.character, D1_KEYS.signin, D1_KEYS.friends, D1_KEYS.emotes).all<{ key: string; value: string }>()).results;
37
+ const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?, ?, ?, ?)").bind(D1_KEYS.character, D1_KEYS.signin, D1_KEYS.friends, D1_KEYS.emotes, D1_KEYS.freePlay).all<{ key: string; value: string }>()).results;
38
38
  const row = (key: string) => rows.find((r) => r.key === key)?.value;
39
- return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends), emotes: row(D1_KEYS.emotes) };
39
+ return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends), emotes: row(D1_KEYS.emotes), freePlay: row(D1_KEYS.freePlay) };
40
40
  },
41
41
 
42
42
  async saveSettings(patch: GamePatch, now: number) {
@@ -47,6 +47,7 @@ export function createD1Store(db: D1Like, options: { logins?: boolean } = {}): A
47
47
  if (patch.signin) batch.push(write(D1_KEYS.signin, patch.signin));
48
48
  if (patch.friends) batch.push(write(D1_KEYS.friends, patch.friends));
49
49
  if (patch.emotes) batch.push(write(D1_KEYS.emotes, patch.emotes));
50
+ if (patch.freePlay) batch.push(write(D1_KEYS.freePlay, patch.freePlay));
50
51
  if (batch.length) await db.batch(batch);
51
52
  },
52
53
 
@@ -1,8 +1,8 @@
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 four settings; everything else it does is a read.
2
+ // Admin SDK's `Firestore` fits. It writes ONLY the five 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", "setting:friends", "setting:emotes", "rate" (value is JSON { usd, sources })
5
+ // meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "setting:friends", "setting:emotes", "setting:freePlay", "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
  //
@@ -33,7 +33,7 @@ export interface FirestoreLike {
33
33
  }
34
34
 
35
35
  export const FIRESTORE_SCAN_LIMIT = 5000;
36
- export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes" } as const;
36
+ export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes", freePlay: "setting:freePlay" } as const;
37
37
 
38
38
  const text = (v: unknown, fallback = ""): string => (typeof v === "string" ? v : fallback);
39
39
  const num = (v: unknown, fallback = 0): number => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
@@ -49,8 +49,14 @@ export function createFirestoreStore(db: FirestoreLike, collections: { meta?: st
49
49
  };
50
50
  return {
51
51
  async readSettings() {
52
- const [character, signin, friends, emotes] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin), valueOf(FIRESTORE_KEYS.friends), valueOf(FIRESTORE_KEYS.emotes)]);
53
- return { character: character?.value, signin: signin?.value, friends: friends?.value, emotes: emotes?.value };
52
+ const [character, signin, friends, emotes, freePlay] = await Promise.all([
53
+ valueOf(FIRESTORE_KEYS.character),
54
+ valueOf(FIRESTORE_KEYS.signin),
55
+ valueOf(FIRESTORE_KEYS.friends),
56
+ valueOf(FIRESTORE_KEYS.emotes),
57
+ valueOf(FIRESTORE_KEYS.freePlay),
58
+ ]);
59
+ return { character: character?.value, signin: signin?.value, friends: friends?.value, emotes: emotes?.value, freePlay: freePlay?.value };
54
60
  },
55
61
 
56
62
  async saveSettings(patch: GamePatch, now: number) {
@@ -60,6 +66,7 @@ export function createFirestoreStore(db: FirestoreLike, collections: { meta?: st
60
66
  if (patch.signin) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
61
67
  if (patch.friends) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.friends), { value: patch.friends, at: now }), n++);
62
68
  if (patch.emotes) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.emotes), { value: patch.emotes, at: now }), n++);
69
+ if (patch.freePlay) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.freePlay), { value: patch.freePlay, at: now }), n++);
63
70
  if (n) await batch.commit();
64
71
  },
65
72
 
@@ -104,7 +104,7 @@ async function read(res: Response): Promise<{ status: number; text: string; json
104
104
  const REFUSAL_KEYS = new Set(["error", "reason", "signedInAs"]);
105
105
 
106
106
  /** Words of the game's data that a refusal or an answer to a visitor must never hold. */
107
- const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"emotes"|"warnings"/;
107
+ const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"emotes"|"freePlay"|"warnings"/;
108
108
 
109
109
  export function adminConformance(host: ConformanceHost): ConformanceCase[] {
110
110
  const cases: ConformanceCase[] = [];
@@ -337,7 +337,7 @@ export function adminConformance(host: ConformanceHost): ConformanceCase[] {
337
337
  export type StoreHarness = {
338
338
  store: AdminStore;
339
339
  seed(data: { orders?: OrderRow[]; owned?: { player: string; lookId: string; at: number }[]; rate?: { value: string; at: number } }): Promise<void>;
340
- /** 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 four settings. Same rows, same text. */
340
+ /** 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 five settings. Same rows, same text. */
341
341
  snapshot(): Promise<string>;
342
342
  /** Only for a store with `recentLogins` and `signOutEverywhere`: put admin sign-ins in the game's database (the store only reads them). */
343
343
  seedLogins?(rows: { at: number; country: string | null; browser: string }[]): Promise<void>;
@@ -363,7 +363,7 @@ export function adminStoreConformance(make: () => Promise<StoreHarness>): Confor
363
363
 
364
364
  add("a new store has no settings of its own", async ({ store }) => {
365
365
  const s = await store.readSettings();
366
- check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined, `expected no settings, got ${JSON.stringify(s)}`);
366
+ check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined && s.freePlay === undefined, `expected no settings, got ${JSON.stringify(s)}`);
367
367
  });
368
368
 
369
369
  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 }) => {
@@ -384,6 +384,15 @@ export function adminStoreConformance(make: () => Promise<StoreHarness>): Confor
384
384
  }
385
385
  });
386
386
 
387
+ add("the free play switch is kept as its own setting: off and on, and a patch of it leaves the other four", async ({ store }) => {
388
+ await store.saveSettings({ signin: "none", character: "own", friends: "off", emotes: "off" }, NOW);
389
+ for (const [i, freePlay] of (["off", "on"] as const).entries()) {
390
+ await store.saveSettings({ freePlay }, NOW + 1 + i);
391
+ const s = await store.readSettings();
392
+ check(s.freePlay === freePlay && s.signin === "none" && s.character === "own" && s.friends === "off" && s.emotes === "off", `after freePlay ${freePlay}: ${JSON.stringify(s)}`);
393
+ }
394
+ });
395
+
387
396
  add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
388
397
  await store.saveSettings({ signin: "none" }, NOW);
389
398
  let s = await store.readSettings();
@@ -399,13 +408,13 @@ export function adminStoreConformance(make: () => Promise<StoreHarness>): Confor
399
408
  add("an empty patch writes nothing and does not fail", async ({ store }) => {
400
409
  await store.saveSettings({}, NOW);
401
410
  const s = await store.readSettings();
402
- check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined, "an empty patch must save nothing");
411
+ check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined && s.freePlay === undefined, "an empty patch must save nothing");
403
412
  });
404
413
 
405
414
  add("saving changes nothing but the settings: no order, no owned look, no other meta row", async (h) => {
406
415
  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 } });
407
416
  const before = await h.snapshot();
408
- await h.store.saveSettings({ character: "own", signin: "none", friends: "off", emotes: "off" }, NOW + 5);
417
+ await h.store.saveSettings({ character: "own", signin: "none", friends: "off", emotes: "off", freePlay: "off" }, NOW + 5);
409
418
  check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
410
419
  });
411
420
 
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // GET {base}/summary the numbers: status, orders, the looks that players own
4
4
  // GET {base}/game the game switches
5
- // POST {base}/game a press: { character }, { signin }, { friends } or { emotes } (only the known words)
5
+ // POST {base}/game a press: { character }, { signin }, { friends }, { emotes } or { freePlay } (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
@@ -190,7 +190,7 @@ export function createAdminHandler(options: AdminHandlerOptions): AdminHandler {
190
190
  return { routes, basePath: options.basePath ?? "/api/admin", handle };
191
191
  }
192
192
 
193
- /** The settings as the game's own routes need them (the listing file, the character, the friends list, the emotes): the stored words, or the game's defaults. A fault in the store gives the defaults. */
193
+ /** The settings as the game's own routes need them (the listing file, the character, the friends list, the emotes, free play): the stored words, or the game's defaults. A fault in the store gives the defaults. */
194
194
  export async function loadGameSettings(store: Pick<AdminStore, "readSettings">, defaults: GameSettings): Promise<GameState> {
195
195
  try {
196
196
  return mergeSettings(await store.readSettings(), defaults);
@@ -17,5 +17,5 @@ export type { AdminLogin, AdminOrder, AdminPlayer, AdminStatus, AdminStore, Admi
17
17
  export { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, statusOf, summarize } from "./summary.js";
18
18
  export type { OrderRow } from "./summary.js";
19
19
  export { RAW_PER_XNO, xnoText } from "./amount.js";
20
- export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_EMOTES, DEFAULT_FRIENDS, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isEmotes, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
21
- export type { Character, Emotes, Friends, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
20
+ export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_EMOTES, DEFAULT_FREE_PLAY, DEFAULT_FRIENDS, FREE_PLAY_NOTE, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, freePlayOn, isCharacter, isEmotes, isFreePlay, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
21
+ export type { Character, Emotes, FreePlay, Friends, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
@@ -35,13 +35,14 @@ export function createMemoryStore(seed: { orders?: OrderRow[]; owned?: { player:
35
35
  pendingAdminRequests: 0,
36
36
  pendingGameRequests: 0,
37
37
  async readSettings() {
38
- return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin"), friends: store.rows.get("setting:friends"), emotes: store.rows.get("setting:emotes") };
38
+ return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin"), friends: store.rows.get("setting:friends"), emotes: store.rows.get("setting:emotes"), freePlay: store.rows.get("setting:freePlay") };
39
39
  },
40
40
  async saveSettings(patch: GamePatch) {
41
41
  if (patch.character) store.rows.set("setting:character", patch.character);
42
42
  if (patch.signin) store.rows.set("setting:signin", patch.signin);
43
43
  if (patch.friends) store.rows.set("setting:friends", patch.friends);
44
44
  if (patch.emotes) store.rows.set("setting:emotes", patch.emotes);
45
+ if (patch.freePlay) store.rows.set("setting:freePlay", patch.freePlay);
45
46
  store.writes++;
46
47
  },
47
48
  async summary(now: number): Promise<StoreSummary> {
@@ -1,9 +1,9 @@
1
1
  // The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra", one three-way
2
- // choice, "Friends list: Nanoterra / The game's own / Off", and the switch "Allow Nanoterra emotes". A press saves at once (POST {api}/game, behind the
3
- // same admin guard as every admin call) and the game reads the setting at run time, so the creator pushes and deploys nothing. The words are in
4
- // ../switches.ts. The card changes ONLY these four settings: never a look, a price or an order.
2
+ // choice, "Friends list: Nanoterra / The game's own / Off", and the switches "Allow Nanoterra emotes" and "Free play". A press saves at once (POST
3
+ // {api}/game, behind the same admin guard as every admin call) and the game reads the setting at run time, so the creator pushes and deploys nothing.
4
+ // The words are in ../switches.ts. The card changes ONLY these five settings: never a look, a price or an order.
5
5
 
6
- import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf, type Friends, type GameState, type SwitchId } from "../switches.js";
6
+ import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, FREE_PLAY_NOTE, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf, type Friends, type GameState, type SwitchId } from "../switches.js";
7
7
  import { ApiError, type Api } from "./api.js";
8
8
  import { h } from "./dom.js";
9
9
 
@@ -17,7 +17,7 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
17
17
  /** Under the character switch: it works only through sign-in, so it says so, and says what a player gets while sign-in is Off. */
18
18
  const needs = h("p", { class: "fine switch-needs" });
19
19
  const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("div", { class: "card-head" }, h("h2", { id: "game-title" }, "Game")));
20
- card.append(h("p", { class: "fine card-intro" }, `Four choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
20
+ card.append(h("p", { class: "fine card-intro" }, `Five choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
21
21
  /** The friends choice: a radio group of three keys (arrow keys move between them, as a radio group does). */
22
22
  const friendsKeys = new Map<Friends, HTMLButtonElement>();
23
23
  const friendsNote = h("p", { class: "switch-note", id: "switch-friends-note" });
@@ -112,6 +112,9 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
112
112
  }
113
113
  card.append(h("div", { class: "switch-row friends-row" }, h("div", { class: "switch-head" }, h("span", { class: "switch-title", id: "switch-friends-label" }, FRIENDS_TITLE), group), friendsNote, friendsStatus));
114
114
  switchRow("emotes");
115
+ // Free play is a word of the listing file too: Nanoterra's review decides what its listing says ("Free to play" or "Paid").
116
+ switchRow("freePlay");
117
+ card.append(h("p", { class: "fine switch-review" }, FREE_PLAY_NOTE, " ", h("a", { href: REVIEW_LINK, target: "_blank", rel: "noopener noreferrer" }, "Open the launch page")));
115
118
  draw();
116
119
  return card;
117
120
  }
@@ -47,7 +47,7 @@ export function overviewView(looks: readonly Look[], colourOf: (lookId: string)
47
47
  const linkKey = (words: string, to: () => void) => h("button", { class: "btn btn-small btn-quiet link-key", type: "button", onclick: to }, words, h("span", { "aria-hidden": "true" }, "→"));
48
48
 
49
49
  function drawGame() {
50
- const rows = (["character", "signin", "emotes"] as const).map((id) => {
50
+ const rows = (["character", "signin", "emotes", "freePlay"] as const).map((id) => {
51
51
  const on = game ? stateOf(id, game) : null;
52
52
  return h("li", {}, h("span", {}, SWITCHES[id].title), on === null ? h("span", { class: "pill" }, "Unknown") : h("span", { class: `pill ${on ? "pill-on" : "pill-off"}` }, on ? "On" : "Off"));
53
53
  });
@@ -48,13 +48,13 @@ export type AdminLogin = { at: number; country: string | null; browser: string }
48
48
  export const ADMIN_LOGINS_SHOWN = 10;
49
49
 
50
50
  /**
51
- * The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the four settings and, when the game turns it on, the deletion of the admin sessions
51
+ * The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the five settings and, when the game turns it on, the deletion of the admin sessions
52
52
  * (`signOutEverywhere`). Nothing else.
53
53
  * (There is no call that writes a look, a price or an order: the Shelf gives text to copy, and a change of looks is a change of the repository.)
54
54
  */
55
55
  export interface AdminStore {
56
56
  /** The settings that /admin has saved. A value that is not a known word is ignored by the handler, so an adapter returns what it holds. */
57
- readSettings(): Promise<{ character?: unknown; signin?: unknown; friends?: unknown; emotes?: unknown }>;
57
+ readSettings(): Promise<{ character?: unknown; signin?: unknown; friends?: unknown; emotes?: unknown; freePlay?: unknown }>;
58
58
  /** Saves a checked patch: all of it or none of it. */
59
59
  saveSettings(patch: GamePatch, now: number): Promise<void>;
60
60
  summary(now: number): Promise<StoreSummary>;