@nano133/nanoterra 0.13.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,13 @@
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
+
3
11
  ## 0.13.0 (2026-11-01)
4
12
 
5
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
package/README.md CHANGED
@@ -536,7 +536,7 @@ import { createAdminHandler } from "@nano133/nanoterra/admin";
536
536
 
537
537
  const admin = createAdminHandler({
538
538
  store, // your database: readSettings(), saveSettings(patch, now), summary(now) (a memory store is in the package, for tests)
539
- 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)
540
540
  owner: ownerAddress, // "nano_..." lowercase, its checksum checked by you; null when none is set
541
541
  session: (req, now) => readAdminSession(req, now), // YOUR admin login: its own cookie; return { address, createdAt, via: "wallet" } or null
542
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")
@@ -562,11 +562,21 @@ neither header (curl) and the browser's own address bar (`Sec-Fetch-Site: none`)
562
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
563
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
564
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
565
- 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.
566
566
 
567
- **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,
568
568
  and your defaults when the store fails.
569
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
+
570
580
  **The conformance suite** proves your whole chain, not only the guard: the cookie you read, the session lookup, the owner you pass.
571
581
 
572
582
  ```ts
@@ -614,8 +624,8 @@ the package's own sign-in, `/admin-signin`, passes it): only a wallet signature
614
624
 
615
625
  ### The store (an adapter for your database)
616
626
 
617
- `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)
618
- (`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
619
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,
620
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.
621
631
 
@@ -677,7 +687,7 @@ status warnings, one banner); four numbers (Earned, Open now, To send back, Play
677
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).
678
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
679
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
680
- 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
681
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.
682
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
683
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`,
@@ -714,7 +724,7 @@ flat picture stays. `createLookPreview({ size?, body?, record?, background?, key
714
724
 
715
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
716
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
717
- 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`,
718
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
719
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
720
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.13.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>;
@@ -1,5 +1,5 @@
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
 
4
4
  /** Which character the player is: Nanoterra's island character (the one the player has there) or the game's own. */
5
5
  export type Character = "nanoterra" | "own";
@@ -12,52 +12,72 @@ export type Signin = "exchange" | "ticket" | "none";
12
12
  export type Friends = "nanoterra" | "own" | "off";
13
13
  /** 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. */
14
14
  export type Emotes = "on" | "off";
15
- /** The game's own words for the switches. `friends` and `emotes` may be left out: "nanoterra" and "on" then (on by default). */
16
- export type GameSettings = { character: Character; signin: Signin; friends?: Friends; emotes?: Emotes };
15
+ /**
16
+ * 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
17
+ * (`"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.
18
+ */
19
+ export type FreePlay = "on" | "off";
20
+ /** The game's own words for the switches. `friends`, `emotes` and `freePlay` may be left out: "nanoterra", "on" and "on" then (on by default). */
21
+ export type GameSettings = { character: Character; signin: Signin; friends?: Friends; emotes?: Emotes; freePlay?: FreePlay };
17
22
  /** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
18
- export type GamePatch = { character?: Character; signin?: "exchange" | "none"; friends?: Friends; emotes?: Emotes };
23
+ export type GamePatch = { character?: Character; signin?: "exchange" | "none"; friends?: Friends; emotes?: Emotes; freePlay?: FreePlay };
19
24
  /** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
20
- export type GameState = { character: Character; signin: Signin; friends: Friends; emotes: Emotes; fromAdmin: { character: boolean; signin: boolean; friends: boolean; emotes: boolean } };
25
+ export type GameState = {
26
+ character: Character;
27
+ signin: Signin;
28
+ friends: Friends;
29
+ emotes: Emotes;
30
+ freePlay: FreePlay;
31
+ fromAdmin: { character: boolean; signin: boolean; friends: boolean; emotes: boolean; freePlay: boolean };
32
+ };
21
33
 
22
34
  export const isCharacter = (v: unknown): v is Character => v === "nanoterra" || v === "own";
23
35
  export const isSigninChoice = (v: unknown): v is "exchange" | "none" => v === "exchange" || v === "none";
24
36
  export const isSignin = (v: unknown): v is Signin => v === "exchange" || v === "ticket" || v === "none";
25
37
  export const isFriends = (v: unknown): v is Friends => v === "nanoterra" || v === "own" || v === "off";
26
38
  export const isEmotes = (v: unknown): v is Emotes => v === "on" || v === "off";
39
+ export const isFreePlay = (v: unknown): v is FreePlay => v === "on" || v === "off";
27
40
  /** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
28
41
  export const DEFAULT_FRIENDS: Friends = "nanoterra";
29
42
  /** Nanoterra's emotes are on while neither the game's own words nor /admin say. */
30
43
  export const DEFAULT_EMOTES: Emotes = "on";
44
+ /** Free play is on while neither the game's own words nor /admin say. */
45
+ export const DEFAULT_FREE_PLAY: FreePlay = "on";
46
+ /** The listing file's word for free play (`"freePlay": true` or `false`), from the settings. */
47
+ export const freePlayOn = (s: Pick<GameSettings, "freePlay">): boolean => (s.freePlay ?? DEFAULT_FREE_PLAY) === "on";
31
48
  /** The sign-in switch is on for any way but "none". */
32
49
  export const signinOn = (s: Signin): boolean => s !== "none";
33
50
 
34
- /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
51
+ /** A request body for a press: only the known words, at least one. Anything else is refused, with the reason in words. */
35
52
  export function parsePatch(body: unknown): { ok: true; patch: GamePatch } | { ok: false; error: string } {
36
- if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: 'Send an object with "character", "signin", "friends" or "emotes".' };
53
+ if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: 'Send an object with "character", "signin", "friends", "emotes" or "freePlay".' };
37
54
  const patch: GamePatch = {};
38
55
  for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
39
56
  if (key === "character" && isCharacter(value)) patch.character = value;
40
57
  else if (key === "signin" && isSigninChoice(value)) patch.signin = value;
41
58
  else if (key === "friends" && isFriends(value)) patch.friends = value;
42
59
  else if (key === "emotes" && isEmotes(value)) patch.emotes = value;
60
+ else if (key === "freePlay" && isFreePlay(value)) patch.freePlay = value;
43
61
  else if (key === "friends") return { ok: false, error: '"friends" must be "nanoterra", "own" or "off".' };
44
62
  else if (key === "emotes") return { ok: false, error: '"emotes" must be "on" or "off".' };
63
+ else if (key === "freePlay") return { ok: false, error: '"freePlay" must be "on" or "off".' };
45
64
  else if (key === "character") return { ok: false, error: '"character" must be "nanoterra" or "own".' };
46
65
  else if (key === "signin") return { ok: false, error: '"signin" must be "exchange" or "none".' };
47
66
  else return { ok: false, error: `"${key}" is not a game switch.` };
48
67
  }
49
- if (!Object.keys(patch).length) return { ok: false, error: 'Send "character", "signin", "friends" or "emotes".' };
68
+ if (!Object.keys(patch).length) return { ok: false, error: 'Send "character", "signin", "friends", "emotes" or "freePlay".' };
50
69
  return { ok: true, patch };
51
70
  }
52
71
 
53
72
  /** 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. */
54
- export function mergeSettings(stored: { character?: unknown; signin?: unknown; friends?: unknown; emotes?: unknown }, defaults: GameSettings): GameState {
73
+ export function mergeSettings(stored: { character?: unknown; signin?: unknown; friends?: unknown; emotes?: unknown; freePlay?: unknown }, defaults: GameSettings): GameState {
55
74
  return {
56
75
  character: isCharacter(stored.character) ? stored.character : defaults.character,
57
76
  signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
58
77
  friends: isFriends(stored.friends) ? stored.friends : isFriends(defaults.friends) ? defaults.friends : DEFAULT_FRIENDS,
59
78
  emotes: isEmotes(stored.emotes) ? stored.emotes : isEmotes(defaults.emotes) ? defaults.emotes : DEFAULT_EMOTES,
60
- fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin), friends: isFriends(stored.friends), emotes: isEmotes(stored.emotes) },
79
+ freePlay: isFreePlay(stored.freePlay) ? stored.freePlay : isFreePlay(defaults.freePlay) ? defaults.freePlay : DEFAULT_FREE_PLAY,
80
+ fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin), friends: isFriends(stored.friends), emotes: isEmotes(stored.emotes), freePlay: isFreePlay(stored.freePlay) },
61
81
  };
62
82
  }
63
83
 
@@ -76,7 +96,7 @@ export const FRIENDS_CHOICES: Record<Friends, { name: string; note: string }> =
76
96
  };
77
97
 
78
98
  /** One switch of the card: its title, and one plain sentence for each state. */
79
- export type SwitchId = "character" | "signin" | "emotes";
99
+ export type SwitchId = "character" | "signin" | "emotes" | "freePlay";
80
100
  export const SWITCHES: Record<SwitchId, { title: string; on: string; off: string }> = {
81
101
  character: {
82
102
  title: "Players can play as their Nanoterra character",
@@ -93,6 +113,11 @@ export const SWITCHES: Record<SwitchId, { title: string; on: string; off: string
93
113
  on: "On: players can play Nanoterra's emotes (Wave, Dance, Sit and more) from the Emotes wheel, and see the other players' emotes.",
94
114
  off: "Off: the game shows no Nanoterra emote. The game's own emotes, if it has any, stay on the wheel.",
95
115
  },
116
+ freePlay: {
117
+ title: "Free play",
118
+ on: "On: players can play without paying. Nanoterra's listing says “Free to play”.",
119
+ 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.",
120
+ },
96
121
  };
97
122
 
98
123
  /**
@@ -107,11 +132,15 @@ export const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every pla
107
132
  export const REVIEW_NOTE =
108
133
  "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).";
109
134
  export const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
135
+ /** 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. */
136
+ 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.";
110
137
 
111
138
  /** The state of a switch from the settings, and the patch that a press sends (the other state). */
112
- export const stateOf = (id: SwitchId, s: GameSettings): boolean => (id === "character" ? s.character === "nanoterra" : id === "emotes" ? (s.emotes ?? DEFAULT_EMOTES) === "on" : signinOn(s.signin));
139
+ export const stateOf = (id: SwitchId, s: GameSettings): boolean =>
140
+ id === "character" ? s.character === "nanoterra" : id === "emotes" ? (s.emotes ?? DEFAULT_EMOTES) === "on" : id === "freePlay" ? freePlayOn(s) : signinOn(s.signin);
113
141
  export function pressOf(id: SwitchId, s: GameSettings): GamePatch {
114
142
  if (id === "character") return { character: s.character === "nanoterra" ? "own" : "nanoterra" };
115
143
  if (id === "emotes") return { emotes: stateOf("emotes", s) ? "off" : "on" };
144
+ if (id === "freePlay") return { freePlay: freePlayOn(s) ? "off" : "on" };
116
145
  return { signin: signinOn(s.signin) ? "none" : "exchange" };
117
146
  }