@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,321 @@
1
+ // The Friends key and panel of a game: `mountFriends(element, options)`. Plain DOM, no framework, no Three.js. The mode is the admin's friends
2
+ // setting (GET {admin}/game gives it as `friends`; a game with no admin passes its own word):
3
+ //
4
+ // "nanoterra" the key opens the nano133 window (./window.ts, contract v1). The window is made at once and kept while the panel is closed: it holds
5
+ // the player's live connection, which tells their friends "Playing <game>" and keeps the badge true. Closed, it is hidden (not shown,
6
+ // off the screen, out of the tab order), never removed or `display: none`. `destroy()` removes it. The badge shows the requests
7
+ // (the thing to do) when there are any, else the friends online, in another colour.
8
+ // "own" the key opens the game's own list, from the handler of "@nano133/nanoterra/friends" at `base`: the view every 20 s while open (every
9
+ // 60 s while closed, for the badge), a pulse every 45 s with this tab's lease, and a "leave" pulse when the page goes away.
10
+ // "off" nothing is drawn.
11
+ //
12
+ // The panel is phone first: a sheet from the bottom on a phone held upright, from the side on a wide or sideways screen; every key is 44 px.
13
+ import { ApiError, createApi } from "../../admin/panel/api.js";
14
+ import { h } from "../../admin/panel/dom.js";
15
+ import { icon } from "../../admin/panel/icons.js";
16
+ import { readWindowMessage, toWindow, whereText, windowUrl } from "./window.js";
17
+ export const FRIENDS_TIMING = { viewOpenMs: 20_000, viewClosedMs: 60_000, pulseMs: 45_000 };
18
+ const NOTHING = { setWhere() { }, open() { }, close() { }, destroy() { } };
19
+ let mounts = 0;
20
+ export function mountFriends(root, options) {
21
+ if (options.mode !== "nanoterra" && options.mode !== "own")
22
+ return NOTHING;
23
+ const n = ++mounts;
24
+ const origin = (options.origin ?? "https://nano133.com").replace(/\/+$/, "");
25
+ const ask = options.confirm ?? ((q) => window.confirm(q));
26
+ let where = options.where === undefined ? null : whereText(options.where);
27
+ let isOpen = false;
28
+ let destroyed = false;
29
+ const cleanup = [];
30
+ const badge = h("span", { class: "n133f-badge", "aria-hidden": "true" });
31
+ badge.hidden = true;
32
+ const keyWords = h("span", { class: "n133f-key-word" }, "Friends");
33
+ const key = h("button", { class: "n133f-key", type: "button", "aria-expanded": "false", "aria-controls": `n133f-panel-${n}` }, icon("players", 20), keyWords, badge);
34
+ const title = h("h2", { class: "n133f-title", id: `n133f-title-${n}` }, "Friends");
35
+ const closeKey = h("button", { class: "n133f-close", type: "button", "aria-label": "Close friends" }, icon("close", 20));
36
+ const body = h("div", { class: "n133f-body" });
37
+ const panel = h("section", { class: `n133f-panel n133f-${options.mode}`, id: `n133f-panel-${n}`, role: "dialog", "aria-labelledby": `n133f-title-${n}`, "data-open": "false" }, h("header", { class: "n133f-head" }, title, closeKey), body);
38
+ const wrap = h("div", { class: "n133-friends" }, key, panel);
39
+ root.append(wrap);
40
+ /** The requests (the thing to do) when there are any, else the friends online (another colour). The key's name says both. */
41
+ const setBadge = ({ requests, online }) => {
42
+ const shown = requests > 0 ? requests : online;
43
+ badge.hidden = shown <= 0;
44
+ badge.classList.toggle("is-online", requests <= 0 && online > 0);
45
+ badge.textContent = shown > 9 ? "9+" : String(shown);
46
+ const parts = [requests > 0 ? `${requests} ${requests === 1 ? "request" : "requests"}` : "", online > 0 ? `${online} online` : ""].filter(Boolean);
47
+ key.setAttribute("aria-label", parts.length ? `Friends, ${parts.join(", ")}` : "Friends");
48
+ };
49
+ let onOpen = () => { };
50
+ let onClose = () => { };
51
+ const open = () => {
52
+ if (destroyed || isOpen)
53
+ return;
54
+ isOpen = true;
55
+ panel.dataset.open = "true";
56
+ key.setAttribute("aria-expanded", "true");
57
+ onOpen();
58
+ closeKey.focus();
59
+ };
60
+ const close = () => {
61
+ if (destroyed || !isOpen)
62
+ return;
63
+ isOpen = false;
64
+ panel.dataset.open = "false";
65
+ key.setAttribute("aria-expanded", "false");
66
+ onClose();
67
+ key.focus();
68
+ };
69
+ key.addEventListener("click", () => (isOpen ? close() : open()));
70
+ closeKey.addEventListener("click", close);
71
+ const onKey = (e) => {
72
+ if (e.key === "Escape" && isOpen)
73
+ close();
74
+ };
75
+ document.addEventListener("keydown", onKey);
76
+ cleanup.push(() => document.removeEventListener("keydown", onKey));
77
+ let setWhereHere = (_w) => { };
78
+ if (options.mode === "nanoterra") {
79
+ // The window, made at once (no lazy loading) and kept while the panel is closed.
80
+ const frame = h("iframe", {
81
+ class: "n133f-window",
82
+ src: windowUrl(origin, options.game),
83
+ title: "Friends",
84
+ referrerpolicy: "origin",
85
+ allow: "storage-access",
86
+ sandbox: "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation",
87
+ });
88
+ body.append(frame);
89
+ const hideFrame = (hidden) => {
90
+ if (hidden) {
91
+ frame.setAttribute("aria-hidden", "true");
92
+ frame.tabIndex = -1;
93
+ }
94
+ else {
95
+ frame.removeAttribute("aria-hidden");
96
+ frame.removeAttribute("tabindex");
97
+ }
98
+ };
99
+ hideFrame(true);
100
+ let ready = false;
101
+ const send = (msg) => {
102
+ if (ready)
103
+ frame.contentWindow?.postMessage(msg, origin);
104
+ };
105
+ onOpen = () => {
106
+ hideFrame(false);
107
+ send(toWindow({ type: "open" }));
108
+ };
109
+ onClose = () => {
110
+ hideFrame(true);
111
+ send(toWindow({ type: "closed" }));
112
+ };
113
+ setWhereHere = (w) => send(toWindow({ type: "where", where: w }));
114
+ const onMessage = (e) => {
115
+ // Only the window itself, from nano133's origin.
116
+ if (e.origin !== origin || e.source !== frame.contentWindow)
117
+ return;
118
+ const m = readWindowMessage(e.data);
119
+ if (!m)
120
+ return;
121
+ if (m.type === "ready") {
122
+ // Right after ready: where the player is, then whether the panel is open.
123
+ ready = true;
124
+ send(toWindow({ type: "where", where }));
125
+ send(toWindow({ type: isOpen ? "open" : "closed" }));
126
+ }
127
+ else if (m.type === "size") {
128
+ // The height its content needs, at most the panel's own room.
129
+ const room = body.clientHeight;
130
+ frame.style.height = `${room > 0 ? Math.min(m.height, room) : m.height}px`;
131
+ }
132
+ else if (m.type === "badge")
133
+ setBadge(m);
134
+ else if (m.type === "join") {
135
+ if (!ask("Leave this game to join your friend?"))
136
+ return;
137
+ if (options.onJoin)
138
+ options.onJoin(m.url);
139
+ else
140
+ window.location.assign(m.url);
141
+ }
142
+ // "signin": the window needs a tap (Storage Access) inside itself. The panel stays as it is; the kit does nothing else.
143
+ };
144
+ window.addEventListener("message", onMessage);
145
+ cleanup.push(() => window.removeEventListener("message", onMessage));
146
+ }
147
+ else {
148
+ const own = ownList(body, options, () => where, setBadge, ask);
149
+ onOpen = own.opened;
150
+ onClose = own.closed;
151
+ setWhereHere = own.moved;
152
+ cleanup.push(own.stop);
153
+ }
154
+ return {
155
+ setWhere(w) {
156
+ where = whereText(w);
157
+ setWhereHere(where);
158
+ },
159
+ open,
160
+ close,
161
+ destroy() {
162
+ if (destroyed)
163
+ return;
164
+ destroyed = true;
165
+ for (const f of cleanup)
166
+ f();
167
+ wrap.remove();
168
+ },
169
+ };
170
+ }
171
+ /** The game's own list in `body`. */
172
+ function ownList(body, options, whereNow, setBadge, ask) {
173
+ const api = createApi(options.base ?? "/friends", options.fetch);
174
+ const lease = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
175
+ const status = h("p", { class: "n133f-status", role: "status", "aria-live": "polite" });
176
+ const nameInput = h("input", { class: "n133f-input", type: "text", name: "friend", maxlength: 64, autocomplete: "off", placeholder: "A player's name", "aria-label": "A player's name" });
177
+ const addKey = h("button", { class: "n133f-btn n133f-primary", type: "submit" }, "Add");
178
+ const form = h("form", { class: "n133f-add" }, nameInput, addKey);
179
+ const list = h("div", { class: "n133f-list" });
180
+ const show = h("input", { type: "checkbox", class: "n133f-check" });
181
+ const showRow = h("label", { class: "n133f-show" }, show, h("span", {}, "Show friends when I am online"));
182
+ body.append(form, status, list, showRow);
183
+ let view = null;
184
+ let isOpen = false;
185
+ let stopped = false;
186
+ let timer = null;
187
+ const fail = (e) => {
188
+ status.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Try again.";
189
+ };
190
+ const draw = () => {
191
+ if (!view)
192
+ return;
193
+ show.checked = view.showOnline;
194
+ const requests = view.people.filter((p) => p.state === "incoming" || (p.state === "friend" && p.invite)).length;
195
+ setBadge({ requests, online: view.people.filter((p) => p.state === "friend" && p.online).length });
196
+ const groups = [
197
+ ["Requests", view.people.filter((p) => p.state === "incoming")],
198
+ ["Friends", view.people.filter((p) => p.state === "friend")],
199
+ ["Sent", view.people.filter((p) => p.state === "outgoing")],
200
+ ["Blocked", view.people.filter((p) => p.state === "blocked")],
201
+ ];
202
+ const parts = [];
203
+ for (const [label, rows] of groups) {
204
+ if (!rows.length)
205
+ continue;
206
+ parts.push(h("h3", { class: "n133f-group" }, `${label} (${rows.length})`), h("ul", { class: "n133f-rows" }, ...rows.map(rowEl)));
207
+ }
208
+ if (!parts.length)
209
+ parts.push(h("p", { class: "n133f-empty" }, "No friends yet. Add a player by their name."));
210
+ list.replaceChildren(...parts);
211
+ };
212
+ const act = async (body, done) => {
213
+ try {
214
+ view = await api.post("", body);
215
+ status.textContent = done ?? "";
216
+ draw();
217
+ }
218
+ catch (e) {
219
+ fail(e);
220
+ }
221
+ };
222
+ const btn = (words, onclick, kind = "") => h("button", { class: `n133f-btn ${kind}`.trim(), type: "button", onclick }, words);
223
+ function rowEl(p) {
224
+ const keys = [];
225
+ if (p.state === "incoming")
226
+ keys.push(btn("Accept", () => void act({ op: "accept", edge: p.edge }, `${p.name} is your friend now.`), "n133f-primary"), btn("Decline", () => void act({ op: "decline", edge: p.edge })));
227
+ if (p.state === "friend") {
228
+ if (p.invite && options.onJoin)
229
+ keys.push(btn("Join", () => options.onJoin(p.invite.where, { name: p.name }), "n133f-primary"));
230
+ const here = whereNow();
231
+ if (here)
232
+ keys.push(btn("Invite", () => void act({ op: "invite", edge: p.edge, where: here }, `Invited ${p.name}.`)));
233
+ keys.push(btn("Remove", () => ask(`Remove ${p.name} from your friends?`) && void act({ op: "remove", edge: p.edge })));
234
+ keys.push(btn("Block", () => ask(`Block ${p.name}? They will not see you or be able to ask you again.`) && void act({ op: "block", edge: p.edge }), "n133f-quiet"));
235
+ }
236
+ if (p.state === "outgoing")
237
+ keys.push(btn("Cancel", () => void act({ op: "decline", edge: p.edge })));
238
+ if (p.state === "blocked")
239
+ keys.push(btn("Unblock", () => void act({ op: "unblock", edge: p.edge })));
240
+ const line = p.state === "friend" ? (p.online ? p.where ?? "Online" : "Offline") : p.state === "incoming" ? "Wants to be friends" : p.state === "outgoing" ? "Asked" : "Blocked";
241
+ const invite = p.state === "friend" && p.invite ? h("p", { class: "n133f-invite" }, `Invites you: ${p.invite.where}`) : null;
242
+ return h("li", { class: `n133f-row n133f-${p.state}${p.online ? " is-online" : ""}` }, h("span", { class: "n133f-dot", "aria-hidden": "true" }), h("div", { class: "n133f-who" }, h("strong", {}, p.name), h("span", { class: "n133f-line" }, p.online ? `Online · ${line}` : line), invite), h("div", { class: "n133f-keys" }, ...keys));
243
+ }
244
+ const read = async () => {
245
+ try {
246
+ view = await api.get("");
247
+ draw();
248
+ }
249
+ catch (e) {
250
+ if (isOpen)
251
+ fail(e);
252
+ }
253
+ };
254
+ const pulse = async (place) => {
255
+ try {
256
+ await api.post("", { op: "pulse", where: place, lease });
257
+ }
258
+ catch {
259
+ // A missed pulse only makes the player look offline for a while.
260
+ }
261
+ };
262
+ /** The next read of the view. One timer at a time: a new one replaces the old. */
263
+ const loop = () => {
264
+ if (timer)
265
+ clearTimeout(timer);
266
+ if (stopped)
267
+ return;
268
+ timer = setTimeout(() => {
269
+ void read().finally(loop);
270
+ }, isOpen ? FRIENDS_TIMING.viewOpenMs : FRIENDS_TIMING.viewClosedMs);
271
+ };
272
+ const beat = setInterval(() => {
273
+ const here = whereNow();
274
+ if (here)
275
+ void pulse(here);
276
+ }, FRIENDS_TIMING.pulseMs);
277
+ const leave = () => {
278
+ // The page goes away: this tab's place is cleared (an old tab cannot clear a newer one: the lease).
279
+ void (options.fetch ?? fetch)(options.base ?? "/friends", { method: "POST", credentials: "same-origin", keepalive: true, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ op: "pulse", where: null, lease }) }).catch(() => undefined);
280
+ };
281
+ window.addEventListener("pagehide", leave);
282
+ form.addEventListener("submit", (e) => {
283
+ e.preventDefault();
284
+ const name = nameInput.value.trim();
285
+ if (!name)
286
+ return;
287
+ void act({ op: "request", name }, `Asked ${name}.`).then(() => {
288
+ if (!status.textContent?.startsWith("Asked"))
289
+ return;
290
+ nameInput.value = "";
291
+ });
292
+ });
293
+ show.addEventListener("change", () => void act({ op: "visibility", showOnline: show.checked }));
294
+ void read();
295
+ if (whereNow())
296
+ void pulse(whereNow());
297
+ loop();
298
+ return {
299
+ opened() {
300
+ isOpen = true;
301
+ status.textContent = "";
302
+ void read();
303
+ loop();
304
+ },
305
+ closed() {
306
+ isOpen = false;
307
+ },
308
+ moved(place) {
309
+ void pulse(place);
310
+ draw();
311
+ },
312
+ stop() {
313
+ stopped = true;
314
+ if (timer)
315
+ clearTimeout(timer);
316
+ clearInterval(beat);
317
+ window.removeEventListener("pagehide", leave);
318
+ leave();
319
+ },
320
+ };
321
+ }
@@ -0,0 +1,52 @@
1
+ /** The `source` of every message, both ways. A message with another `source` is not for this contract. */
2
+ export declare const WINDOW_SOURCE = "nanoterra-friends";
3
+ /** The version of the messages. A message with another `v` is ignored. */
4
+ export declare const WINDOW_V = 1;
5
+ type Head = {
6
+ source: "nanoterra-friends";
7
+ v: 1;
8
+ };
9
+ /** Messages from the game's page to the window (sent with the window's origin as the target). */
10
+ export type ToWindow = Head & ({
11
+ type: "open";
12
+ } | {
13
+ type: "closed";
14
+ } | {
15
+ type: "where";
16
+ where: string | null;
17
+ });
18
+ /** Messages from the window that the kit acts on. Anything else is ignored. */
19
+ export type FromWindow = Head & ({
20
+ type: "ready";
21
+ } | {
22
+ type: "size";
23
+ height: number;
24
+ } | {
25
+ type: "badge";
26
+ online: number;
27
+ requests: number;
28
+ } | {
29
+ type: "join";
30
+ url: string;
31
+ } | {
32
+ type: "signin";
33
+ });
34
+ /** The window's address for a game. */
35
+ export declare function windowUrl(origin: string, game: string): string;
36
+ /** A message to the window. */
37
+ export declare function toWindow(m: {
38
+ type: "open";
39
+ } | {
40
+ type: "closed";
41
+ } | {
42
+ type: "where";
43
+ where: string | null;
44
+ }): ToWindow;
45
+ /**
46
+ * The game's `where` as the kit sends it: plain text, control characters removed, trimmed, at most 64 characters (the site does the same again and
47
+ * shows it as text only). Null when nothing is left.
48
+ */
49
+ export declare function whereText(where: unknown): string | null;
50
+ /** A message from the window, checked: its source, its version, its type and each field. Null for anything else (a bad field too). */
51
+ export declare function readWindowMessage(data: unknown): FromWindow | null;
52
+ export {};
@@ -0,0 +1,72 @@
1
+ // The nano133 friends window: the contract between the kit and nano133's page /embed/friends (contract v1, accepted by the site on 2026-10-30).
2
+ // The window holds the player's Nanoterra friends and their live connection; the game gets none of them. It says only its height, two counts for
3
+ // the badge, that it needs a tap (Storage Access), and a friend's https address to join (the kit asks the player first). No message carries an
4
+ // address, a pass or a player id.
5
+ /** The `source` of every message, both ways. A message with another `source` is not for this contract. */
6
+ export const WINDOW_SOURCE = "nanoterra-friends";
7
+ /** The version of the messages. A message with another `v` is ignored. */
8
+ export const WINDOW_V = 1;
9
+ /** The window's address for a game. */
10
+ export function windowUrl(origin, game) {
11
+ return `${origin}/embed/friends?game=${encodeURIComponent(game)}&v=${WINDOW_V}`;
12
+ }
13
+ /** A message to the window. */
14
+ export function toWindow(m) {
15
+ return { source: WINDOW_SOURCE, v: WINDOW_V, ...m };
16
+ }
17
+ /**
18
+ * The game's `where` as the kit sends it: plain text, control characters removed, trimmed, at most 64 characters (the site does the same again and
19
+ * shows it as text only). Null when nothing is left.
20
+ */
21
+ export function whereText(where) {
22
+ if (typeof where !== "string")
23
+ return null;
24
+ // eslint-disable-next-line no-control-regex
25
+ const plain = where.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "").trim();
26
+ const cut = Array.from(plain).slice(0, 64).join("").trim();
27
+ return cut || null;
28
+ }
29
+ const count = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.min(99, Math.floor(v)) : null);
30
+ /** A message from the window, checked: its source, its version, its type and each field. Null for anything else (a bad field too). */
31
+ export function readWindowMessage(data) {
32
+ if (!data || typeof data !== "object" || Array.isArray(data))
33
+ return null;
34
+ const m = data;
35
+ if (m.source !== WINDOW_SOURCE || m.v !== WINDOW_V || typeof m.type !== "string")
36
+ return null;
37
+ const head = { source: WINDOW_SOURCE, v: WINDOW_V };
38
+ switch (m.type) {
39
+ case "ready":
40
+ case "signin":
41
+ return { ...head, type: m.type };
42
+ case "size": {
43
+ if (typeof m.height !== "number" || !Number.isFinite(m.height) || m.height < 0)
44
+ return null;
45
+ return { ...head, type: "size", height: Math.min(10_000, Math.ceil(m.height)) };
46
+ }
47
+ case "badge": {
48
+ const online = count(m.online);
49
+ const requests = count(m.requests);
50
+ if (online === null || requests === null)
51
+ return null;
52
+ return { ...head, type: "badge", online, requests };
53
+ }
54
+ case "join": {
55
+ if (typeof m.url !== "string" || m.url.length > 2048)
56
+ return null;
57
+ let url;
58
+ try {
59
+ url = new URL(m.url);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ // https only: an island friend's room on nano133, or a friend's game at its listed address. No name or password in it.
65
+ if (url.protocol !== "https:" || url.username || url.password)
66
+ return null;
67
+ return { ...head, type: "join", url: url.href };
68
+ }
69
+ default:
70
+ return null;
71
+ }
72
+ }
@@ -0,0 +1,92 @@
1
+ import { type FriendRow, type FriendState } from "./types.js";
2
+ /** An edge as an adapter keeps it. `a` and `b` are the two players' ids, `a < b`. `rev` grows with each write (an adapter may use it to refuse a write on a row that changed). */
3
+ export type EdgeRec = {
4
+ edge: string;
5
+ a: string;
6
+ b: string;
7
+ from: string;
8
+ state: "pending" | "friend" | "blocked";
9
+ blockedBy: string[];
10
+ at: number;
11
+ invite: {
12
+ from: string;
13
+ where: string;
14
+ at: number;
15
+ } | null;
16
+ rev: number;
17
+ };
18
+ /** What an adapter does after a rule: write this edge (new or changed), delete it, or nothing. */
19
+ export type Plan = {
20
+ put: EdgeRec;
21
+ } | {
22
+ drop: EdgeRec;
23
+ } | {
24
+ none: true;
25
+ };
26
+ /** A presence row: where a player is, which tab said so, and when. */
27
+ export type PresenceRec = {
28
+ player: string;
29
+ where: string;
30
+ lease: string;
31
+ at: number;
32
+ };
33
+ /** The two ids of a pair in the order an adapter keeps them. */
34
+ export declare const pairOf: (x: string, y: string) => [string, string];
35
+ export declare const otherOf: (rec: EdgeRec, me: string) => string;
36
+ export declare const hasPlayer: (rec: EdgeRec, me: string) => boolean;
37
+ /** A request that nobody answered in time is gone; anything else is live. */
38
+ export declare const liveEdge: (rec: EdgeRec, now: number) => boolean;
39
+ /** A new opaque edge id: random, never made from a player id. */
40
+ export declare function newEdgeId(): string;
41
+ /** An edge id as the browser may send one: letters, digits, "-" and "_", 8 to 64. Anything else is refused before a store is asked. */
42
+ export declare const EDGE_PATTERN: RegExp;
43
+ /** A tab's lease, as for an edge. */
44
+ export declare const LEASE_PATTERN: RegExp;
45
+ /**
46
+ * The game's `where` text as a store keeps it: control characters removed, spaces at the ends trimmed, cut to FRIEND_WHERE_MAX characters
47
+ * (whole characters, not halves of one). Null when nothing is left, or when it is not text.
48
+ */
49
+ export declare function cleanWhere(where: unknown): string | null;
50
+ /** 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). */
51
+ export declare function stateFor(rec: EdgeRec, me: string, now: number): FriendState | null;
52
+ /**
53
+ * 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
54
+ * edge would be made (it passes a function).
55
+ */
56
+ export declare function planRequest(me: string, other: string, rec: EdgeRec | null, now: number, counts: () => Promise<{
57
+ mine: number;
58
+ theirs: number;
59
+ }>): Promise<Plan>;
60
+ export declare function planAccept(me: string, rec: EdgeRec | null, now: number): Plan;
61
+ /** Declines a request to `me`, or takes back a request of `me`'s. */
62
+ export declare function planDecline(me: string, rec: EdgeRec | null, now: number): Plan;
63
+ /** Ends a friendship (or a request, either way). A block is ended by unblock, not by remove. */
64
+ export declare function planRemove(me: string, rec: EdgeRec | null, now: number): Plan;
65
+ export declare function planBlock(me: string, rec: EdgeRec | null, now: number): Plan;
66
+ /** Takes `me`'s block away. With no block left the pair has no edge (they are not friends again: either may ask). */
67
+ export declare function planUnblock(me: string, rec: EdgeRec | null, now: number): Plan;
68
+ export declare function planInvite(me: string, rec: EdgeRec | null, where: unknown, now: number): Plan;
69
+ /** 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). */
70
+ export declare function checkPulse(where: unknown, lease: unknown): {
71
+ where: string | null;
72
+ lease: string;
73
+ };
74
+ /** 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). */
75
+ export declare function planPulse(player: string, where: string | null, lease: string, was: PresenceRec | null, now: number): {
76
+ put: PresenceRec;
77
+ } | {
78
+ drop: true;
79
+ } | {
80
+ none: true;
81
+ };
82
+ /** A presence row that still counts. */
83
+ export declare const presenceLive: (p: PresenceRec | null | undefined, now: number) => p is PresenceRec;
84
+ /**
85
+ * 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
86
+ * directory; a player it does not know reads "A player". No player id goes into a row.
87
+ */
88
+ export declare function buildRows(me: string, recs: EdgeRec[], now: number, names: Map<string, string>, presence: Map<string, PresenceRec>, showOnline: Map<string, boolean>): FriendRow[];
89
+ /** The other players whose names a view needs: every edge `me` can see. */
90
+ export declare function shownOthers(me: string, recs: EdgeRec[], now: number): string[];
91
+ /** The friends of `me` (their presence is read for the view). */
92
+ export declare function friendsOf(me: string, recs: EdgeRec[], now: number): string[];