@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 (2026-11-02)
4
+
5
+ - **"Free play" in /admin (Wes, 2026-11-02):** the Game card's fifth setting, a switch, On by default. Off says that the game has no free play: a player must pay in
6
+ the game to play. The switch only says so; the game asks for the payment. `freePlay` ("on" or "off") is in `GameSettings`, `GamePatch` and `GameState` (with
7
+ `fromAdmin.freePlay`), `POST {base}/game` takes `{ "freePlay": "on" | "off" }`, and the memory, D1 and Firestore stores keep it as `setting:freePlay`, with a
8
+ store conformance case. `freePlayOn(settings)` gives the listing file's word, `"freePlay": true` or `false`: Nanoterra's listing then says "Paid" in place of
9
+ "Free to play". The game's paid play, its prices and its risk are the creator's. The Overview lists the switch too.
10
+
11
+ ## 0.13.0 (2026-11-01)
12
+
13
+ - **The camera's distance on one curve (`createZoom`, `listenZoom` in `@nano133/nanoterra/input`):** one number, the camera's distance from the eyes, moved by
14
+ the mouse wheel, a trackpad's scroll or pinch, a touch pinch and the + and − keys, with nano133.com's island feel. A notch moves `0.35 + 0.14 × distance`
15
+ metres (100 px of scroll, a line 16 px, a page 400 px, a trackpad pinch five times a scroll), and every bit of a scroll counts: Echo Island's dead zone and
16
+ speed limit dropped most of a quick spin, and its wheel needed about five times the spins. The last notch in goes to the eyes, the first notch out past the
17
+ switch. The touch pinch is the same curve as a ratio: twice the spread divides `distance + 2.5` by 2^1.3 (`pinchStart`, `pinch`, `pinchEnd`; the game wires
18
+ the fingers). `createZoom(range, { read, write })` keeps the distance in the game's own storage (`read` at first use, never on the server), eases it
19
+ (`step(dt, calm)`, at once with reduced motion), and has `set`, `by`, `eyes` and `subscribe`. `listenZoom(zoom, canvas, { skip })` stops the page's scroll
20
+ over the canvas and leaves the browser's zoom keys and a field alone. The pure parts (`wheelNotches`, `zoomedBy`, `pinchedTo`) are exported. About 1.0 kB gzip.
21
+
3
22
  ## 0.12.0 (2026-11-01)
4
23
 
5
24
  - **A passkey for the owner (`createAdminSignin({ passkeys: true })`): the owner's LINKED passkeys open /admin.** A passkey is only a key to /admin; the money stays
package/README.md CHANGED
@@ -69,7 +69,7 @@ Each path loads only what it needs. A page that reads a ticket loads no Three.js
69
69
  | `@nano133/nanoterra/looks` | the looks ladder | no | 1.9 kB (all), 0.9 kB (`checkLooks` alone) |
70
70
  | `@nano133/nanoterra/character` | the builder and `createCharacter` (it includes the record), with the three sockets | yes | 21.8 kB (all, with the sockets and `createLookPreview` of 0.3.0; 20.4 kB on 0.2.0), 17.8 kB (`createCharacter` alone, measured on 0.2.0) |
71
71
  | `@nano133/nanoterra/model` | the 3D model check: a `.glb` against the island's limits or a cosmetic's (since 0.3.0) | no | 8.8 kB |
72
- | `@nano133/nanoterra/input` | let go of held keys and sticks when the player leaves the page, and pause: `releaseWhenLost`, `pauseWhenLeft`, `swallowsKeyup` (since 0.3.1) | no | 0.5 kB |
72
+ | `@nano133/nanoterra/input` | let go of held keys and sticks when the player leaves the page, and pause: `releaseWhenLost`, `pauseWhenLeft`, `swallowsKeyup` (since 0.3.1); the camera's distance on one curve: `createZoom`, `listenZoom` (since 0.13.0) | no | 0.5 kB, 1.0 kB more with the zoom |
73
73
  | `@nano133/nanoterra/admin` | the admin of a game: the guard, the handler, the store type (server code; since 0.2.0) | no | 3.7 kB |
74
74
  | `@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 |
75
75
  | `@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) |
@@ -291,6 +291,31 @@ pauseWhenLeft(() => { game.paused = true; resumeCard.hidden = false; });
291
291
  - It returns the function that takes its listeners away. It names no package and reads only plain browser events. The same code runs in nano133.com's island and rooms,
292
292
  and in the [game starter](https://github.com/nano133-com/nano-game-starter).
293
293
 
294
+ ## The camera's distance (`@nano133/nanoterra/input`; since 0.13.0)
295
+
296
+ One number, the camera's distance from the eyes, moved by the mouse wheel, a trackpad's two-finger scroll or pinch, a two-finger pinch on a touch screen
297
+ and the + and − keys, along ONE curve: nano133.com's island feel. Each notch moves `0.35 + 0.14 × distance` metres, so a notch feels the same near and
298
+ far, and every bit of a scroll counts (Echo Island's wheel had a dead zone and a speed limit, dropped most of a quick spin and needed about five times the
299
+ spins). The last notch in goes to the eyes and the first notch out goes past the switch, so the camera never rests half inside the head.
300
+
301
+ ```ts
302
+ import { createZoom, listenZoom } from "@nano133/nanoterra/input";
303
+
304
+ const zoom = createZoom({ max: 10, start: 5.2, first: 0.6 }, { read: () => saved(), write: (d) => save(d) });
305
+ listenZoom(zoom, renderer.domElement, { skip: () => paused }); // the wheel over the canvas, + and − on the window
306
+ // each frame: the distance eases to the one asked for (at once with reduced motion)
307
+ const dist = zoom.step(dt, lessMotion);
308
+ const firstPerson = zoom.eyes();
309
+ ```
310
+
311
+ - `read` is called the first time the distance is asked for (never on the server); `write` on every change: put a delay in it if your storage is slow.
312
+ - `listenZoom` stops the page's scroll and zoom over the canvas. A key with ctrl, alt or the command key (the browser's zoom) and a key typed in a field are
313
+ left alone; `skip(e)` keeps any other event from the zoom (a key the player is binding).
314
+ - The touch pinch is yours to wire, because the fingers are also your stick and your look: `pinchStart(spread)` when the second finger lands, `pinch(spread)`
315
+ as they move, `pinchEnd()`. Twice the spread divides `distance + 2.5` by 2^1.3, wherever the camera is: the wheel's curve.
316
+ - `set(d)` for a setting (a slider, a First person card), `subscribe(on)` for a menu that shows the distance. The pure parts (`wheelNotches`, `zoomedBy`,
317
+ `pinchedTo`) are there for a test.
318
+
294
319
  ## The sign-in ticket
295
320
 
296
321
  `@nano133/nanoterra/ticket` reads and checks a sign-in ticket in the player's browser. It is for a game on a **static site**: no server, no secret.
@@ -511,7 +536,7 @@ import { createAdminHandler } from "@nano133/nanoterra/admin";
511
536
 
512
537
  const admin = createAdminHandler({
513
538
  store, // your database: readSettings(), saveSettings(patch, now), summary(now) (a memory store is in the package, for tests)
514
- defaults: { character: "nanoterra", signin: "exchange", friends: "nanoterra", emotes: "on" }, // what the switches are until /admin sets them (your game.config.json; friends and emotes may be left out)
539
+ defaults: { character: "nanoterra", signin: "exchange", friends: "nanoterra", emotes: "on", freePlay: "on" }, // what the switches are until /admin sets them (your game.config.json; friends, emotes and freePlay may be left out)
515
540
  owner: ownerAddress, // "nano_..." lowercase, its checksum checked by you; null when none is set
516
541
  session: (req, now) => readAdminSession(req, now), // YOUR admin login: its own cookie; return { address, createdAt, via: "wallet" } or null
517
542
  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")
@@ -537,11 +562,21 @@ neither header (curl) and the browser's own address bar (`Sec-Fetch-Site: none`)
537
562
  **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
538
563
  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
539
564
  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
540
- nothing of the fault (`onError` hears it). The only writes are the four 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.
565
+ nothing of the fault (`onError` hears it). The only writes are the five 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.
541
566
 
542
- **Your own routes read the switches** with `loadGameSettings(store, defaults)` (for the listing file's `signin` word, the character, the friends list and the emotes): the stored word, else your default,
567
+ **Your own routes read the switches** with `loadGameSettings(store, defaults)` (for the listing file's `signin` and `freePlay` words, the character, the friends list and the emotes): the stored word, else your default,
543
568
  and your defaults when the store fails.
544
569
 
570
+ **Free play** (since 0.14.0). The Game card's switch "Free play" is On by default. Off says that the game has no free play: a player must pay in the game to play. The switch only
571
+ says so, and your game asks for the payment. Your listing file carries it as `"freePlay": freePlayOn(settings)` (`true` or `false`; a file without it is a free game), and
572
+ Nanoterra's listing then says "Paid" in place of "Free to play". Like the sign-in word, a change of a listed game's file waits for Nanoterra's review. Your paid game, its prices
573
+ and its risk are yours, and every payment goes to a wallet that you hold the key for.
574
+
575
+ ```ts
576
+ const settings = await loadGameSettings(store, defaults);
577
+ const listing = { nanoGame: 1, /* name, line, play, art, creator, ... */ signin: settings.signin, freePlay: freePlayOn(settings) };
578
+ ```
579
+
545
580
  **The conformance suite** proves your whole chain, not only the guard: the cookie you read, the session lookup, the owner you pass.
546
581
 
547
582
  ```ts
@@ -589,8 +624,8 @@ the package's own sign-in, `/admin-signin`, passes it): only a wallet signature
589
624
 
590
625
  ### The store (an adapter for your database)
591
626
 
592
- `createAdminHandler` needs a store: three calls, `readSettings()`, `saveSettings(patch, now)` and `summary(now)`. Two adapters come with the package, and **both write only the four settings** (the D1 store also deletes the admin sessions, but only when the game turns on the sign-in list: see below)
593
- (`setting:character`, `setting:signin`, `setting:friends` and `setting:emotes`); every other call they make is a read. Both must pass the same **store contract**, `adminStoreConformance(make)` in
627
+ `createAdminHandler` needs a store: three calls, `readSettings()`, `saveSettings(patch, now)` and `summary(now)`. Two adapters come with the package, and **both write only the five settings** (the D1 store also deletes the admin sessions, but only when the game turns on the sign-in list: see below)
628
+ (`setting:character`, `setting:signin`, `setting:friends`, `setting:emotes` and `setting:freePlay`); every other call they make is a read. Both must pass the same **store contract**, `adminStoreConformance(make)` in
594
629
  `@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,
595
630
  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.
596
631
 
@@ -652,7 +687,7 @@ status warnings, one banner); four numbers (Earned, Open now, To send back, Play
652
687
  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).
653
688
  **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
654
689
  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
655
- look, with their looks. **Settings**: the **Game** card (the character and sign-in switches, the friends list's three-way choice and "Allow Nanoterra emotes"), the Sign-ins card and the facts (the address, the mode, the nodes, the last price). The tabs are
690
+ look, with their looks. **Settings**: the **Game** card (the character and sign-in switches, the friends list's three-way choice, "Allow Nanoterra emotes" and "Free play"), the Sign-ins card and the facts (the address, the mode, the nodes, the last price). The tabs are
656
691
  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.
657
692
  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
658
693
  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`,
@@ -689,7 +724,7 @@ flat picture stays. `createLookPreview({ size?, body?, record?, background?, key
689
724
 
690
725
  **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
691
726
  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
692
- 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`,
727
+ five 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`,
693
728
  `--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
694
729
  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
695
730
  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
@@ -17,6 +17,7 @@ export declare const D1_KEYS: {
17
17
  readonly signin: "setting:signin";
18
18
  readonly friends: "setting:friends";
19
19
  readonly emotes: "setting:emotes";
20
+ readonly freePlay: "setting:freePlay";
20
21
  };
21
22
  /**
22
23
  * 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 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";
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", friends: "setting:friends", emotes: "setting:emotes" };
8
+ export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes", freePlay: "setting:freePlay" };
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, D1_KEYS.friends, D1_KEYS.emotes).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, D1_KEYS.emotes, D1_KEYS.freePlay).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), friends: row(D1_KEYS.friends), emotes: row(D1_KEYS.emotes) };
20
+ 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) };
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);
@@ -30,6 +30,8 @@ export function createD1Store(db, options = {}) {
30
30
  batch.push(write(D1_KEYS.friends, patch.friends));
31
31
  if (patch.emotes)
32
32
  batch.push(write(D1_KEYS.emotes, patch.emotes));
33
+ if (patch.freePlay)
34
+ batch.push(write(D1_KEYS.freePlay, patch.freePlay));
33
35
  if (batch.length)
34
36
  await db.batch(batch);
35
37
  },
@@ -34,6 +34,7 @@ export declare const FIRESTORE_KEYS: {
34
34
  readonly signin: "setting:signin";
35
35
  readonly friends: "setting:friends";
36
36
  readonly emotes: "setting:emotes";
37
+ readonly freePlay: "setting:freePlay";
37
38
  };
38
39
  export declare function createFirestoreStore(db: FirestoreLike, collections?: {
39
40
  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 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
  //
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", friends: "setting:friends", emotes: "setting:emotes" };
12
+ export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends", emotes: "setting:emotes", freePlay: "setting:freePlay" };
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,14 @@ export function createFirestoreStore(db, collections = {}) {
23
23
  };
24
24
  return {
25
25
  async readSettings() {
26
- const [character, signin, friends, emotes] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin), valueOf(FIRESTORE_KEYS.friends), valueOf(FIRESTORE_KEYS.emotes)]);
27
- return { character: character?.value, signin: signin?.value, friends: friends?.value, emotes: emotes?.value };
26
+ const [character, signin, friends, emotes, freePlay] = await Promise.all([
27
+ valueOf(FIRESTORE_KEYS.character),
28
+ valueOf(FIRESTORE_KEYS.signin),
29
+ valueOf(FIRESTORE_KEYS.friends),
30
+ valueOf(FIRESTORE_KEYS.emotes),
31
+ valueOf(FIRESTORE_KEYS.freePlay),
32
+ ]);
33
+ return { character: character?.value, signin: signin?.value, friends: friends?.value, emotes: emotes?.value, freePlay: freePlay?.value };
28
34
  },
29
35
  async saveSettings(patch, now) {
30
36
  const batch = db.batch();
@@ -37,6 +43,8 @@ export function createFirestoreStore(db, collections = {}) {
37
43
  (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.friends), { value: patch.friends, at: now }), n++);
38
44
  if (patch.emotes)
39
45
  (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.emotes), { value: patch.emotes, at: now }), n++);
46
+ if (patch.freePlay)
47
+ (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.freePlay), { value: patch.freePlay, at: now }), n++);
40
48
  if (n)
41
49
  await batch.commit();
42
50
  },
@@ -78,7 +78,7 @@ export type StoreHarness = {
78
78
  at: number;
79
79
  };
80
80
  }): Promise<void>;
81
- /** 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. */
81
+ /** 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. */
82
82
  snapshot(): Promise<string>;
83
83
  /** Only for a store with `recentLogins` and `signOutEverywhere`: put admin sign-ins in the game's database (the store only reads them). */
84
84
  seedLogins?(rows: {
@@ -42,7 +42,7 @@ async function read(res) {
42
42
  /** A refusal carries words and a reason and nothing else: no data of the game. */
43
43
  const REFUSAL_KEYS = new Set(["error", "reason", "signedInAs"]);
44
44
  /** Words of the game's data that a refusal or an answer to a visitor must never hold. */
45
- const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"emotes"|"warnings"/;
45
+ const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"emotes"|"freePlay"|"warnings"/;
46
46
  export function adminConformance(host) {
47
47
  const cases = [];
48
48
  const add = (name, run, timeoutMs) => cases.push(timeoutMs ? { name, run, timeoutMs } : { name, run });
@@ -263,7 +263,7 @@ export function adminStoreConformance(make) {
263
263
  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 });
264
264
  add("a new store has no settings of its own", async ({ store }) => {
265
265
  const s = await store.readSettings();
266
- check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined, `expected no settings, got ${JSON.stringify(s)}`);
266
+ check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined && s.freePlay === undefined, `expected no settings, got ${JSON.stringify(s)}`);
267
267
  });
268
268
  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 }) => {
269
269
  await store.saveSettings({ signin: "none", character: "own" }, NOW);
@@ -281,6 +281,14 @@ export function adminStoreConformance(make) {
281
281
  check(s.emotes === emotes && s.signin === "none" && s.character === "own" && s.friends === "off", `after emotes ${emotes}: ${JSON.stringify(s)}`);
282
282
  }
283
283
  });
284
+ 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 }) => {
285
+ await store.saveSettings({ signin: "none", character: "own", friends: "off", emotes: "off" }, NOW);
286
+ for (const [i, freePlay] of ["off", "on"].entries()) {
287
+ await store.saveSettings({ freePlay }, NOW + 1 + i);
288
+ const s = await store.readSettings();
289
+ check(s.freePlay === freePlay && s.signin === "none" && s.character === "own" && s.friends === "off" && s.emotes === "off", `after freePlay ${freePlay}: ${JSON.stringify(s)}`);
290
+ }
291
+ });
284
292
  add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
285
293
  await store.saveSettings({ signin: "none" }, NOW);
286
294
  let s = await store.readSettings();
@@ -295,12 +303,12 @@ export function adminStoreConformance(make) {
295
303
  add("an empty patch writes nothing and does not fail", async ({ store }) => {
296
304
  await store.saveSettings({}, NOW);
297
305
  const s = await store.readSettings();
298
- check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined, "an empty patch must save nothing");
306
+ check(s.character === undefined && s.signin === undefined && s.friends === undefined && s.emotes === undefined && s.freePlay === undefined, "an empty patch must save nothing");
299
307
  });
300
308
  add("saving changes nothing but the settings: no order, no owned look, no other meta row", async (h) => {
301
309
  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 } });
302
310
  const before = await h.snapshot();
303
- await h.store.saveSettings({ character: "own", signin: "none", friends: "off", emotes: "off" }, NOW + 5);
311
+ await h.store.saveSettings({ character: "own", signin: "none", friends: "off", emotes: "off", freePlay: "off" }, NOW + 5);
304
312
  check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
305
313
  });
306
314
  add("the summary counts the orders by status, reads an open order past its expiry as expired, and totals the money", async ({ store, seed }) => {
@@ -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 friends list, the emotes): 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 emotes, free play): 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>;
@@ -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
@@ -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 friends list, the emotes): 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 emotes, free play): 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);
@@ -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, 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";
13
- export type { Character, Emotes, Friends, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
12
+ 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";
13
+ export type { Character, Emotes, FreePlay, Friends, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.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, 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";
14
+ 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";
@@ -14,7 +14,7 @@ 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"), friends: store.rows.get("setting:friends"), emotes: store.rows.get("setting:emotes") };
17
+ 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") };
18
18
  },
19
19
  async saveSettings(patch) {
20
20
  if (patch.character)
@@ -25,6 +25,8 @@ export function createMemoryStore(seed = {}) {
25
25
  store.rows.set("setting:friends", patch.friends);
26
26
  if (patch.emotes)
27
27
  store.rows.set("setting:emotes", patch.emotes);
28
+ if (patch.freePlay)
29
+ store.rows.set("setting:freePlay", patch.freePlay);
28
30
  store.writes++;
29
31
  },
30
32
  async summary(now) {
@@ -1,8 +1,8 @@
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.
5
- import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf } from "../switches.js";
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
+ import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, FREE_PLAY_NOTE, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf } from "../switches.js";
6
6
  import { ApiError } from "./api.js";
7
7
  import { h } from "./dom.js";
8
8
  /**
@@ -15,7 +15,7 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
15
15
  /** 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. */
16
16
  const needs = h("p", { class: "fine switch-needs" });
17
17
  const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("div", { class: "card-head" }, h("h2", { id: "game-title" }, "Game")));
18
- 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.`));
18
+ 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.`));
19
19
  /** The friends choice: a radio group of three keys (arrow keys move between them, as a radio group does). */
20
20
  const friendsKeys = new Map();
21
21
  const friendsNote = h("p", { class: "switch-note", id: "switch-friends-note" });
@@ -108,6 +108,9 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
108
108
  }
109
109
  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));
110
110
  switchRow("emotes");
111
+ // Free play is a word of the listing file too: Nanoterra's review decides what its listing says ("Free to play" or "Paid").
112
+ switchRow("freePlay");
113
+ 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")));
111
114
  draw();
112
115
  return card;
113
116
  }
@@ -31,7 +31,7 @@ export function overviewView(looks, colourOf, go) {
31
31
  const el = h("div", { class: "overview" }, main, side);
32
32
  const linkKey = (words, to) => h("button", { class: "btn btn-small btn-quiet link-key", type: "button", onclick: to }, words, h("span", { "aria-hidden": "true" }, "→"));
33
33
  function drawGame() {
34
- const rows = ["character", "signin", "emotes"].map((id) => {
34
+ const rows = ["character", "signin", "emotes", "freePlay"].map((id) => {
35
35
  const on = game ? stateOf(id, game) : null;
36
36
  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"));
37
37
  });
@@ -71,7 +71,7 @@ export type AdminLogin = {
71
71
  /** How many sign-ins the panel lists. */
72
72
  export declare const ADMIN_LOGINS_SHOWN = 10;
73
73
  /**
74
- * 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
74
+ * 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
75
75
  * (`signOutEverywhere`). Nothing else.
76
76
  * (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.)
77
77
  */
@@ -82,6 +82,7 @@ export interface AdminStore {
82
82
  signin?: unknown;
83
83
  friends?: unknown;
84
84
  emotes?: unknown;
85
+ freePlay?: unknown;
85
86
  }>;
86
87
  /** Saves a checked patch: all of it or none of it. */
87
88
  saveSettings(patch: GamePatch, now: number): Promise<void>;
@@ -9,12 +9,18 @@ export type Signin = "exchange" | "ticket" | "none";
9
9
  export type Friends = "nanoterra" | "own" | "off";
10
10
  /** Whether the game shows Nanoterra's emotes ("@nano133/nanoterra/emotes"): the wheel's Nanoterra emotes and the other players' on their characters. Off leaves the game's own emotes. */
11
11
  export type Emotes = "on" | "off";
12
- /** The game's own words for the switches. `friends` and `emotes` may be left out: "nanoterra" and "on" then (on by default). */
12
+ /**
13
+ * Whether the game has free play. "off": a player pays in the game to play (the game asks for the payment; the switch does not). The listing file says it
14
+ * (`"freePlay": false`), and Nanoterra's listing then says "Paid" in place of "Free to play". The game's paid play, its prices and its risk are the creator's.
15
+ */
16
+ export type FreePlay = "on" | "off";
17
+ /** The game's own words for the switches. `friends`, `emotes` and `freePlay` may be left out: "nanoterra", "on" and "on" then (on by default). */
13
18
  export type GameSettings = {
14
19
  character: Character;
15
20
  signin: Signin;
16
21
  friends?: Friends;
17
22
  emotes?: Emotes;
23
+ freePlay?: FreePlay;
18
24
  };
19
25
  /** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
20
26
  export type GamePatch = {
@@ -22,6 +28,7 @@ export type GamePatch = {
22
28
  signin?: "exchange" | "none";
23
29
  friends?: Friends;
24
30
  emotes?: Emotes;
31
+ freePlay?: FreePlay;
25
32
  };
26
33
  /** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
27
34
  export type GameState = {
@@ -29,11 +36,13 @@ export type GameState = {
29
36
  signin: Signin;
30
37
  friends: Friends;
31
38
  emotes: Emotes;
39
+ freePlay: FreePlay;
32
40
  fromAdmin: {
33
41
  character: boolean;
34
42
  signin: boolean;
35
43
  friends: boolean;
36
44
  emotes: boolean;
45
+ freePlay: boolean;
37
46
  };
38
47
  };
39
48
  export declare const isCharacter: (v: unknown) => v is Character;
@@ -41,13 +50,18 @@ export declare const isSigninChoice: (v: unknown) => v is "exchange" | "none";
41
50
  export declare const isSignin: (v: unknown) => v is Signin;
42
51
  export declare const isFriends: (v: unknown) => v is Friends;
43
52
  export declare const isEmotes: (v: unknown) => v is Emotes;
53
+ export declare const isFreePlay: (v: unknown) => v is FreePlay;
44
54
  /** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
45
55
  export declare const DEFAULT_FRIENDS: Friends;
46
56
  /** Nanoterra's emotes are on while neither the game's own words nor /admin say. */
47
57
  export declare const DEFAULT_EMOTES: Emotes;
58
+ /** Free play is on while neither the game's own words nor /admin say. */
59
+ export declare const DEFAULT_FREE_PLAY: FreePlay;
60
+ /** The listing file's word for free play (`"freePlay": true` or `false`), from the settings. */
61
+ export declare const freePlayOn: (s: Pick<GameSettings, "freePlay">) => boolean;
48
62
  /** The sign-in switch is on for any way but "none". */
49
63
  export declare const signinOn: (s: Signin) => boolean;
50
- /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
64
+ /** A request body for a press: only the known words, at least one. Anything else is refused, with the reason in words. */
51
65
  export declare function parsePatch(body: unknown): {
52
66
  ok: true;
53
67
  patch: GamePatch;
@@ -61,6 +75,7 @@ export declare function mergeSettings(stored: {
61
75
  signin?: unknown;
62
76
  friends?: unknown;
63
77
  emotes?: unknown;
78
+ freePlay?: unknown;
64
79
  }, defaults: GameSettings): GameState;
65
80
  /** The friends choice of the card: its title, and for each choice its short name (the card's three keys) and one plain sentence. Nanoterra's is the default. */
66
81
  export declare const FRIENDS_TITLE = "Friends list";
@@ -69,7 +84,7 @@ export declare const FRIENDS_CHOICES: Record<Friends, {
69
84
  note: string;
70
85
  }>;
71
86
  /** One switch of the card: its title, and one plain sentence for each state. */
72
- export type SwitchId = "character" | "signin" | "emotes";
87
+ export type SwitchId = "character" | "signin" | "emotes" | "freePlay";
73
88
  export declare const SWITCHES: Record<SwitchId, {
74
89
  title: string;
75
90
  on: string;
@@ -85,6 +100,8 @@ export declare const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so e
85
100
  /** What to tell the creator under the sign-in switch about Nanoterra's review. A game cannot see that review: it says so. */
86
101
  export declare const REVIEW_NOTE = "Your choice is saved here at once. The sign-in word is part of your listing file, and Nanoterra's review decides what Nanoterra shows for a game that is listed. This page cannot see that review. Check it on Nanoterra's launch page (step 6).";
87
102
  export declare const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
103
+ /** Under the free play switch: the word is part of the listing file, as the sign-in word is, so Nanoterra's review decides what Nanoterra shows. */
104
+ export declare const FREE_PLAY_NOTE = "Your choice is saved here at once. Free play is part of your listing file too, and Nanoterra's review decides what Nanoterra's listing says for a game that is listed.";
88
105
  /** The state of a switch from the settings, and the patch that a press sends (the other state). */
89
106
  export declare const stateOf: (id: SwitchId, s: GameSettings) => boolean;
90
107
  export declare function pressOf(id: SwitchId, s: GameSettings): GamePatch;
@@ -1,20 +1,25 @@
1
1
  // The switches of the "Game" card in /admin, and the words that say what each does. The handler checks a press with `parsePatch`; the panel
2
- // draws the card from `SWITCHES` and `FRIENDS_CHOICES`. A switch changes ONLY these four settings; never a look, a price or an order.
2
+ // draws the card from `SWITCHES` and `FRIENDS_CHOICES`. A switch changes ONLY these five settings; never a look, a price or an order.
3
3
  export const isCharacter = (v) => v === "nanoterra" || v === "own";
4
4
  export const isSigninChoice = (v) => v === "exchange" || v === "none";
5
5
  export const isSignin = (v) => v === "exchange" || v === "ticket" || v === "none";
6
6
  export const isFriends = (v) => v === "nanoterra" || v === "own" || v === "off";
7
7
  export const isEmotes = (v) => v === "on" || v === "off";
8
+ export const isFreePlay = (v) => v === "on" || v === "off";
8
9
  /** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
9
10
  export const DEFAULT_FRIENDS = "nanoterra";
10
11
  /** Nanoterra's emotes are on while neither the game's own words nor /admin say. */
11
12
  export const DEFAULT_EMOTES = "on";
13
+ /** Free play is on while neither the game's own words nor /admin say. */
14
+ export const DEFAULT_FREE_PLAY = "on";
15
+ /** The listing file's word for free play (`"freePlay": true` or `false`), from the settings. */
16
+ export const freePlayOn = (s) => (s.freePlay ?? DEFAULT_FREE_PLAY) === "on";
12
17
  /** The sign-in switch is on for any way but "none". */
13
18
  export const signinOn = (s) => s !== "none";
14
- /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
19
+ /** A request body for a press: only the known words, at least one. Anything else is refused, with the reason in words. */
15
20
  export function parsePatch(body) {
16
21
  if (!body || typeof body !== "object" || Array.isArray(body))
17
- return { ok: false, error: 'Send an object with "character", "signin", "friends" or "emotes".' };
22
+ return { ok: false, error: 'Send an object with "character", "signin", "friends", "emotes" or "freePlay".' };
18
23
  const patch = {};
19
24
  for (const [key, value] of Object.entries(body)) {
20
25
  if (key === "character" && isCharacter(value))
@@ -25,10 +30,14 @@ export function parsePatch(body) {
25
30
  patch.friends = value;
26
31
  else if (key === "emotes" && isEmotes(value))
27
32
  patch.emotes = value;
33
+ else if (key === "freePlay" && isFreePlay(value))
34
+ patch.freePlay = value;
28
35
  else if (key === "friends")
29
36
  return { ok: false, error: '"friends" must be "nanoterra", "own" or "off".' };
30
37
  else if (key === "emotes")
31
38
  return { ok: false, error: '"emotes" must be "on" or "off".' };
39
+ else if (key === "freePlay")
40
+ return { ok: false, error: '"freePlay" must be "on" or "off".' };
32
41
  else if (key === "character")
33
42
  return { ok: false, error: '"character" must be "nanoterra" or "own".' };
34
43
  else if (key === "signin")
@@ -37,7 +46,7 @@ export function parsePatch(body) {
37
46
  return { ok: false, error: `"${key}" is not a game switch.` };
38
47
  }
39
48
  if (!Object.keys(patch).length)
40
- return { ok: false, error: 'Send "character", "signin", "friends" or "emotes".' };
49
+ return { ok: false, error: 'Send "character", "signin", "friends", "emotes" or "freePlay".' };
41
50
  return { ok: true, patch };
42
51
  }
43
52
  /** The settings from what the store kept and the game's defaults. A stored value that is not one of the known words is ignored. */
@@ -47,7 +56,8 @@ export function mergeSettings(stored, defaults) {
47
56
  signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
48
57
  friends: isFriends(stored.friends) ? stored.friends : isFriends(defaults.friends) ? defaults.friends : DEFAULT_FRIENDS,
49
58
  emotes: isEmotes(stored.emotes) ? stored.emotes : isEmotes(defaults.emotes) ? defaults.emotes : DEFAULT_EMOTES,
50
- fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin), friends: isFriends(stored.friends), emotes: isEmotes(stored.emotes) },
59
+ freePlay: isFreePlay(stored.freePlay) ? stored.freePlay : isFreePlay(defaults.freePlay) ? defaults.freePlay : DEFAULT_FREE_PLAY,
60
+ fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin), friends: isFriends(stored.friends), emotes: isEmotes(stored.emotes), freePlay: isFreePlay(stored.freePlay) },
51
61
  };
52
62
  }
53
63
  /** The friends choice of the card: its title, and for each choice its short name (the card's three keys) and one plain sentence. Nanoterra's is the default. */
@@ -79,6 +89,11 @@ export const SWITCHES = {
79
89
  on: "On: players can play Nanoterra's emotes (Wave, Dance, Sit and more) from the Emotes wheel, and see the other players' emotes.",
80
90
  off: "Off: the game shows no Nanoterra emote. The game's own emotes, if it has any, stay on the wheel.",
81
91
  },
92
+ freePlay: {
93
+ title: "Free play",
94
+ on: "On: players can play without paying. Nanoterra's listing says “Free to play”.",
95
+ off: "Off: there is no free play. A player must pay in your game to play, and Nanoterra's listing says “Paid”. Your game asks for the payment: this switch only says so. Your paid game, its prices and its risk are yours.",
96
+ },
82
97
  };
83
98
  /**
84
99
  * The character comes through the sign-in: only a player who signs in from Nanoterra brings one. So the character switch is shown with its dependency. With sign-in Off the
@@ -90,12 +105,16 @@ export const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every pla
90
105
  /** What to tell the creator under the sign-in switch about Nanoterra's review. A game cannot see that review: it says so. */
91
106
  export const REVIEW_NOTE = "Your choice is saved here at once. The sign-in word is part of your listing file, and Nanoterra's review decides what Nanoterra shows for a game that is listed. This page cannot see that review. Check it on Nanoterra's launch page (step 6).";
92
107
  export const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
108
+ /** Under the free play switch: the word is part of the listing file, as the sign-in word is, so Nanoterra's review decides what Nanoterra shows. */
109
+ export const FREE_PLAY_NOTE = "Your choice is saved here at once. Free play is part of your listing file too, and Nanoterra's review decides what Nanoterra's listing says for a game that is listed.";
93
110
  /** The state of a switch from the settings, and the patch that a press sends (the other state). */
94
- export const stateOf = (id, s) => (id === "character" ? s.character === "nanoterra" : id === "emotes" ? (s.emotes ?? DEFAULT_EMOTES) === "on" : signinOn(s.signin));
111
+ export const stateOf = (id, s) => id === "character" ? s.character === "nanoterra" : id === "emotes" ? (s.emotes ?? DEFAULT_EMOTES) === "on" : id === "freePlay" ? freePlayOn(s) : signinOn(s.signin);
95
112
  export function pressOf(id, s) {
96
113
  if (id === "character")
97
114
  return { character: s.character === "nanoterra" ? "own" : "nanoterra" };
98
115
  if (id === "emotes")
99
116
  return { emotes: stateOf("emotes", s) ? "off" : "on" };
117
+ if (id === "freePlay")
118
+ return { freePlay: freePlayOn(s) ? "off" : "on" };
100
119
  return { signin: signinOn(s.signin) ? "none" : "exchange" };
101
120
  }