@nano133/nanoterra 0.5.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 (70) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +133 -8
  3. package/ai/SPEC.md +25 -3
  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 +1 -1
  18. package/dist/admin/panel/overview.d.ts +1 -1
  19. package/dist/admin/panel/overview.js +4 -2
  20. package/dist/admin/store.d.ts +2 -1
  21. package/dist/admin/switches.d.ts +23 -1
  22. package/dist/admin/switches.js +26 -5
  23. package/dist/friends/adapters/d1.d.ts +4 -0
  24. package/dist/friends/adapters/d1.js +131 -0
  25. package/dist/friends/adapters/firestore.d.ts +49 -0
  26. package/dist/friends/adapters/firestore.js +163 -0
  27. package/dist/friends/conformance.d.ts +11 -0
  28. package/dist/friends/conformance.js +229 -0
  29. package/dist/friends/handler.d.ts +31 -0
  30. package/dist/friends/handler.js +139 -0
  31. package/dist/friends/index.d.ts +8 -0
  32. package/dist/friends/index.js +12 -0
  33. package/dist/friends/memory.d.ts +9 -0
  34. package/dist/friends/memory.js +79 -0
  35. package/dist/friends/panel/index.d.ts +4 -0
  36. package/dist/friends/panel/index.js +10 -0
  37. package/dist/friends/panel/mount.d.ts +38 -0
  38. package/dist/friends/panel/mount.js +321 -0
  39. package/dist/friends/panel/window.d.ts +52 -0
  40. package/dist/friends/panel/window.js +72 -0
  41. package/dist/friends/rules.d.ts +92 -0
  42. package/dist/friends/rules.js +180 -0
  43. package/dist/friends/types.d.ts +58 -0
  44. package/dist/friends/types.js +24 -0
  45. package/package.json +11 -4
  46. package/src/admin/adapters/d1.ts +5 -4
  47. package/src/admin/adapters/firestore.ts +6 -5
  48. package/src/admin/conformance.ts +15 -6
  49. package/src/admin/handler.ts +3 -3
  50. package/src/admin/index.ts +3 -3
  51. package/src/admin/memory.ts +2 -1
  52. package/src/admin/panel/gameCard.ts +52 -6
  53. package/src/admin/panel/mount.ts +1 -1
  54. package/src/admin/panel/overview.ts +4 -3
  55. package/src/admin/panel/style.css +10 -1
  56. package/src/admin/store.ts +2 -2
  57. package/src/admin/switches.ts +35 -9
  58. package/src/friends/adapters/d1.sql +33 -0
  59. package/src/friends/adapters/d1.ts +146 -0
  60. package/src/friends/adapters/firestore.ts +197 -0
  61. package/src/friends/conformance.ts +260 -0
  62. package/src/friends/handler.ts +144 -0
  63. package/src/friends/index.ts +17 -0
  64. package/src/friends/memory.ts +83 -0
  65. package/src/friends/panel/index.ts +13 -0
  66. package/src/friends/panel/mount.ts +346 -0
  67. package/src/friends/panel/style.css +303 -0
  68. package/src/friends/panel/window.ts +79 -0
  69. package/src/friends/rules.ts +196 -0
  70. package/src/friends/types.ts +57 -0
@@ -0,0 +1,180 @@
1
+ // The rules of the friends list, as pure functions: every adapter (memory, D1, Firestore) reads its rows, asks these what to write, and writes it
2
+ // in one step. So the rules live in one place, and the conformance suite (./conformance.ts) proves that an adapter keeps them.
3
+ //
4
+ // One pair of players has at most one edge. An edge is a request ("pending", from one of them), a friendship, or a block (by one or both).
5
+ // - A block hides the pair from the player who is blocked, both ways, and wins over a friendship or a request. The player who blocked sees a
6
+ // "blocked" row, so that they can unblock it.
7
+ // - A request that nobody answered goes after FRIEND_REQUEST_MS: it is not shown and does not count.
8
+ // - Every live edge counts toward FRIEND_LIMIT for both players.
9
+ import { FRIEND_INVITE_MS, FRIEND_LIMIT, FRIEND_LIVE_MS, FRIEND_REQUEST_MS, FRIEND_WHERE_MAX, FriendsError } from "./types.js";
10
+ /** The two ids of a pair in the order an adapter keeps them. */
11
+ export const pairOf = (x, y) => (x < y ? [x, y] : [y, x]);
12
+ export const otherOf = (rec, me) => (rec.a === me ? rec.b : rec.a);
13
+ export const hasPlayer = (rec, me) => rec.a === me || rec.b === me;
14
+ /** A request that nobody answered in time is gone; anything else is live. */
15
+ export const liveEdge = (rec, now) => rec.state !== "pending" || now - rec.at < FRIEND_REQUEST_MS;
16
+ /** A new opaque edge id: random, never made from a player id. */
17
+ export function newEdgeId() {
18
+ return globalThis.crypto.randomUUID();
19
+ }
20
+ /** An edge id as the browser may send one: letters, digits, "-" and "_", 8 to 64. Anything else is refused before a store is asked. */
21
+ export const EDGE_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
22
+ /** A tab's lease, as for an edge. */
23
+ export const LEASE_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
24
+ /**
25
+ * The game's `where` text as a store keeps it: control characters removed, spaces at the ends trimmed, cut to FRIEND_WHERE_MAX characters
26
+ * (whole characters, not halves of one). Null when nothing is left, or when it is not text.
27
+ */
28
+ export function cleanWhere(where) {
29
+ if (typeof where !== "string")
30
+ return null;
31
+ // eslint-disable-next-line no-control-regex
32
+ const plain = where.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "").trim();
33
+ const cut = Array.from(plain).slice(0, FRIEND_WHERE_MAX).join("").trim();
34
+ return cut ? cut : null;
35
+ }
36
+ /** The state of an edge for one of its players, or null when they must not see it (a block by the other, an old request). */
37
+ export function stateFor(rec, me, now) {
38
+ if (!hasPlayer(rec, me) || !liveEdge(rec, now))
39
+ return null;
40
+ if (rec.state === "blocked")
41
+ return rec.blockedBy.includes(me) ? "blocked" : null;
42
+ if (rec.state === "friend")
43
+ return "friend";
44
+ return rec.from === me ? "outgoing" : "incoming";
45
+ }
46
+ const NOT_HERE = "This player is not on your list any more.";
47
+ /** The edge that `me` named, or a refusal: an edge of other players, or one they must not see, reads as gone (it says nothing about why). */
48
+ function mine(rec, me, now) {
49
+ if (!rec || stateFor(rec, me, now) === null)
50
+ throw new FriendsError(404, NOT_HERE);
51
+ return rec;
52
+ }
53
+ /**
54
+ * A request from `me` to `other`. `rec` is the pair's edge (or null); `counts` are the live edges of each, which the adapter reads only when a new
55
+ * edge would be made (it passes a function).
56
+ */
57
+ export async function planRequest(me, other, rec, now, counts) {
58
+ if (me === other)
59
+ throw new FriendsError(400, "You cannot add yourself.");
60
+ const live = rec && liveEdge(rec, now) ? rec : null;
61
+ if (live?.state === "blocked")
62
+ throw new FriendsError(409, "This player is not available.");
63
+ if (live?.state === "friend")
64
+ return { none: true };
65
+ if (live?.state === "pending") {
66
+ if (live.from === me)
67
+ return { none: true };
68
+ // The other player already asked: this is an accept.
69
+ return { put: { ...live, from: me, state: "friend", at: now, invite: null, rev: live.rev + 1 } };
70
+ }
71
+ const c = await counts();
72
+ if (c.mine >= FRIEND_LIMIT)
73
+ throw new FriendsError(409, "Your friends list is full. Remove someone first.");
74
+ if (c.theirs >= FRIEND_LIMIT)
75
+ throw new FriendsError(409, "That player's friends list is full.");
76
+ const [a, b] = pairOf(me, other);
77
+ // An old request of the same pair is replaced (same row), so the pair still has one edge.
78
+ return { put: { edge: rec?.edge ?? newEdgeId(), a, b, from: me, state: "pending", blockedBy: [], at: now, invite: null, rev: (rec?.rev ?? 0) + 1 } };
79
+ }
80
+ export function planAccept(me, rec, now) {
81
+ const r = mine(rec, me, now);
82
+ const s = stateFor(r, me, now);
83
+ if (s === "friend")
84
+ return { none: true };
85
+ if (s !== "incoming")
86
+ throw new FriendsError(409, s === "outgoing" ? "Wait for the other player to accept." : "This request is no longer open.");
87
+ return { put: { ...r, from: me, state: "friend", at: now, invite: null, rev: r.rev + 1 } };
88
+ }
89
+ /** Declines a request to `me`, or takes back a request of `me`'s. */
90
+ export function planDecline(me, rec, now) {
91
+ const r = mine(rec, me, now);
92
+ const s = stateFor(r, me, now);
93
+ if (s !== "incoming" && s !== "outgoing")
94
+ throw new FriendsError(409, "This is not a request.");
95
+ return { drop: r };
96
+ }
97
+ /** Ends a friendship (or a request, either way). A block is ended by unblock, not by remove. */
98
+ export function planRemove(me, rec, now) {
99
+ const r = mine(rec, me, now);
100
+ if (stateFor(r, me, now) === "blocked")
101
+ throw new FriendsError(409, "Unblock this player first.");
102
+ return { drop: r };
103
+ }
104
+ export function planBlock(me, rec, now) {
105
+ const r = mine(rec, me, now);
106
+ if (r.blockedBy.includes(me))
107
+ return { none: true };
108
+ const blockedBy = r.state === "blocked" ? [...r.blockedBy, me] : [me];
109
+ return { put: { ...r, from: me, state: "blocked", blockedBy, at: now, invite: null, rev: r.rev + 1 } };
110
+ }
111
+ /** Takes `me`'s block away. With no block left the pair has no edge (they are not friends again: either may ask). */
112
+ export function planUnblock(me, rec, now) {
113
+ const r = mine(rec, me, now);
114
+ if (!r.blockedBy.includes(me))
115
+ throw new FriendsError(409, "This player is not blocked.");
116
+ const left = r.blockedBy.filter((p) => p !== me);
117
+ if (!left.length)
118
+ return { drop: r };
119
+ return { put: { ...r, blockedBy: left, at: now, rev: r.rev + 1 } };
120
+ }
121
+ export function planInvite(me, rec, where, now) {
122
+ const r = mine(rec, me, now);
123
+ if (stateFor(r, me, now) !== "friend")
124
+ throw new FriendsError(409, "You can invite only a friend.");
125
+ const place = cleanWhere(where);
126
+ if (!place)
127
+ throw new FriendsError(400, "Say where to meet.");
128
+ return { put: { ...r, invite: { from: me, where: place, at: now }, rev: r.rev + 1 } };
129
+ }
130
+ /** The lease and the place of a pulse, checked. `where` null means the tab is leaving (it clears the row only when the lease is the same). */
131
+ export function checkPulse(where, lease) {
132
+ if (typeof lease !== "string" || !LEASE_PATTERN.test(lease))
133
+ throw new FriendsError(400, "A pulse needs a lease of 8 to 64 letters or digits.");
134
+ if (where === null)
135
+ return { where: null, lease };
136
+ const place = cleanWhere(where);
137
+ if (!place)
138
+ throw new FriendsError(400, "Say where you are, or send null to leave.");
139
+ return { where: place, lease };
140
+ }
141
+ /** What a pulse does to the presence row `was`: the new row, a deletion, or nothing (an old tab that leaves after a newer one came). */
142
+ export function planPulse(player, where, lease, was, now) {
143
+ if (where !== null)
144
+ return { put: { player, where, lease, at: now } };
145
+ if (was && was.lease === lease)
146
+ return { drop: true };
147
+ return { none: true };
148
+ }
149
+ /** A presence row that still counts. */
150
+ export const presenceLive = (p, now) => Boolean(p) && now - p.at < FRIEND_LIVE_MS && now - p.at >= -FRIEND_LIVE_MS;
151
+ /** The order of the list: requests to answer first, then friends who are online, friends, requests sent, blocks; each by name. */
152
+ const RANK = { incoming: 0, friend: 1, outgoing: 3, blocked: 4 };
153
+ /**
154
+ * The browser's view of `me`'s edges. `presence` and `showOnline` are of the OTHER players (only friends' are read). `names` comes from the game's
155
+ * directory; a player it does not know reads "A player". No player id goes into a row.
156
+ */
157
+ export function buildRows(me, recs, now, names, presence, showOnline) {
158
+ const rows = [];
159
+ for (const rec of recs) {
160
+ const state = stateFor(rec, me, now);
161
+ if (!state)
162
+ continue;
163
+ const other = otherOf(rec, me);
164
+ const p = state === "friend" && showOnline.get(other) !== false ? presence.get(other) : undefined;
165
+ const online = presenceLive(p, now);
166
+ const invite = state === "friend" && rec.invite && rec.invite.from !== me && now - rec.invite.at < FRIEND_INVITE_MS && now - rec.invite.at >= -FRIEND_INVITE_MS ? { where: rec.invite.where, at: rec.invite.at } : null;
167
+ const name = names.get(other);
168
+ rows.push({ edge: rec.edge, name: typeof name === "string" && name ? name : "A player", state, online, where: online ? p.where : null, invite, rank: state === "friend" && online ? 0.5 : RANK[state] });
169
+ }
170
+ rows.sort((x, y) => x.rank - y.rank || x.name.localeCompare(y.name) || (x.edge < y.edge ? -1 : 1));
171
+ return rows.map(({ rank: _rank, ...row }) => row);
172
+ }
173
+ /** The other players whose names a view needs: every edge `me` can see. */
174
+ export function shownOthers(me, recs, now) {
175
+ return [...new Set(recs.filter((r) => stateFor(r, me, now) !== null).map((r) => otherOf(r, me)))];
176
+ }
177
+ /** The friends of `me` (their presence is read for the view). */
178
+ export function friendsOf(me, recs, now) {
179
+ return recs.filter((r) => stateFor(r, me, now) === "friend").map((r) => otherOf(r, me));
180
+ }
@@ -0,0 +1,58 @@
1
+ /** A row of a player's list, as the browser gets it. `edge` is the store's opaque id for the pair (never a player id). */
2
+ export type FriendState = "friend" | "incoming" | "outgoing" | "blocked";
3
+ export type FriendRow = {
4
+ edge: string;
5
+ name: string;
6
+ state: FriendState;
7
+ online: boolean;
8
+ where: string | null;
9
+ invite: {
10
+ where: string;
11
+ at: number;
12
+ } | null;
13
+ };
14
+ export type FriendView = {
15
+ showOnline: boolean;
16
+ people: FriendRow[];
17
+ };
18
+ /** Live rows a player may have (friends, requests both ways, blocks), as the island. */
19
+ export declare const FRIEND_LIMIT = 100;
20
+ /** A pulse counts as online this long. */
21
+ export declare const FRIEND_LIVE_MS = 120000;
22
+ /** An invite shows this long. */
23
+ export declare const FRIEND_INVITE_MS = 600000;
24
+ /** An unanswered request goes after this. */
25
+ export declare const FRIEND_REQUEST_MS: number;
26
+ /** The longest `where` a store keeps (after trimming and removing control characters). */
27
+ export declare const FRIEND_WHERE_MAX = 64;
28
+ /** The game's players, which only the game knows: its sign-in id for a name, and the names to show. */
29
+ export interface PlayerDirectory {
30
+ byName(name: string): Promise<string | null>;
31
+ names(ids: string[]): Promise<Map<string, string>>;
32
+ }
33
+ /** The game's database for friends. An adapter writes ONLY friend rows, presence rows and the show-online choice. */
34
+ export interface FriendsStore {
35
+ view(player: string, directory: PlayerDirectory, now: number): Promise<FriendView>;
36
+ /** A request by the other player's id; an accept when the other already asked. Refuses: self, a full list, a block. */
37
+ request(player: string, other: string, now: number): Promise<void>;
38
+ accept(player: string, edge: string, now: number): Promise<void>;
39
+ decline(player: string, edge: string): Promise<void>;
40
+ remove(player: string, edge: string): Promise<void>;
41
+ block(player: string, edge: string, now: number): Promise<void>;
42
+ unblock(player: string, edge: string, now: number): Promise<void>;
43
+ /** Only between friends; `where` is the game's own text (a room, a level), at most 64 characters. */
44
+ invite(player: string, edge: string, where: string, now: number): Promise<void>;
45
+ /** Presence: here now (`where` or null), kept FRIEND_LIVE_MS; a `lease` per tab, so an old tab cannot clear a new one. */
46
+ pulse(player: string, where: string | null, lease: string, now: number): Promise<void>;
47
+ setShowOnline(player: string, on: boolean): Promise<void>;
48
+ /** A player's "delete my data": every row of theirs; returns how many. */
49
+ forget(player: string): Promise<number>;
50
+ }
51
+ /**
52
+ * A refusal of a store or the handler: the status to answer and words a player can read. A store throws it for a rule (a block, a full list, an edge
53
+ * that is not theirs); anything else it throws is a fault, and the handler answers a plain 500.
54
+ */
55
+ export declare class FriendsError extends Error {
56
+ status: 400 | 401 | 403 | 404 | 409 | 413 | 429;
57
+ constructor(status: 400 | 401 | 403 | 404 | 409 | 413 | 429, message: string);
58
+ }
@@ -0,0 +1,24 @@
1
+ // The friends list of a game's OWN players: the types, the limits and the store contract. The package never sees a Nanoterra account here: a
2
+ // player is the game's own id, and a name comes from the game's own directory. (Nanoterra's friends are another thing: the nano133 window, ./panel.)
3
+ /** Live rows a player may have (friends, requests both ways, blocks), as the island. */
4
+ export const FRIEND_LIMIT = 100;
5
+ /** A pulse counts as online this long. */
6
+ export const FRIEND_LIVE_MS = 120_000;
7
+ /** An invite shows this long. */
8
+ export const FRIEND_INVITE_MS = 600_000;
9
+ /** An unanswered request goes after this. */
10
+ export const FRIEND_REQUEST_MS = 30 * 86_400_000;
11
+ /** The longest `where` a store keeps (after trimming and removing control characters). */
12
+ export const FRIEND_WHERE_MAX = 64;
13
+ /**
14
+ * A refusal of a store or the handler: the status to answer and words a player can read. A store throws it for a rule (a block, a full list, an edge
15
+ * that is not theirs); anything else it throws is a fault, and the handler answers a plain 500.
16
+ */
17
+ export class FriendsError extends Error {
18
+ status;
19
+ constructor(status, message) {
20
+ super(message);
21
+ this.status = status;
22
+ this.name = "FriendsError";
23
+ }
24
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.5.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 {
@@ -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
 
@@ -1679,7 +1679,16 @@
1679
1679
  font-size: 12px;
1680
1680
  }
1681
1681
 
1682
- /* ---- The "Game" card: two switches (gameCard.ts). Each is a button of at least 44 px ---- */
1682
+ /* ---- The "Game" card: two switches and the friends choice (gameCard.ts). Each is a button of at least 44 px ---- */
1683
+
1684
+ /* The friends choice: three keys in a row that wraps on a narrow phone, each 44 px tall. */
1685
+ .n133-admin .game-card .friends-row .switch-head {
1686
+ flex-wrap: wrap;
1687
+ }
1688
+
1689
+ .n133-admin .game-card .friends-choice .seg-btn {
1690
+ min-height: 44px;
1691
+ }
1683
1692
 
1684
1693
  .n133-admin .game-card .switch-row {
1685
1694
  display: grid;