@nano133/nanoterra 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +161 -9
  3. package/ai/SPEC.md +27 -4
  4. package/dist/admin/adapters/d1.d.ts +1 -0
  5. package/dist/admin/adapters/d1.js +6 -4
  6. package/dist/admin/adapters/firestore.d.ts +1 -0
  7. package/dist/admin/adapters/firestore.js +7 -5
  8. package/dist/admin/conformance.d.ts +1 -1
  9. package/dist/admin/conformance.js +13 -5
  10. package/dist/admin/handler.d.ts +1 -1
  11. package/dist/admin/handler.js +3 -3
  12. package/dist/admin/index.d.ts +2 -2
  13. package/dist/admin/index.js +2 -2
  14. package/dist/admin/memory.js +3 -1
  15. package/dist/admin/panel/gameCard.d.ts +1 -1
  16. package/dist/admin/panel/gameCard.js +56 -6
  17. package/dist/admin/panel/mount.js +3 -3
  18. package/dist/admin/panel/overview.d.ts +1 -1
  19. package/dist/admin/panel/overview.js +4 -2
  20. package/dist/admin/panel/shelf.d.ts +4 -1
  21. package/dist/admin/panel/shelf.js +84 -29
  22. package/dist/admin/panel/shelfData.js +1 -1
  23. package/dist/admin/store.d.ts +2 -1
  24. package/dist/admin/switches.d.ts +23 -1
  25. package/dist/admin/switches.js +26 -5
  26. package/dist/friends/adapters/d1.d.ts +4 -0
  27. package/dist/friends/adapters/d1.js +131 -0
  28. package/dist/friends/adapters/firestore.d.ts +49 -0
  29. package/dist/friends/adapters/firestore.js +163 -0
  30. package/dist/friends/conformance.d.ts +11 -0
  31. package/dist/friends/conformance.js +229 -0
  32. package/dist/friends/handler.d.ts +31 -0
  33. package/dist/friends/handler.js +139 -0
  34. package/dist/friends/index.d.ts +8 -0
  35. package/dist/friends/index.js +12 -0
  36. package/dist/friends/memory.d.ts +9 -0
  37. package/dist/friends/memory.js +79 -0
  38. package/dist/friends/panel/index.d.ts +4 -0
  39. package/dist/friends/panel/index.js +10 -0
  40. package/dist/friends/panel/mount.d.ts +38 -0
  41. package/dist/friends/panel/mount.js +321 -0
  42. package/dist/friends/panel/window.d.ts +52 -0
  43. package/dist/friends/panel/window.js +72 -0
  44. package/dist/friends/rules.d.ts +92 -0
  45. package/dist/friends/rules.js +180 -0
  46. package/dist/friends/types.d.ts +58 -0
  47. package/dist/friends/types.js +24 -0
  48. package/dist/looks/change.d.ts +5 -5
  49. package/dist/looks/change.js +12 -7
  50. package/dist/looks/index.d.ts +2 -2
  51. package/dist/looks/index.js +1 -1
  52. package/dist/looks/json.d.ts +3 -2
  53. package/dist/looks/json.js +16 -9
  54. package/dist/looks/look.d.ts +26 -6
  55. package/dist/looks/look.js +37 -12
  56. package/package.json +11 -4
  57. package/src/admin/adapters/d1.ts +5 -4
  58. package/src/admin/adapters/firestore.ts +6 -5
  59. package/src/admin/conformance.ts +15 -6
  60. package/src/admin/handler.ts +3 -3
  61. package/src/admin/index.ts +3 -3
  62. package/src/admin/memory.ts +2 -1
  63. package/src/admin/panel/gameCard.ts +52 -6
  64. package/src/admin/panel/mount.ts +3 -3
  65. package/src/admin/panel/overview.ts +4 -3
  66. package/src/admin/panel/shelf.ts +135 -65
  67. package/src/admin/panel/shelfData.ts +1 -1
  68. package/src/admin/panel/style.css +10 -1
  69. package/src/admin/store.ts +2 -2
  70. package/src/admin/switches.ts +35 -9
  71. package/src/friends/adapters/d1.sql +33 -0
  72. package/src/friends/adapters/d1.ts +146 -0
  73. package/src/friends/adapters/firestore.ts +197 -0
  74. package/src/friends/conformance.ts +260 -0
  75. package/src/friends/handler.ts +144 -0
  76. package/src/friends/index.ts +17 -0
  77. package/src/friends/memory.ts +83 -0
  78. package/src/friends/panel/index.ts +13 -0
  79. package/src/friends/panel/mount.ts +346 -0
  80. package/src/friends/panel/style.css +303 -0
  81. package/src/friends/panel/window.ts +79 -0
  82. package/src/friends/rules.ts +196 -0
  83. package/src/friends/types.ts +57 -0
  84. package/src/looks/change.ts +12 -9
  85. package/src/looks/index.ts +2 -2
  86. package/src/looks/json.ts +12 -7
  87. package/src/looks/look.ts +47 -9
@@ -1,6 +1,12 @@
1
1
  /** The hats that the starter's character can wear. */
2
2
  export declare const HATS: readonly ["cap", "beanie", "crown", "tophat"];
3
3
  export type Hat = (typeof HATS)[number];
4
+ /** What an outfit changes: the body's colour, and a hat in its colour. */
5
+ export type Change = {
6
+ body: string;
7
+ hat?: Hat;
8
+ hatColor?: string;
9
+ };
4
10
  export type Look = {
5
11
  /** 1 to 40 lowercase letters, digits or dashes: "mint-cap". Never change the id of a look that someone owns. */
6
12
  id: string;
@@ -8,11 +14,14 @@ export type Look = {
8
14
  name: string;
9
15
  /** The price in US dollars with at most 2 decimals, for example 0.25. 0 means free for everyone. */
10
16
  priceUsd: number;
11
- change: {
12
- body: string;
13
- hat?: Hat;
14
- hatColor?: string;
15
- };
17
+ /**
18
+ * The wear slot that the look fills: none (or "outfit") for an outfit, or a slot of the game's own ("shovel", "charm"): 1 to 24 lowercase letters, digits
19
+ * or dashes, starting with a letter. A player wears one look in each slot. Never change the slot of a look that someone owns. `checkLooks` leaves it out
20
+ * for an outfit.
21
+ */
22
+ wear?: string;
23
+ /** What an outfit changes (an outfit must have it; an item of another slot must not: `isOutfit`). */
24
+ change?: Change;
16
25
  /**
17
26
  * What a game defines for itself: up to 8 slots, each a name and a value, that the game's own code reads ("wings": "ember", "trail": "#ffcf5a"). A name is 1 to 24
18
27
  * lowercase letters, digits or dashes, starting with a letter; a value is a word (1 to 40 lowercase letters, digits or dashes) or a colour ("#rrggbb"). Optional: a look
@@ -23,6 +32,8 @@ export type Look = {
23
32
  /** The slots of a look: names and values, words or colours. */
24
33
  export type Slots = Record<string, string>;
25
34
  export declare const ID_PATTERN: RegExp;
35
+ /** The wear slot of an outfit (the character's own look). A look with no `wear` is an outfit too. */
36
+ export declare const OUTFIT = "outfit";
26
37
  export declare const COLOR_PATTERN: RegExp;
27
38
  /** At most this many slots on a look. */
28
39
  export declare const SLOTS_MAX = 8;
@@ -38,7 +49,10 @@ export declare const PRICE_MAX_USD = 1000;
38
49
  export type Problem = {
39
50
  /** The place of the look in the list, from 1, or null for a mistake of the whole list. */
40
51
  look: number | null;
41
- /** The field: "list", "id", "name", "priceUsd", "change.body", "change.hat", "change.hatColor", "slots", "slots.<name>" or "free". (The change rules also use "slots" for a slot that changed on a look that players own.) */
52
+ /**
53
+ * The field: "list", "id", "name", "priceUsd", "wear", "change", "change.body", "change.hat", "change.hatColor", "slots", "slots.<name>" or "free". (The change
54
+ * rules also use "slots" and "wear" for a slot or a wear slot that changed on a look that players own.)
55
+ */
42
56
  field: string;
43
57
  message: string;
44
58
  };
@@ -60,3 +74,9 @@ export declare function cleanSlots(slots: Slots | undefined): Slots | undefined;
60
74
  export declare function checkLooks(rawInput: unknown): Look[];
61
75
  /** "$0.25" */
62
76
  export declare const usdText: (usd: number) => string;
77
+ /** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
78
+ export declare const wearOf: (l: Pick<Look, "wear">) => string;
79
+ /** Whether a look is an outfit (the character's own look, with its `change`), not an item of another wear slot. */
80
+ export declare const isOutfit: (l: Look) => l is Look & {
81
+ change: Change;
82
+ };
@@ -1,8 +1,14 @@
1
1
  // The look: one thing a player can buy to change how their character looks. The rules are the game starter's (looks.json): a look only
2
2
  // changes how the character looks, it is never random and it never changes a score, a speed or a rule.
3
+ //
4
+ // A look fills one WEAR SLOT. An outfit (no `wear`, or "outfit") is the character's own look: its body colour and its hat (`change`); a player wears one
5
+ // outfit. Every other wear slot is the game's own (a tool's skin, a charm, a trail: "shovel", "charm"): an item there has no `change` (the game reads what
6
+ // it is from its id or its `slots`), and a player wears one item in each slot, beside the outfit.
3
7
  /** The hats that the starter's character can wear. */
4
8
  export const HATS = ["cap", "beanie", "crown", "tophat"];
5
9
  export const ID_PATTERN = /^[a-z0-9-]{1,40}$/;
10
+ /** The wear slot of an outfit (the character's own look). A look with no `wear` is an outfit too. */
11
+ export const OUTFIT = "outfit";
6
12
  export const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
7
13
  /** At most this many slots on a look. */
8
14
  export const SLOTS_MAX = 8;
@@ -27,7 +33,7 @@ export function lookProblems(raw) {
27
33
  if (!Array.isArray(raw) || raw.length === 0)
28
34
  return [{ look: null, field: "list", message: "looks.json must be a list with at least one look" }];
29
35
  const seen = new Set();
30
- let free = false;
36
+ let freeOutfit = false;
31
37
  raw.forEach((l, i) => {
32
38
  const where = `looks.json, look ${i + 1}`;
33
39
  const v = (l ?? {});
@@ -44,19 +50,32 @@ export function lookProblems(raw) {
44
50
  add("priceUsd", '"priceUsd" must be a number from 0 to 1000');
45
51
  else if (!wholeCents(v.priceUsd))
46
52
  add("priceUsd", '"priceUsd" can have at most 2 decimals (cents)');
47
- else if (v.priceUsd === 0)
48
- free = true;
49
- if (typeof v.change?.body !== "string" || !COLOR_PATTERN.test(v.change.body))
50
- add("change.body", '"change.body" must be a colour like "#4aa8ff"');
51
- if (v.change?.hat !== undefined && !HATS.includes(v.change.hat))
52
- add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
53
- if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor)))
54
- add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
53
+ const wearOk = v.wear === undefined || (typeof v.wear === "string" && SLOT_NAME_PATTERN.test(v.wear) && !RESERVED_SLOT_NAMES.includes(v.wear));
54
+ if (!wearOk)
55
+ add("wear", `"wear" must be "${OUTFIT}" or the name of a wear slot: 1 to 24 lowercase letters, digits or dashes, starting with a letter ("shovel")`);
56
+ const outfit = v.wear === undefined || v.wear === OUTFIT;
57
+ if (outfit) {
58
+ if (typeof v.change?.body !== "string" || !COLOR_PATTERN.test(v.change.body))
59
+ add("change.body", '"change.body" must be a colour like "#4aa8ff"');
60
+ if (v.change?.hat !== undefined && !HATS.includes(v.change.hat))
61
+ add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
62
+ if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor)))
63
+ add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
64
+ if (v.priceUsd === 0)
65
+ freeOutfit = true;
66
+ }
67
+ else if (wearOk && v.change !== undefined)
68
+ add("change", `"change" is only for an outfit: a look worn in the "${v.wear}" slot does not change the body (remove "change")`);
55
69
  if (v.slots !== undefined)
56
70
  slotProblems(v.slots).forEach((s) => add(s.field, s.message));
57
71
  });
58
- if (!out.length && !free)
59
- out.push({ look: null, field: "free", message: "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
72
+ // the starter's words for a list of outfits only; a list with items says that an item does not count
73
+ const items = raw.some((l) => {
74
+ const w = l?.wear;
75
+ return w !== undefined && w !== OUTFIT;
76
+ });
77
+ if (!out.length && !freeOutfit)
78
+ out.push({ look: null, field: "free", message: items ? 'looks.json needs at least one free outfit (priceUsd 0, and no "wear"): every player starts with it' : "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
60
79
  return out;
61
80
  }
62
81
  /** Whether `v` is a plain object (not null, not a list, not a class instance). */
@@ -106,8 +125,14 @@ export function checkLooks(rawInput) {
106
125
  throw new Error(first.message);
107
126
  return raw.map((l) => {
108
127
  const slots = cleanSlots(l.slots);
109
- return { id: l.id, name: l.name, priceUsd: l.priceUsd, change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor }, ...(slots ? { slots } : {}) };
128
+ const wear = wearOf(l);
129
+ const change = isOutfit(l) ? { change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor } } : {};
130
+ return { id: l.id, name: l.name, priceUsd: l.priceUsd, ...(wear === OUTFIT ? {} : { wear }), ...change, ...(slots ? { slots } : {}) };
110
131
  });
111
132
  }
112
133
  /** "$0.25" */
113
134
  export const usdText = (usd) => `$${usd.toFixed(2)}`;
135
+ /** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
136
+ export const wearOf = (l) => l.wear ?? OUTFIT;
137
+ /** Whether a look is an outfit (the character's own look, with its `change`), not an item of another wear slot. */
138
+ export const isOutfit = (l) => wearOf(l) === OUTFIT && l.change !== undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.4.0",
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) and the looks ladder.",
3
+ "version": "0.6.0",
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 the friends list.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -18,7 +18,14 @@
18
18
  "./admin/d1": { "types": "./dist/admin/adapters/d1.d.ts", "import": "./dist/admin/adapters/d1.js" },
19
19
  "./admin/firestore": { "types": "./dist/admin/adapters/firestore.d.ts", "import": "./dist/admin/adapters/firestore.js" },
20
20
  "./admin/panel": { "types": "./dist/admin/panel/index.d.ts", "import": "./dist/admin/panel/index.js" },
21
- "./admin/style.css": "./src/admin/panel/style.css"
21
+ "./admin/style.css": "./src/admin/panel/style.css",
22
+ "./friends": { "types": "./dist/friends/index.d.ts", "import": "./dist/friends/index.js" },
23
+ "./friends/conformance": { "types": "./dist/friends/conformance.d.ts", "import": "./dist/friends/conformance.js" },
24
+ "./friends/d1": { "types": "./dist/friends/adapters/d1.d.ts", "import": "./dist/friends/adapters/d1.js" },
25
+ "./friends/d1.sql": "./src/friends/adapters/d1.sql",
26
+ "./friends/firestore": { "types": "./dist/friends/adapters/firestore.d.ts", "import": "./dist/friends/adapters/firestore.js" },
27
+ "./friends/panel": { "types": "./dist/friends/panel/index.d.ts", "import": "./dist/friends/panel/index.js" },
28
+ "./friends/style.css": "./src/friends/panel/style.css"
22
29
  },
23
30
  "sideEffects": false,
24
31
  "files": ["dist", "src", "README.md", "CHANGELOG.md", "LICENSE", "ai"],
@@ -46,5 +53,5 @@
46
53
  },
47
54
  "homepage": "https://nano133.com/build/nanoterra",
48
55
  "license": "MIT",
49
- "keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics"]
56
+ "keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics", "friends", "friends list"]
50
57
  }
@@ -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 two settings (`setting:character`, `setting:signin` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
2
+ // It writes ONLY the three settings (`setting:character`, `setting:signin`, `setting:friends` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
3
3
  // (`logins: true`): then the one other write is `DELETE FROM sessions WHERE scope = 'admin'` ("sign out everywhere").
4
4
  //
5
5
  // import { createD1Store } from "@nano133/nanoterra/admin/d1";
@@ -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" } as const;
24
+ export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" } 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).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).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) };
39
+ return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends) };
40
40
  },
41
41
 
42
42
  async saveSettings(patch: GamePatch, now: number) {
@@ -45,6 +45,7 @@ export function createD1Store(db: D1Like, options: { logins?: boolean } = {}): A
45
45
  const batch = [];
46
46
  if (patch.character) batch.push(write(D1_KEYS.character, patch.character));
47
47
  if (patch.signin) batch.push(write(D1_KEYS.signin, patch.signin));
48
+ if (patch.friends) batch.push(write(D1_KEYS.friends, patch.friends));
48
49
  if (batch.length) await db.batch(batch);
49
50
  },
50
51
 
@@ -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 two settings; everything else it does is a read.
2
+ // Admin SDK's `Firestore` fits. It writes ONLY the three settings; everything else it does is a read.
3
3
  //
4
4
  // The data model (the names a game must have; only the three collection names can be changed):
5
- // meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "rate" (value is JSON { usd, sources })
5
+ // meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "setting:friends", "rate" (value is JSON { usd, sources })
6
6
  // orders/{id} { player, lookId, usd, amount, status, createdAt, expiresAt, paidAt, payer, hash } `amount` is the raw amount as text
7
7
  // owned/{player__lookId} { player, lookId, orderId, grantedAt }
8
8
  //
@@ -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" } as const;
36
+ export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" } 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,8 @@ export function createFirestoreStore(db: FirestoreLike, collections: { meta?: st
49
49
  };
50
50
  return {
51
51
  async readSettings() {
52
- const [character, signin] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin)]);
53
- return { character: character?.value, signin: signin?.value };
52
+ const [character, signin, friends] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin), valueOf(FIRESTORE_KEYS.friends)]);
53
+ return { character: character?.value, signin: signin?.value, friends: friends?.value };
54
54
  },
55
55
 
56
56
  async saveSettings(patch: GamePatch, now: number) {
@@ -58,6 +58,7 @@ export function createFirestoreStore(db: FirestoreLike, collections: { meta?: st
58
58
  let n = 0;
59
59
  if (patch.character) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.character), { value: patch.character, at: now }), n++);
60
60
  if (patch.signin) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
61
+ if (patch.friends) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.friends), { value: patch.friends, at: now }), n++);
61
62
  if (n) await batch.commit();
62
63
  },
63
64
 
@@ -97,7 +97,7 @@ async function read(res: Response): Promise<{ status: number; text: string; json
97
97
  const REFUSAL_KEYS = new Set(["error", "reason", "signedInAs"]);
98
98
 
99
99
  /** Words of the game's data that a refusal or an answer to a visitor must never hold. */
100
- const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"warnings"/;
100
+ const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"warnings"/;
101
101
 
102
102
  export function adminConformance(host: ConformanceHost): ConformanceCase[] {
103
103
  const cases: ConformanceCase[] = [];
@@ -332,7 +332,7 @@ export function adminConformance(host: ConformanceHost): ConformanceCase[] {
332
332
  export type StoreHarness = {
333
333
  store: AdminStore;
334
334
  seed(data: { orders?: OrderRow[]; owned?: { player: string; lookId: string; at: number }[]; rate?: { value: string; at: number } }): Promise<void>;
335
- /** 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 two settings. Same rows, same text. */
335
+ /** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the three settings. Same rows, same text. */
336
336
  snapshot(): Promise<string>;
337
337
  /** Only for a store with `recentLogins` and `signOutEverywhere`: put admin sign-ins in the game's database (the store only reads them). */
338
338
  seedLogins?(rows: { at: number; country: string | null; browser: string }[]): Promise<void>;
@@ -358,7 +358,16 @@ export function adminStoreConformance(make: () => Promise<StoreHarness>): Confor
358
358
 
359
359
  add("a new store has no settings of its own", async ({ store }) => {
360
360
  const s = await store.readSettings();
361
- check(s.character === undefined && s.signin === undefined, `expected no settings, got ${JSON.stringify(s)}`);
361
+ check(s.character === undefined && s.signin === undefined && s.friends === undefined, `expected no settings, got ${JSON.stringify(s)}`);
362
+ });
363
+
364
+ 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 }) => {
365
+ await store.saveSettings({ signin: "none", character: "own" }, NOW);
366
+ for (const [i, friends] of (["own", "off", "nanoterra"] as const).entries()) {
367
+ await store.saveSettings({ friends }, NOW + 1 + i);
368
+ const s = await store.readSettings();
369
+ check(s.friends === friends && s.signin === "none" && s.character === "own", `after friends ${friends}: ${JSON.stringify(s)}`);
370
+ }
362
371
  });
363
372
 
364
373
  add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
@@ -376,13 +385,13 @@ export function adminStoreConformance(make: () => Promise<StoreHarness>): Confor
376
385
  add("an empty patch writes nothing and does not fail", async ({ store }) => {
377
386
  await store.saveSettings({}, NOW);
378
387
  const s = await store.readSettings();
379
- check(s.character === undefined && s.signin === undefined, "an empty patch must save nothing");
388
+ check(s.character === undefined && s.signin === undefined && s.friends === undefined, "an empty patch must save nothing");
380
389
  });
381
390
 
382
- add("saving changes nothing but the two settings: no order, no owned look, no other meta row", async (h) => {
391
+ add("saving changes nothing but the settings: no order, no owned look, no other meta row", async (h) => {
383
392
  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 } });
384
393
  const before = await h.snapshot();
385
- await h.store.saveSettings({ character: "own", signin: "none" }, NOW + 5);
394
+ await h.store.saveSettings({ character: "own", signin: "none", friends: "off" }, NOW + 5);
386
395
  check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
387
396
  });
388
397
 
@@ -1,8 +1,8 @@
1
1
  // The admin handler: the routes of /admin on the Web `Request` and `Response` types, so it fits a Worker, Firebase Functions and Node.
2
2
  //
3
3
  // GET {base}/summary the numbers: status, orders, the looks that players own
4
- // GET {base}/game the two switches
5
- // POST {base}/game a press: { character } or { signin } (only the two known words)
4
+ // GET {base}/game the game switches
5
+ // POST {base}/game a press: { character }, { signin } or { friends } (only the known words)
6
6
  // POST {base}/check the payment check, when the game gives one
7
7
  //
8
8
  // EVERY request under the base goes through `adminAccess` first, before the route is even looked up: a visitor who is not the owner gets 401 or 403
@@ -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 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 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);
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // adminAccess the guard: one pure function, who may open /admin
4
4
  // createAdminHandler the routes, on Request and Response
5
- // loadGameSettings the two switches for the game's own routes
5
+ // loadGameSettings the game switches for the game's own routes
6
6
  // createMemoryStore a store in memory, for tests
7
7
  // (the conformance suite is its own path: "@nano133/nanoterra/admin/conformance"; the adapters are "/admin/d1" and "/admin/firestore")
8
8
 
@@ -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, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
21
- export type { Character, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
20
+ export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_FRIENDS, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
21
+ export type { Character, Friends, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
@@ -35,11 +35,12 @@ 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") };
38
+ return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin"), friends: store.rows.get("setting:friends") };
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
+ if (patch.friends) store.rows.set("setting:friends", patch.friends);
43
44
  store.writes++;
44
45
  },
45
46
  async summary(now: number): Promise<StoreSummary> {
@@ -1,14 +1,15 @@
1
- // The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra". A press saves at once
2
- // (POST {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
3
- // nothing. The words are in ../switches.ts. The card changes ONLY these two settings: never a look, a price or an order.
1
+ // The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra", and one three-way
2
+ // choice, "Friends list: Nanoterra / The game's own / Off". A press saves at once (POST {api}/game, behind the same admin guard as every admin call) and
3
+ // the game reads the setting at run time, so the creator pushes and deploys nothing. The words are in ../switches.ts. The card changes ONLY these three
4
+ // settings: never a look, a price or an order.
4
5
 
5
- import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf, type GameState, type SwitchId } from "../switches.js";
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
7
  import { ApiError, type Api } from "./api.js";
7
8
  import { h } from "./dom.js";
8
9
 
9
10
  /**
10
11
  * The card for the settings `initial`. It keeps its own state: a press shows "Saving…", then "Saved." or the server's words. `defaultsFile` is where the game's defaults are
11
- * written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the two switches too).
12
+ * written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the switches too).
12
13
  */
13
14
  export function gameCard(api: Api, initial: GameState, defaultsFile = "the game's own settings", onChange?: (state: GameState) => void): HTMLElement {
14
15
  let state = initial;
@@ -16,7 +17,11 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
16
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. */
17
18
  const needs = h("p", { class: "fine switch-needs" });
18
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")));
19
- card.append(h("p", { class: "fine card-intro" }, `Two 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" }, `Three 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
+ /** The friends choice: a radio group of three keys (arrow keys move between them, as a radio group does). */
22
+ const friendsKeys = new Map<Friends, HTMLButtonElement>();
23
+ const friendsNote = h("p", { class: "switch-note", id: "switch-friends-note" });
24
+ const friendsStatus = h("span", { class: "fine", role: "status", "aria-live": "polite" });
20
25
 
21
26
  const draw = () => {
22
27
  for (const [id, row] of rows) {
@@ -28,6 +33,29 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
28
33
  }
29
34
  needs.hidden = !stateOf("character", state);
30
35
  needs.textContent = signinOn(state.signin) ? CHARACTER_NEEDS_SIGNIN : CHARACTER_SIGNIN_OFF;
36
+ for (const [id, key] of friendsKeys) {
37
+ const on = state.friends === id;
38
+ key.setAttribute("aria-checked", on ? "true" : "false");
39
+ key.tabIndex = on ? 0 : -1;
40
+ key.classList.toggle("is-on", on);
41
+ }
42
+ friendsNote.textContent = FRIENDS_CHOICES[state.friends]?.note ?? "";
43
+ };
44
+
45
+ const choose = async (id: Friends) => {
46
+ if (state.friends === id) return;
47
+ for (const key of friendsKeys.values()) key.disabled = true;
48
+ friendsStatus.textContent = "Saving…";
49
+ try {
50
+ state = await api.post<GameState>("/game", { friends: id });
51
+ friendsStatus.textContent = "Saved.";
52
+ draw();
53
+ onChange?.(state);
54
+ } catch (e) {
55
+ friendsStatus.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Nothing was changed.";
56
+ } finally {
57
+ for (const key of friendsKeys.values()) key.disabled = false;
58
+ }
31
59
  };
32
60
 
33
61
  const press = async (id: SwitchId) => {
@@ -62,6 +90,24 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
62
90
  // The sign-in word is part of the listing file: Nanoterra's review decides what Nanoterra shows. This page cannot see that review.
63
91
  if (id === "signin") card.append(h("p", { class: "fine switch-review" }, REVIEW_NOTE, " ", h("a", { href: REVIEW_LINK, target: "_blank", rel: "noopener noreferrer" }, "Open the launch page")));
64
92
  }
93
+
94
+ const order = Object.keys(FRIENDS_CHOICES) as Friends[];
95
+ const group = h("div", { class: "seg friends-choice", role: "radiogroup", "aria-labelledby": "switch-friends-label", "aria-describedby": "switch-friends-note" });
96
+ for (const id of order) {
97
+ const key = h("button", { class: "seg-btn", type: "button", role: "radio", "aria-checked": "false", "data-friends": id }, FRIENDS_CHOICES[id].name);
98
+ key.addEventListener("click", () => void choose(id));
99
+ key.addEventListener("keydown", (e) => {
100
+ const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
101
+ if (!step) return;
102
+ e.preventDefault();
103
+ const next = order[(order.indexOf(id) + step + order.length) % order.length];
104
+ friendsKeys.get(next)!.focus();
105
+ void choose(next);
106
+ });
107
+ friendsKeys.set(id, key);
108
+ group.append(key);
109
+ }
110
+ 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));
65
111
  draw();
66
112
  return card;
67
113
  }
@@ -69,7 +69,7 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
69
69
  let gameState: GameState | null = null;
70
70
  let dash: Dashboard | null = null;
71
71
 
72
- /** The card, made once. A fault in reading the two switches must not hide the orders: the card is left out and the rest still shows. */
72
+ /** The card, made once. A fault in reading the game switches must not hide the orders: the card is left out and the rest still shows. */
73
73
  async function gameSection(): Promise<HTMLElement | null> {
74
74
  if (game) return game;
75
75
  try {
@@ -156,8 +156,8 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
156
156
  return shelf.el;
157
157
  }
158
158
 
159
- /** A look's body colour, while the game still has the look (for the dots in the lists). */
160
- const colourOf = (id: string): string | null => options.looks.find((l) => l.id === id)?.change.body ?? null;
159
+ /** An outfit's body colour, while the game still has the look (for the dots in the lists); none for an item of another wear slot. */
160
+ const colourOf = (id: string): string | null => options.looks.find((l) => l.id === id)?.change?.body ?? null;
161
161
 
162
162
  function build(): Dashboard {
163
163
  const tabs = tabBar("overview");
@@ -4,7 +4,7 @@
4
4
  import type { Look } from "../../looks/look.js";
5
5
  import { priceText } from "../../looks/ladder.js";
6
6
  import type { AdminSummary } from "../store.js";
7
- import { SWITCHES, stateOf, type GameState } from "../switches.js";
7
+ import { FRIENDS_CHOICES, FRIENDS_TITLE, SWITCHES, stateOf, type GameState } from "../switches.js";
8
8
  import { dollars, h, plural } from "./dom.js";
9
9
  import { icon } from "./icons.js";
10
10
  import { orderRow, type OrderFilter } from "./orders.js";
@@ -29,7 +29,7 @@ export function bestSellers(s: Pick<AdminSummary, "orders" | "players">, looks:
29
29
  export type OverviewView = {
30
30
  el: HTMLElement;
31
31
  update(s: AdminSummary): void;
32
- /** The two switches as they are now (null: they could not be read). */
32
+ /** The game switches as they are now (null: they could not be read). */
33
33
  setGame(state: GameState | null): void;
34
34
  };
35
35
 
@@ -51,9 +51,10 @@ export function overviewView(looks: readonly Look[], colourOf: (lookId: string)
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
  });
54
+ if (game) rows.push(h("li", {}, h("span", {}, FRIENDS_TITLE), h("span", { class: `pill ${game.friends === "off" ? "pill-off" : "pill-on"}` }, FRIENDS_CHOICES[game.friends]?.name ?? "Unknown")));
54
55
  gameBox.replaceChildren(
55
56
  h("div", { class: "card-head" }, h("h2", { id: "n133-glance-game" }, "Game switches"), linkKey("Change", () => go("settings"))),
56
- game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The two switches could not be read. Reload the page to try again."),
57
+ game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The game switches could not be read. Reload the page to try again."),
57
58
  );
58
59
  }
59
60