@nano133/nanoterra 0.5.0 → 0.6.1

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 +25 -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,260 @@
1
+ // The conformance suite of the friends rules: a game that writes its own store (another database) runs it, and so proves that its store keeps
2
+ // every rule the package's adapters keep. The package runs it on the memory store, D1 (on SQLite) and Firestore.
3
+ //
4
+ // import { friendsStoreConformance } from "@nano133/nanoterra/friends/conformance";
5
+ // for (const c of friendsStoreConformance(async () => ({ store: myStore(), rows: () => countMyRows() }))) it(c.name, c.run);
6
+ //
7
+ // Each case gets a NEW, empty store from `make`. The suite names players with ids that it can find in a text (so it can prove that no view holds one)
8
+ // and gives the store its own directory.
9
+
10
+ import { FRIEND_INVITE_MS, FRIEND_LIMIT, FRIEND_LIVE_MS, FRIEND_REQUEST_MS, FriendsError, type FriendRow, type FriendView, type FriendsStore, type PlayerDirectory } from "./types.js";
11
+
12
+ export type FriendsHarness = {
13
+ store: FriendsStore;
14
+ /** Every row the store keeps (edges, presence, prefs) as one text. `forget` must leave none that holds the player's id. */
15
+ dump(): Promise<string>;
16
+ };
17
+
18
+ export type FriendsConformanceCase = { name: string; run: () => Promise<void> };
19
+
20
+ function check(ok: boolean, message: string): void {
21
+ if (!ok) throw new Error(message);
22
+ }
23
+
24
+ /** A player id the suite can find in any text. */
25
+ const id = (n: string) => `player-id-${n}-7f3e`;
26
+ const NAMES: Record<string, string> = {};
27
+ const directory: PlayerDirectory = {
28
+ async byName(name) {
29
+ return Object.entries(NAMES).find(([, v]) => v === name)?.[0] ?? null;
30
+ },
31
+ async names(ids) {
32
+ return new Map(ids.filter((i) => NAMES[i]).map((i) => [i, NAMES[i]] as const));
33
+ },
34
+ };
35
+ for (const n of ["ann", "bob", "cyd", "dee"]) NAMES[id(n)] = n[0].toUpperCase() + n.slice(1);
36
+
37
+ const ANN = id("ann");
38
+ const BOB = id("bob");
39
+ const CYD = id("cyd");
40
+
41
+ async function rejects(p: Promise<unknown>, status: number, what: string): Promise<void> {
42
+ try {
43
+ await p;
44
+ } catch (e) {
45
+ check(e instanceof FriendsError, `${what}: expected a FriendsError, got ${String(e)}`);
46
+ check((e as FriendsError).status === status, `${what}: expected ${status}, got ${(e as FriendsError).status} (${(e as Error).message})`);
47
+ return;
48
+ }
49
+ throw new Error(`${what}: expected a refusal`);
50
+ }
51
+
52
+ export function friendsStoreConformance(make: () => Promise<FriendsHarness>): FriendsConformanceCase[] {
53
+ const cases: FriendsConformanceCase[] = [];
54
+ const add = (name: string, run: (h: FriendsHarness, now: number) => Promise<void>) => cases.push({ name, run: async () => run(await make(), Date.now()) });
55
+ const view = (h: FriendsHarness, who: string, now: number): Promise<FriendView> => h.store.view(who, directory, now);
56
+ const row = async (h: FriendsHarness, who: string, name: string, now: number): Promise<FriendRow | undefined> => (await view(h, who, now)).people.find((r) => r.name === name);
57
+ const friends = async (h: FriendsHarness, x: string, y: string, now: number) => {
58
+ await h.store.request(x, y, now);
59
+ await h.store.request(y, x, now + 1);
60
+ };
61
+
62
+ add("a request shows as outgoing to the sender and incoming to the other, with the other's name and no id", async (h, now) => {
63
+ await h.store.request(ANN, BOB, now);
64
+ const a = await row(h, ANN, "Bob", now);
65
+ const b = await row(h, BOB, "Ann", now);
66
+ check(a?.state === "outgoing" && b?.state === "incoming", `states: ${JSON.stringify([a, b])}`);
67
+ check(a!.edge === b!.edge && a!.edge.length >= 8, "the two rows name the same edge");
68
+ check(!a!.online && a!.where === null && a!.invite === null, "a request is not online and has no invite");
69
+ });
70
+
71
+ add("a request when the other already asked is an accept: both are friends, on one edge", async (h, now) => {
72
+ await friends(h, ANN, BOB, now);
73
+ const a = (await view(h, ANN, now + 2)).people;
74
+ const b = (await view(h, BOB, now + 2)).people;
75
+ check(a.length === 1 && b.length === 1 && a[0].state === "friend" && b[0].state === "friend", `views: ${JSON.stringify([a, b])}`);
76
+ });
77
+
78
+ add("a request twice, or to a friend, changes nothing: one edge for the pair", async (h, now) => {
79
+ await h.store.request(ANN, BOB, now);
80
+ await h.store.request(ANN, BOB, now + 1);
81
+ check((await view(h, ANN, now + 2)).people.length === 1, "one row after two requests");
82
+ await h.store.request(BOB, ANN, now + 3);
83
+ await h.store.request(ANN, BOB, now + 4);
84
+ const v = await view(h, ANN, now + 5);
85
+ check(v.people.length === 1 && v.people[0].state === "friend", `still one friend row: ${JSON.stringify(v)}`);
86
+ });
87
+
88
+ add("a player cannot ask themself", async (h, now) => {
89
+ await rejects(h.store.request(ANN, ANN, now), 400, "a request to self");
90
+ });
91
+
92
+ add("accept: only the player who was asked can accept; then both are friends", async (h, now) => {
93
+ await h.store.request(ANN, BOB, now);
94
+ const edge = (await row(h, ANN, "Bob", now))!.edge;
95
+ await rejects(h.store.accept(ANN, edge, now + 1), 409, "the sender accepts their own request");
96
+ await h.store.accept(BOB, edge, now + 2);
97
+ check((await row(h, ANN, "Bob", now + 3))?.state === "friend", "Ann sees a friend");
98
+ });
99
+
100
+ add("decline takes the request away for both; the sender may also take it back", async (h, now) => {
101
+ await h.store.request(ANN, BOB, now);
102
+ let edge = (await row(h, BOB, "Ann", now))!.edge;
103
+ await h.store.decline(BOB, edge);
104
+ check((await view(h, ANN, now + 1)).people.length === 0 && (await view(h, BOB, now + 1)).people.length === 0, "no rows after a decline");
105
+ await h.store.request(ANN, BOB, now + 2);
106
+ edge = (await row(h, ANN, "Bob", now + 2))!.edge;
107
+ await h.store.decline(ANN, edge);
108
+ check((await view(h, BOB, now + 3)).people.length === 0, "no rows after the sender takes it back");
109
+ });
110
+
111
+ add("remove ends a friendship for both", async (h, now) => {
112
+ await friends(h, ANN, BOB, now);
113
+ await h.store.remove(BOB, (await row(h, BOB, "Ann", now + 2))!.edge);
114
+ check((await view(h, ANN, now + 3)).people.length === 0 && (await view(h, BOB, now + 3)).people.length === 0, "no rows after remove");
115
+ });
116
+
117
+ add("a block wins: the blocker sees a blocked row, the other sees nothing, and neither can ask again", async (h, now) => {
118
+ await friends(h, ANN, BOB, now);
119
+ await h.store.block(ANN, (await row(h, ANN, "Bob", now + 2))!.edge, now + 3);
120
+ check((await row(h, ANN, "Bob", now + 4))?.state === "blocked", "Ann sees Bob as blocked");
121
+ check((await view(h, BOB, now + 4)).people.length === 0, "Bob does not see Ann at all");
122
+ await rejects(h.store.request(BOB, ANN, now + 5), 409, "a request by the blocked player");
123
+ await rejects(h.store.request(ANN, BOB, now + 5), 409, "a request by the blocker");
124
+ });
125
+
126
+ add("the blocked player cannot use the old edge for anything", async (h, now) => {
127
+ await friends(h, ANN, BOB, now);
128
+ const edge = (await row(h, BOB, "Ann", now + 2))!.edge;
129
+ await h.store.block(ANN, edge, now + 3);
130
+ await rejects(h.store.accept(BOB, edge, now + 4), 404, "accept on a blocked edge");
131
+ await rejects(h.store.invite(BOB, edge, "Room 1", now + 4), 404, "invite on a blocked edge");
132
+ await rejects(h.store.remove(BOB, edge), 404, "remove on a blocked edge");
133
+ await rejects(h.store.unblock(BOB, edge, now + 4), 404, "unblock by the one who did not block");
134
+ check((await row(h, ANN, "Bob", now + 5))?.state === "blocked", "the block stands");
135
+ });
136
+
137
+ add("unblock leaves no edge (not friends again); only the player who blocked can unblock", async (h, now) => {
138
+ await friends(h, ANN, BOB, now);
139
+ const edge = (await row(h, ANN, "Bob", now + 2))!.edge;
140
+ await h.store.block(ANN, edge, now + 3);
141
+ await h.store.unblock(ANN, edge, now + 4);
142
+ check((await view(h, ANN, now + 5)).people.length === 0 && (await view(h, BOB, now + 5)).people.length === 0, "no edge after unblock");
143
+ // Bob blocks this time: Ann sees nothing, so she cannot unblock his block.
144
+ await friends(h, ANN, BOB, now + 11);
145
+ const e3 = (await row(h, BOB, "Ann", now + 13))!.edge;
146
+ await h.store.block(BOB, e3, now + 14);
147
+ check((await view(h, ANN, now + 15)).people.length === 0, "Ann does not see Bob's block");
148
+ await rejects(h.store.unblock(ANN, e3, now + 16), 404, "Ann cannot unblock Bob's block");
149
+ await h.store.unblock(BOB, e3, now + 17);
150
+ check((await view(h, ANN, now + 18)).people.length === 0 && (await view(h, BOB, now + 18)).people.length === 0, "nothing left after Bob unblocks");
151
+ });
152
+
153
+ add(`a full list (${FRIEND_LIMIT} live rows) refuses a new request, both ways`, async (h, now) => {
154
+ for (let i = 0; i < FRIEND_LIMIT; i++) {
155
+ const other = id(`fan${i}`);
156
+ NAMES[other] = `Fan${i}`;
157
+ await h.store.request(other, ANN, now + i);
158
+ }
159
+ await rejects(h.store.request(ANN, BOB, now + FRIEND_LIMIT), 409, "Ann asks with a full list");
160
+ await rejects(h.store.request(CYD, ANN, now + FRIEND_LIMIT), 409, "Cyd asks Ann, whose list is full");
161
+ // An answer to one of the requests is not a new row: it is allowed.
162
+ const edge = (await row(h, ANN, "Fan0", now + FRIEND_LIMIT))!.edge;
163
+ await h.store.accept(ANN, edge, now + FRIEND_LIMIT + 1);
164
+ });
165
+
166
+ add("a request nobody answers goes after its time: not shown, not counted, and it may be sent again", async (h, now) => {
167
+ await h.store.request(ANN, BOB, now);
168
+ const later = now + FRIEND_REQUEST_MS + 1;
169
+ check((await view(h, ANN, later)).people.length === 0 && (await view(h, BOB, later)).people.length === 0, "the old request is gone");
170
+ await h.store.request(BOB, ANN, later);
171
+ const v = await view(h, ANN, later + 1);
172
+ check(v.people.length === 1 && v.people[0].state === "incoming", `a new request after the old one went: ${JSON.stringify(v)}`);
173
+ });
174
+
175
+ add("an invite works only between friends; the other sees it for its time, the sender does not", async (h, now) => {
176
+ await h.store.request(ANN, BOB, now);
177
+ const edge = (await row(h, ANN, "Bob", now))!.edge;
178
+ await rejects(h.store.invite(ANN, edge, "Room 1", now + 1), 409, "an invite to a request");
179
+ await h.store.accept(BOB, edge, now + 2);
180
+ await h.store.invite(ANN, edge, "Room 1", now + 3);
181
+ const b = await row(h, BOB, "Ann", now + 4);
182
+ check(b?.invite?.where === "Room 1" && b.invite.at === now + 3, `Bob sees the invite: ${JSON.stringify(b)}`);
183
+ check((await row(h, ANN, "Bob", now + 4))?.invite === null, "Ann does not see her own invite");
184
+ check((await row(h, BOB, "Ann", now + 3 + FRIEND_INVITE_MS))?.invite === null, "the invite is gone after its time");
185
+ });
186
+
187
+ add("an invite's place is plain text: control characters go and it is cut to 64 characters; an empty one is refused", async (h, now) => {
188
+ await friends(h, ANN, BOB, now);
189
+ const edge = (await row(h, ANN, "Bob", now + 2))!.edge;
190
+ await h.store.invite(ANN, edge, ` Lev\u0000el\n 3 ${"x".repeat(100)}`, now + 3);
191
+ const w = (await row(h, BOB, "Ann", now + 4))!.invite!.where;
192
+ check(!/[\u0000-\u001f]/.test(w) && Array.from(w).length <= 64 && w.startsWith("Level 3"), `the place: ${JSON.stringify(w)}`);
193
+ await rejects(h.store.invite(ANN, edge, " \n\t ", now + 5), 400, "an empty place");
194
+ });
195
+
196
+ add("online only within its time, only to friends, and only while the friend shows online", async (h, now) => {
197
+ await h.store.request(ANN, CYD, now);
198
+ await friends(h, ANN, BOB, now);
199
+ await h.store.pulse(BOB, "Room 2", "lease-bob-1", now + 2);
200
+ await h.store.pulse(CYD, "Room 3", "lease-cyd-1", now + 2);
201
+ const b = await row(h, ANN, "Bob", now + 3);
202
+ check(b?.online === true && b.where === "Room 2", `Bob is online: ${JSON.stringify(b)}`);
203
+ const c = await row(h, ANN, "Cyd", now + 3);
204
+ check(c?.online === false && c.where === null, "a player who is only asked is not shown online");
205
+ check((await row(h, ANN, "Bob", now + 2 + FRIEND_LIVE_MS))?.online === false, "a pulse is old after its time");
206
+ await h.store.pulse(BOB, "Room 2", "lease-bob-1", now + 4);
207
+ await h.store.setShowOnline(BOB, false);
208
+ const hidden = await row(h, ANN, "Bob", now + 5);
209
+ check(hidden?.online === false && hidden.where === null, "a friend who hides is not online");
210
+ check((await view(h, BOB, now + 5)).showOnline === false && (await view(h, ANN, now + 5)).showOnline === true, "each player's own choice");
211
+ await h.store.setShowOnline(BOB, true);
212
+ check((await row(h, ANN, "Bob", now + 6))?.online === false, "hiding cleared the old pulse");
213
+ });
214
+
215
+ add("presence: a tab that leaves clears only its own pulse, so an old tab cannot clear a new one", async (h, now) => {
216
+ await friends(h, ANN, BOB, now);
217
+ await h.store.pulse(BOB, "Room 1", "lease-old-tab", now + 2);
218
+ await h.store.pulse(BOB, "Room 2", "lease-new-tab", now + 3);
219
+ await h.store.pulse(BOB, null, "lease-old-tab", now + 4);
220
+ check((await row(h, ANN, "Bob", now + 5))?.where === "Room 2", "the new tab's place stands");
221
+ await h.store.pulse(BOB, null, "lease-new-tab", now + 6);
222
+ check((await row(h, ANN, "Bob", now + 7))?.online === false, "the new tab left");
223
+ await rejects(h.store.pulse(BOB, "Room 1", "short", now + 8), 400, "a lease that is too short");
224
+ });
225
+
226
+ add("a player cannot use an edge of two other players", async (h, now) => {
227
+ await friends(h, ANN, BOB, now);
228
+ const edge = (await row(h, ANN, "Bob", now + 2))!.edge;
229
+ await rejects(h.store.remove(CYD, edge), 404, "remove");
230
+ await rejects(h.store.block(CYD, edge, now + 3), 404, "block");
231
+ await rejects(h.store.invite(CYD, edge, "Room", now + 3), 404, "invite");
232
+ await rejects(h.store.accept(CYD, "no-such-edge-1", now + 3), 404, "an edge that does not exist");
233
+ });
234
+
235
+ add("no view holds a player id", async (h, now) => {
236
+ await friends(h, ANN, BOB, now);
237
+ await h.store.request(CYD, ANN, now + 2);
238
+ await h.store.pulse(BOB, "Room 9", "lease-bob-9", now + 3);
239
+ await h.store.invite(BOB, (await row(h, BOB, "Ann", now + 3))!.edge, "Room 9", now + 4);
240
+ for (const who of [ANN, BOB, CYD]) {
241
+ const text = JSON.stringify(await view(h, who, now + 5));
242
+ check(!/player-id-/.test(text), `a view holds an id: ${text}`);
243
+ }
244
+ });
245
+
246
+ add("forget leaves nothing of the player: no edge, no presence, no choice; the others' lists lose them", async (h, now) => {
247
+ await friends(h, ANN, BOB, now);
248
+ await h.store.request(CYD, ANN, now + 2);
249
+ await h.store.pulse(ANN, "Room", "lease-ann-1", now + 3);
250
+ await h.store.setShowOnline(ANN, false);
251
+ await h.store.pulse(BOB, "Room", "lease-bob-1", now + 3);
252
+ const n = await h.store.forget(ANN);
253
+ check(n >= 3, `forget said ${n}`);
254
+ check(!(await h.dump()).includes(ANN), "a row still holds the player's id");
255
+ check((await view(h, BOB, now + 4)).people.length === 0 && (await view(h, CYD, now + 4)).people.length === 0, "the others no longer see the player");
256
+ check((await h.dump()).includes(BOB), "the other players' own rows stay");
257
+ });
258
+
259
+ return cases;
260
+ }
@@ -0,0 +1,144 @@
1
+ // The routes of the game's own friends list, on the Web `Request` and `Response` types (a Worker, Firebase Functions, Node).
2
+ //
3
+ // GET {base} the player's view: { showOnline, people }
4
+ // POST {base} one operation, { op, ... }:
5
+ // request { name } (the name through the game's directory)
6
+ // accept | decline | remove | block | unblock { edge }
7
+ // invite { edge, where }
8
+ // pulse { where (text, or null to leave), lease }
9
+ // visibility { showOnline: boolean }
10
+ //
11
+ // The player is the game's own signed-in player (`player(request)`); a visitor who is not signed in gets 401. A POST must come from the game's own
12
+ // page, and a read must not come from another page. Each player has 30 calls a minute. No answer ever holds a player id: a row names a player by
13
+ // the directory's name and an opaque edge.
14
+
15
+ import { fail, foreignOrigin, json, memoryLimiter, readLimited, sameOrigin } from "../admin/http.js";
16
+ import { EDGE_PATTERN } from "./rules.js";
17
+ import { FriendsError, type FriendsStore, type PlayerDirectory } from "./types.js";
18
+
19
+ export type FriendsOp = "request" | "accept" | "decline" | "remove" | "block" | "unblock" | "invite" | "pulse" | "visibility";
20
+
21
+ export type FriendsHandlerOptions = {
22
+ store: FriendsStore;
23
+ directory: PlayerDirectory;
24
+ /** The game's signed-in player of this request (its own id), or null. */
25
+ player(request: Request): Promise<string | null>;
26
+ /** Where the routes are. Default "/friends". */
27
+ base?: string;
28
+ /** A limiter: false means "too many". Default: a fixed window in memory, 30 calls a minute for each player. */
29
+ limit?: (key: string, limit: number, windowMs: number, now: number) => boolean | Promise<boolean>;
30
+ /** The time now in ms. Default Date.now. */
31
+ now?: () => number;
32
+ /** Told about a fault in the store or the directory. The player gets a plain 500 and never sees it. */
33
+ onError?: (e: unknown) => void;
34
+ };
35
+
36
+ export const FRIENDS_LIMITS = {
37
+ /** Calls of one player (reads and writes together). */
38
+ calls: { limit: 30, windowMs: 60_000 },
39
+ /** The biggest body of a POST. */
40
+ bodyMax: 1024,
41
+ /** The longest name a request may send: room for a whole Nano address (65 characters with "nano_"), which a directory may accept as a name. */
42
+ nameMax: 80,
43
+ } as const;
44
+
45
+ const sharedLimit = memoryLimiter();
46
+ const OPS = new Set<FriendsOp>(["request", "accept", "decline", "remove", "block", "unblock", "invite", "pulse", "visibility"]);
47
+
48
+ export function createFriendsHandler(options: FriendsHandlerOptions): { base: string; handle(request: Request): Promise<Response | null> } {
49
+ const base = options.base ?? "/friends";
50
+ if (!base.startsWith("/") || base.length < 2 || base.endsWith("/")) throw new Error('base must start with "/" and not end with "/", like "/friends"');
51
+ const now = options.now ?? (() => Date.now());
52
+ const limit = options.limit ?? sharedLimit;
53
+ const report = (e: unknown) => {
54
+ try {
55
+ options.onError?.(e);
56
+ } catch {
57
+ // nothing more can be done
58
+ }
59
+ };
60
+
61
+ async function run(req: Request, player: string, at: number): Promise<Response> {
62
+ if (req.method === "GET") return json(await options.store.view(player, options.directory, at));
63
+ const text = await readLimited(req, FRIENDS_LIMITS.bodyMax);
64
+ if (text === null) return fail(413, "That is too much to send.");
65
+ let body: Record<string, unknown>;
66
+ try {
67
+ const v: unknown = JSON.parse(text);
68
+ if (!v || typeof v !== "object" || Array.isArray(v)) return fail(400, "Send an object with an op.");
69
+ body = v as Record<string, unknown>;
70
+ } catch {
71
+ return fail(400, "Send JSON.");
72
+ }
73
+ const op = body.op as FriendsOp;
74
+ if (typeof op !== "string" || !OPS.has(op)) return fail(400, "Unknown op.");
75
+ const store = options.store;
76
+ const edge = (): string => {
77
+ if (typeof body.edge !== "string" || !EDGE_PATTERN.test(body.edge)) throw new FriendsError(400, "Choose a player on your list.");
78
+ return body.edge;
79
+ };
80
+ switch (op) {
81
+ case "request": {
82
+ const name = typeof body.name === "string" ? body.name.trim() : "";
83
+ if (!name || name.length > FRIENDS_LIMITS.nameMax) return fail(400, "Enter a player's name.");
84
+ const other = await options.directory.byName(name);
85
+ if (!other) return fail(404, "No player has that name.");
86
+ await store.request(player, other, at);
87
+ break;
88
+ }
89
+ case "accept":
90
+ await store.accept(player, edge(), at);
91
+ break;
92
+ case "decline":
93
+ await store.decline(player, edge());
94
+ break;
95
+ case "remove":
96
+ await store.remove(player, edge());
97
+ break;
98
+ case "block":
99
+ await store.block(player, edge(), at);
100
+ break;
101
+ case "unblock":
102
+ await store.unblock(player, edge(), at);
103
+ break;
104
+ case "invite":
105
+ await store.invite(player, edge(), typeof body.where === "string" ? body.where : "", at);
106
+ break;
107
+ case "pulse":
108
+ await store.pulse(player, body.where === null ? null : typeof body.where === "string" ? body.where : "", typeof body.lease === "string" ? body.lease : "", at);
109
+ // A pulse needs no list back: the panel reads the view on its own clock.
110
+ return json({ ok: true });
111
+ case "visibility":
112
+ if (typeof body.showOnline !== "boolean") return fail(400, "Send showOnline as true or false.");
113
+ await store.setShowOnline(player, body.showOnline);
114
+ break;
115
+ }
116
+ return json(await store.view(player, options.directory, now()));
117
+ }
118
+
119
+ async function handle(req: Request): Promise<Response | null> {
120
+ const url = new URL(req.url);
121
+ if (url.pathname !== base) return null;
122
+ try {
123
+ if (req.method !== "GET" && req.method !== "POST") return fail(405, "Not allowed.", {}, { Allow: "GET, POST" });
124
+ if (req.method === "POST" ? !sameOrigin(req) : foreignOrigin(req)) return fail(403, "This call must come from the game's own page.");
125
+ let player: string | null;
126
+ try {
127
+ player = await options.player(req);
128
+ } catch (e) {
129
+ report(e);
130
+ return fail(503, "The game's server could not check your sign-in. Try again.");
131
+ }
132
+ if (typeof player !== "string" || !player) return fail(401, "Sign in to see your friends.");
133
+ const at = now();
134
+ if (!(await limit(`${base}|friends|${player}`, FRIENDS_LIMITS.calls.limit, FRIENDS_LIMITS.calls.windowMs, at))) return fail(429, "Too many calls. Wait a minute.");
135
+ return await run(req, player, at);
136
+ } catch (e) {
137
+ if (e instanceof FriendsError) return fail(e.status, e.message);
138
+ report(e);
139
+ return fail(500, "The game's server could not do that. Try again.");
140
+ }
141
+ }
142
+
143
+ return { base, handle };
144
+ }
@@ -0,0 +1,17 @@
1
+ // "@nano133/nanoterra/friends": the friends list of a game's OWN players (the setting "The game's own" of the admin's Game card). No database, no
2
+ // cookie, no Nanoterra account: the game gives its store, its directory of players and its signed-in player.
3
+ //
4
+ // createFriendsHandler the routes (GET the view, POST an op), on Request and Response
5
+ // createMemoryFriends a store in memory, for tests
6
+ // (the adapters are "/friends/d1" and "/friends/firestore", the conformance suite "/friends/conformance", the browser panel "/friends/panel")
7
+ //
8
+ // Nanoterra's own friends list (the setting "Nanoterra") is not here: the panel holds the nano133 window for it, and the friends stay on nano133.
9
+
10
+ export { FRIEND_INVITE_MS, FRIEND_LIMIT, FRIEND_LIVE_MS, FRIEND_REQUEST_MS, FRIEND_WHERE_MAX, FriendsError } from "./types.js";
11
+ export type { FriendRow, FriendState, FriendView, FriendsStore, PlayerDirectory } from "./types.js";
12
+ export { FRIENDS_LIMITS, createFriendsHandler } from "./handler.js";
13
+ export type { FriendsHandlerOptions, FriendsOp } from "./handler.js";
14
+ export { createMemoryFriends } from "./memory.js";
15
+ export type { MemoryFriends } from "./memory.js";
16
+ export { EDGE_PATTERN, LEASE_PATTERN, cleanWhere } from "./rules.js";
17
+ export type { EdgeRec, PresenceRec } from "./rules.js";
@@ -0,0 +1,83 @@
1
+ // A friends store in memory, for tests (and for a game's first try). Nothing is kept after the process ends. It uses the same rules as the real
2
+ // adapters (./rules.ts), so it is the reference for what a store does.
3
+
4
+ import { buildRows, checkPulse, friendsOf, hasPlayer, liveEdge, pairOf, planAccept, planBlock, planDecline, planInvite, planPulse, planRemove, planRequest, planUnblock, shownOthers, type EdgeRec, type Plan, type PresenceRec } from "./rules.js";
5
+ import type { FriendView, FriendsStore, PlayerDirectory } from "./types.js";
6
+
7
+ export type MemoryFriends = FriendsStore & {
8
+ /** The rows as an adapter would keep them. */
9
+ readonly edges: Map<string, EdgeRec>;
10
+ readonly presence: Map<string, PresenceRec>;
11
+ readonly prefs: Map<string, boolean>;
12
+ };
13
+
14
+ export function createMemoryFriends(): MemoryFriends {
15
+ const edges = new Map<string, EdgeRec>();
16
+ const presence = new Map<string, PresenceRec>();
17
+ const prefs = new Map<string, boolean>();
18
+ const of = (player: string) => [...edges.values()].filter((r) => hasPlayer(r, player));
19
+ const byPair = (x: string, y: string) => {
20
+ const [a, b] = pairOf(x, y);
21
+ return [...edges.values()].find((r) => r.a === a && r.b === b) ?? null;
22
+ };
23
+ const live = (player: string, now: number) => of(player).filter((r) => liveEdge(r, now)).length;
24
+ const apply = (plan: Plan) => {
25
+ if ("put" in plan) edges.set(plan.put.edge, plan.put);
26
+ else if ("drop" in plan) edges.delete(plan.drop.edge);
27
+ };
28
+
29
+ return {
30
+ edges,
31
+ presence,
32
+ prefs,
33
+ async view(player: string, directory: PlayerDirectory, now: number): Promise<FriendView> {
34
+ const recs = of(player);
35
+ const names = await directory.names(shownOthers(player, recs, now));
36
+ const friends = friendsOf(player, recs, now);
37
+ const pres = new Map(friends.flatMap((f) => (presence.has(f) ? [[f, presence.get(f)!] as const] : [])));
38
+ const shows = new Map(friends.map((f) => [f, prefs.get(f) !== false] as const));
39
+ return { showOnline: prefs.get(player) !== false, people: buildRows(player, recs, now, names, pres, shows) };
40
+ },
41
+ async request(player, other, now) {
42
+ apply(await planRequest(player, other, byPair(player, other), now, async () => ({ mine: live(player, now), theirs: live(other, now) })));
43
+ },
44
+ async accept(player, edge, now) {
45
+ apply(planAccept(player, edges.get(edge) ?? null, now));
46
+ },
47
+ async decline(player, edge) {
48
+ apply(planDecline(player, edges.get(edge) ?? null, Date.now()));
49
+ },
50
+ async remove(player, edge) {
51
+ apply(planRemove(player, edges.get(edge) ?? null, Date.now()));
52
+ },
53
+ async block(player, edge, now) {
54
+ apply(planBlock(player, edges.get(edge) ?? null, now));
55
+ },
56
+ async unblock(player, edge, now) {
57
+ apply(planUnblock(player, edges.get(edge) ?? null, now));
58
+ },
59
+ async invite(player, edge, where, now) {
60
+ apply(planInvite(player, edges.get(edge) ?? null, where, now));
61
+ },
62
+ async pulse(player, where, lease, now) {
63
+ const p = checkPulse(where, lease);
64
+ const plan = planPulse(player, p.where, p.lease, presence.get(player) ?? null, now);
65
+ if ("put" in plan) presence.set(player, plan.put);
66
+ else if ("drop" in plan) presence.delete(player);
67
+ },
68
+ async setShowOnline(player, on) {
69
+ prefs.set(player, Boolean(on));
70
+ if (!on) presence.delete(player);
71
+ },
72
+ async forget(player) {
73
+ let n = 0;
74
+ for (const r of of(player)) {
75
+ edges.delete(r.edge);
76
+ n++;
77
+ }
78
+ if (presence.delete(player)) n++;
79
+ if (prefs.delete(player)) n++;
80
+ return n;
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,13 @@
1
+ // "@nano133/nanoterra/friends/panel": the Friends key and panel of a game, drawn in the browser. Plain DOM, no framework, no Three.js.
2
+ //
3
+ // import { mountFriends } from "@nano133/nanoterra/friends/panel";
4
+ // import "@nano133/nanoterra/friends/style.css";
5
+ // const friends = mountFriends(document.getElementById("friends")!, { mode: settings.friends, game: "my-game" });
6
+ // friends.setWhere("Level 2");
7
+ //
8
+ // The server part is "@nano133/nanoterra/friends" (the handler and the stores): it must not load this one.
9
+
10
+ export { FRIENDS_TIMING, mountFriends } from "./mount.js";
11
+ export type { FriendsMode, FriendsMountOptions, MountedFriends } from "./mount.js";
12
+ export { WINDOW_SOURCE, WINDOW_V, readWindowMessage, toWindow, whereText, windowUrl } from "./window.js";
13
+ export type { FromWindow, ToWindow } from "./window.js";