@nano133/nanoterra 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +161 -9
  3. package/ai/SPEC.md +27 -4
  4. package/dist/admin/adapters/d1.d.ts +1 -0
  5. package/dist/admin/adapters/d1.js +6 -4
  6. package/dist/admin/adapters/firestore.d.ts +1 -0
  7. package/dist/admin/adapters/firestore.js +7 -5
  8. package/dist/admin/conformance.d.ts +1 -1
  9. package/dist/admin/conformance.js +13 -5
  10. package/dist/admin/handler.d.ts +1 -1
  11. package/dist/admin/handler.js +3 -3
  12. package/dist/admin/index.d.ts +2 -2
  13. package/dist/admin/index.js +2 -2
  14. package/dist/admin/memory.js +3 -1
  15. package/dist/admin/panel/gameCard.d.ts +1 -1
  16. package/dist/admin/panel/gameCard.js +56 -6
  17. package/dist/admin/panel/mount.js +3 -3
  18. package/dist/admin/panel/overview.d.ts +1 -1
  19. package/dist/admin/panel/overview.js +4 -2
  20. package/dist/admin/panel/shelf.d.ts +4 -1
  21. package/dist/admin/panel/shelf.js +84 -29
  22. package/dist/admin/panel/shelfData.js +1 -1
  23. package/dist/admin/store.d.ts +2 -1
  24. package/dist/admin/switches.d.ts +23 -1
  25. package/dist/admin/switches.js +26 -5
  26. package/dist/friends/adapters/d1.d.ts +4 -0
  27. package/dist/friends/adapters/d1.js +131 -0
  28. package/dist/friends/adapters/firestore.d.ts +49 -0
  29. package/dist/friends/adapters/firestore.js +163 -0
  30. package/dist/friends/conformance.d.ts +11 -0
  31. package/dist/friends/conformance.js +229 -0
  32. package/dist/friends/handler.d.ts +31 -0
  33. package/dist/friends/handler.js +139 -0
  34. package/dist/friends/index.d.ts +8 -0
  35. package/dist/friends/index.js +12 -0
  36. package/dist/friends/memory.d.ts +9 -0
  37. package/dist/friends/memory.js +79 -0
  38. package/dist/friends/panel/index.d.ts +4 -0
  39. package/dist/friends/panel/index.js +10 -0
  40. package/dist/friends/panel/mount.d.ts +38 -0
  41. package/dist/friends/panel/mount.js +321 -0
  42. package/dist/friends/panel/window.d.ts +52 -0
  43. package/dist/friends/panel/window.js +72 -0
  44. package/dist/friends/rules.d.ts +92 -0
  45. package/dist/friends/rules.js +180 -0
  46. package/dist/friends/types.d.ts +58 -0
  47. package/dist/friends/types.js +24 -0
  48. package/dist/looks/change.d.ts +5 -5
  49. package/dist/looks/change.js +12 -7
  50. package/dist/looks/index.d.ts +2 -2
  51. package/dist/looks/index.js +1 -1
  52. package/dist/looks/json.d.ts +3 -2
  53. package/dist/looks/json.js +16 -9
  54. package/dist/looks/look.d.ts +26 -6
  55. package/dist/looks/look.js +37 -12
  56. package/package.json +11 -4
  57. package/src/admin/adapters/d1.ts +5 -4
  58. package/src/admin/adapters/firestore.ts +6 -5
  59. package/src/admin/conformance.ts +15 -6
  60. package/src/admin/handler.ts +3 -3
  61. package/src/admin/index.ts +3 -3
  62. package/src/admin/memory.ts +2 -1
  63. package/src/admin/panel/gameCard.ts +52 -6
  64. package/src/admin/panel/mount.ts +3 -3
  65. package/src/admin/panel/overview.ts +4 -3
  66. package/src/admin/panel/shelf.ts +135 -65
  67. package/src/admin/panel/shelfData.ts +1 -1
  68. package/src/admin/panel/style.css +10 -1
  69. package/src/admin/store.ts +2 -2
  70. package/src/admin/switches.ts +35 -9
  71. package/src/friends/adapters/d1.sql +33 -0
  72. package/src/friends/adapters/d1.ts +146 -0
  73. package/src/friends/adapters/firestore.ts +197 -0
  74. package/src/friends/conformance.ts +260 -0
  75. package/src/friends/handler.ts +144 -0
  76. package/src/friends/index.ts +17 -0
  77. package/src/friends/memory.ts +83 -0
  78. package/src/friends/panel/index.ts +13 -0
  79. package/src/friends/panel/mount.ts +346 -0
  80. package/src/friends/panel/style.css +303 -0
  81. package/src/friends/panel/window.ts +79 -0
  82. package/src/friends/rules.ts +196 -0
  83. package/src/friends/types.ts +57 -0
  84. package/src/looks/change.ts +12 -9
  85. package/src/looks/index.ts +2 -2
  86. package/src/looks/json.ts +12 -7
  87. package/src/looks/look.ts +47 -9
@@ -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";
@@ -0,0 +1,346 @@
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
+
14
+ import { ApiError, createApi, type Api } from "../../admin/panel/api.js";
15
+ import { h } from "../../admin/panel/dom.js";
16
+ import { icon } from "../../admin/panel/icons.js";
17
+ import type { FriendRow, FriendView } from "../types.js";
18
+ import { readWindowMessage, toWindow, whereText, windowUrl, type ToWindow } from "./window.js";
19
+
20
+ export type FriendsMode = "nanoterra" | "own" | "off";
21
+
22
+ export type FriendsMountOptions = {
23
+ mode: FriendsMode;
24
+ /** The game's id on Nanoterra (its listing's id): the window shows the player as "Playing" it. */
25
+ game: string;
26
+ /** nano133's origin. Default "https://nano133.com". */
27
+ origin?: string;
28
+ /** The handler's path for the game's own friends. Default "/friends". */
29
+ base?: string;
30
+ /** Where the player is now, in the game's own words ("Level 2", a room): friends see it. Plain text, at most 64 characters. Change it with `setWhere`. */
31
+ where?: string | null;
32
+ /**
33
+ * The player chose to join a friend. "nanoterra": `target` is the friend's https address (the kit has asked the player already) and there is no
34
+ * `friend` (the window sends no name); with no `onJoin` the page goes there. "own": `target` is the `where` of the friend's invite, for the game to
35
+ * move the player, and `friend` names them (with no `onJoin`, there is no Join key).
36
+ */
37
+ onJoin?: (target: string, friend?: { name: string }) => void;
38
+ /** The `fetch` to use (default: the page's). */
39
+ fetch?: typeof fetch;
40
+ /** Asks the player a yes or no question (default: `window.confirm`). */
41
+ confirm?: (question: string) => boolean;
42
+ };
43
+
44
+ export type MountedFriends = {
45
+ /** Sets where the player is (friends see it). Null: no place. */
46
+ setWhere(where: string | null): void;
47
+ open(): void;
48
+ close(): void;
49
+ /** Removes the key, the panel and the window, and stops every timer. */
50
+ destroy(): void;
51
+ };
52
+
53
+ export const FRIENDS_TIMING = { viewOpenMs: 20_000, viewClosedMs: 60_000, pulseMs: 45_000 } as const;
54
+
55
+ const NOTHING: MountedFriends = { setWhere() {}, open() {}, close() {}, destroy() {} };
56
+
57
+ let mounts = 0;
58
+
59
+ export function mountFriends(root: HTMLElement, options: FriendsMountOptions): MountedFriends {
60
+ if (options.mode !== "nanoterra" && options.mode !== "own") return NOTHING;
61
+ const n = ++mounts;
62
+ const origin = (options.origin ?? "https://nano133.com").replace(/\/+$/, "");
63
+ const ask = options.confirm ?? ((q: string) => window.confirm(q));
64
+ let where = options.where === undefined ? null : whereText(options.where);
65
+ let isOpen = false;
66
+ let destroyed = false;
67
+ const cleanup: (() => void)[] = [];
68
+
69
+ const badge = h("span", { class: "n133f-badge", "aria-hidden": "true" });
70
+ badge.hidden = true;
71
+ const keyWords = h("span", { class: "n133f-key-word" }, "Friends");
72
+ const key = h("button", { class: "n133f-key", type: "button", "aria-expanded": "false", "aria-controls": `n133f-panel-${n}` }, icon("players", 20), keyWords, badge);
73
+ const title = h("h2", { class: "n133f-title", id: `n133f-title-${n}` }, "Friends");
74
+ const closeKey = h("button", { class: "n133f-close", type: "button", "aria-label": "Close friends" }, icon("close", 20));
75
+ const body = h("div", { class: "n133f-body" });
76
+ 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);
77
+ const wrap = h("div", { class: "n133-friends" }, key, panel);
78
+ root.append(wrap);
79
+
80
+ /** The requests (the thing to do) when there are any, else the friends online (another colour). The key's name says both. */
81
+ const setBadge = ({ requests, online }: { requests: number; online: number }) => {
82
+ const shown = requests > 0 ? requests : online;
83
+ badge.hidden = shown <= 0;
84
+ badge.classList.toggle("is-online", requests <= 0 && online > 0);
85
+ badge.textContent = shown > 9 ? "9+" : String(shown);
86
+ const parts = [requests > 0 ? `${requests} ${requests === 1 ? "request" : "requests"}` : "", online > 0 ? `${online} online` : ""].filter(Boolean);
87
+ key.setAttribute("aria-label", parts.length ? `Friends, ${parts.join(", ")}` : "Friends");
88
+ };
89
+
90
+ let onOpen = () => {};
91
+ let onClose = () => {};
92
+ const open = () => {
93
+ if (destroyed || isOpen) return;
94
+ isOpen = true;
95
+ panel.dataset.open = "true";
96
+ key.setAttribute("aria-expanded", "true");
97
+ onOpen();
98
+ closeKey.focus();
99
+ };
100
+ const close = () => {
101
+ if (destroyed || !isOpen) return;
102
+ isOpen = false;
103
+ panel.dataset.open = "false";
104
+ key.setAttribute("aria-expanded", "false");
105
+ onClose();
106
+ key.focus();
107
+ };
108
+ key.addEventListener("click", () => (isOpen ? close() : open()));
109
+ closeKey.addEventListener("click", close);
110
+ const onKey = (e: KeyboardEvent) => {
111
+ if (e.key === "Escape" && isOpen) close();
112
+ };
113
+ document.addEventListener("keydown", onKey);
114
+ cleanup.push(() => document.removeEventListener("keydown", onKey));
115
+
116
+ let setWhereHere = (_w: string | null) => {};
117
+
118
+ if (options.mode === "nanoterra") {
119
+ // The window, made at once (no lazy loading) and kept while the panel is closed.
120
+ const frame = h("iframe", {
121
+ class: "n133f-window",
122
+ src: windowUrl(origin, options.game),
123
+ title: "Friends",
124
+ referrerpolicy: "origin",
125
+ allow: "storage-access",
126
+ sandbox: "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation",
127
+ });
128
+ body.append(frame);
129
+ const hideFrame = (hidden: boolean) => {
130
+ if (hidden) {
131
+ frame.setAttribute("aria-hidden", "true");
132
+ frame.tabIndex = -1;
133
+ } else {
134
+ frame.removeAttribute("aria-hidden");
135
+ frame.removeAttribute("tabindex");
136
+ }
137
+ };
138
+ hideFrame(true);
139
+ let ready = false;
140
+ const send = (msg: ToWindow) => {
141
+ if (ready) frame.contentWindow?.postMessage(msg, origin);
142
+ };
143
+ onOpen = () => {
144
+ hideFrame(false);
145
+ send(toWindow({ type: "open" }));
146
+ };
147
+ onClose = () => {
148
+ hideFrame(true);
149
+ send(toWindow({ type: "closed" }));
150
+ };
151
+ setWhereHere = (w) => send(toWindow({ type: "where", where: w }));
152
+ const onMessage = (e: MessageEvent) => {
153
+ // Only the window itself, from nano133's origin.
154
+ if (e.origin !== origin || e.source !== frame.contentWindow) return;
155
+ const m = readWindowMessage(e.data);
156
+ if (!m) return;
157
+ if (m.type === "ready") {
158
+ // Right after ready: where the player is, then whether the panel is open.
159
+ ready = true;
160
+ send(toWindow({ type: "where", where }));
161
+ send(toWindow({ type: isOpen ? "open" : "closed" }));
162
+ } else if (m.type === "size") {
163
+ // The height its content needs, at most the panel's own room.
164
+ const room = body.clientHeight;
165
+ frame.style.height = `${room > 0 ? Math.min(m.height, room) : m.height}px`;
166
+ } else if (m.type === "badge") setBadge(m);
167
+ else if (m.type === "join") {
168
+ if (!ask("Leave this game to join your friend?")) return;
169
+ if (options.onJoin) options.onJoin(m.url);
170
+ else window.location.assign(m.url);
171
+ }
172
+ // "signin": the window needs a tap (Storage Access) inside itself. The panel stays as it is; the kit does nothing else.
173
+ };
174
+ window.addEventListener("message", onMessage);
175
+ cleanup.push(() => window.removeEventListener("message", onMessage));
176
+ } else {
177
+ const own = ownList(body, options, () => where, setBadge, ask);
178
+ onOpen = own.opened;
179
+ onClose = own.closed;
180
+ setWhereHere = own.moved;
181
+ cleanup.push(own.stop);
182
+ }
183
+
184
+ return {
185
+ setWhere(w) {
186
+ where = whereText(w);
187
+ setWhereHere(where);
188
+ },
189
+ open,
190
+ close,
191
+ destroy() {
192
+ if (destroyed) return;
193
+ destroyed = true;
194
+ for (const f of cleanup) f();
195
+ wrap.remove();
196
+ },
197
+ };
198
+ }
199
+
200
+ /** The game's own list in `body`. */
201
+ function ownList(body: HTMLElement, options: FriendsMountOptions, whereNow: () => string | null, setBadge: (n: { requests: number; online: number }) => void, ask: (q: string) => boolean) {
202
+ const api: Api = createApi(options.base ?? "/friends", options.fetch);
203
+ const lease = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
204
+ const status = h("p", { class: "n133f-status", role: "status", "aria-live": "polite" });
205
+ 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" });
206
+ const addKey = h("button", { class: "n133f-btn n133f-primary", type: "submit" }, "Add");
207
+ const form = h("form", { class: "n133f-add" }, nameInput, addKey);
208
+ const list = h("div", { class: "n133f-list" });
209
+ const show = h("input", { type: "checkbox", class: "n133f-check" });
210
+ const showRow = h("label", { class: "n133f-show" }, show, h("span", {}, "Show friends when I am online"));
211
+ body.append(form, status, list, showRow);
212
+
213
+ let view: FriendView | null = null;
214
+ let isOpen = false;
215
+ let stopped = false;
216
+ let timer: ReturnType<typeof setTimeout> | null = null;
217
+
218
+ const fail = (e: unknown) => {
219
+ status.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Try again.";
220
+ };
221
+ const draw = () => {
222
+ if (!view) return;
223
+ show.checked = view.showOnline;
224
+ const requests = view.people.filter((p) => p.state === "incoming" || (p.state === "friend" && p.invite)).length;
225
+ setBadge({ requests, online: view.people.filter((p) => p.state === "friend" && p.online).length });
226
+ const groups: [string, FriendRow[]][] = [
227
+ ["Requests", view.people.filter((p) => p.state === "incoming")],
228
+ ["Friends", view.people.filter((p) => p.state === "friend")],
229
+ ["Sent", view.people.filter((p) => p.state === "outgoing")],
230
+ ["Blocked", view.people.filter((p) => p.state === "blocked")],
231
+ ];
232
+ const parts: HTMLElement[] = [];
233
+ for (const [label, rows] of groups) {
234
+ if (!rows.length) continue;
235
+ parts.push(h("h3", { class: "n133f-group" }, `${label} (${rows.length})`), h("ul", { class: "n133f-rows" }, ...rows.map(rowEl)));
236
+ }
237
+ if (!parts.length) parts.push(h("p", { class: "n133f-empty" }, "No friends yet. Add a player by their name."));
238
+ list.replaceChildren(...parts);
239
+ };
240
+ const act = async (body: Record<string, unknown>, done?: string) => {
241
+ try {
242
+ view = await api.post<FriendView>("", body);
243
+ status.textContent = done ?? "";
244
+ draw();
245
+ } catch (e) {
246
+ fail(e);
247
+ }
248
+ };
249
+ const btn = (words: string, onclick: () => void, kind = "") => h("button", { class: `n133f-btn ${kind}`.trim(), type: "button", onclick }, words);
250
+ function rowEl(p: FriendRow): HTMLElement {
251
+ const keys: HTMLElement[] = [];
252
+ if (p.state === "incoming") 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 })));
253
+ if (p.state === "friend") {
254
+ if (p.invite && options.onJoin) keys.push(btn("Join", () => options.onJoin!(p.invite!.where, { name: p.name }), "n133f-primary"));
255
+ const here = whereNow();
256
+ if (here) keys.push(btn("Invite", () => void act({ op: "invite", edge: p.edge, where: here }, `Invited ${p.name}.`)));
257
+ keys.push(btn("Remove", () => ask(`Remove ${p.name} from your friends?`) && void act({ op: "remove", edge: p.edge })));
258
+ 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"));
259
+ }
260
+ if (p.state === "outgoing") keys.push(btn("Cancel", () => void act({ op: "decline", edge: p.edge })));
261
+ if (p.state === "blocked") keys.push(btn("Unblock", () => void act({ op: "unblock", edge: p.edge })));
262
+ const line = p.state === "friend" ? (p.online ? p.where ?? "Online" : "Offline") : p.state === "incoming" ? "Wants to be friends" : p.state === "outgoing" ? "Asked" : "Blocked";
263
+ const invite = p.state === "friend" && p.invite ? h("p", { class: "n133f-invite" }, `Invites you: ${p.invite.where}`) : null;
264
+ return h(
265
+ "li",
266
+ { class: `n133f-row n133f-${p.state}${p.online ? " is-online" : ""}` },
267
+ h("span", { class: "n133f-dot", "aria-hidden": "true" }),
268
+ h("div", { class: "n133f-who" }, h("strong", {}, p.name), h("span", { class: "n133f-line" }, p.online ? `Online · ${line}` : line), invite),
269
+ h("div", { class: "n133f-keys" }, ...keys),
270
+ );
271
+ }
272
+
273
+ const read = async () => {
274
+ try {
275
+ view = await api.get<FriendView>("");
276
+ draw();
277
+ } catch (e) {
278
+ if (isOpen) fail(e);
279
+ }
280
+ };
281
+ const pulse = async (place: string | null) => {
282
+ try {
283
+ await api.post("", { op: "pulse", where: place, lease });
284
+ } catch {
285
+ // A missed pulse only makes the player look offline for a while.
286
+ }
287
+ };
288
+ /** The next read of the view. One timer at a time: a new one replaces the old. */
289
+ const loop = () => {
290
+ if (timer) clearTimeout(timer);
291
+ if (stopped) return;
292
+ timer = setTimeout(
293
+ () => {
294
+ void read().finally(loop);
295
+ },
296
+ isOpen ? FRIENDS_TIMING.viewOpenMs : FRIENDS_TIMING.viewClosedMs,
297
+ );
298
+ };
299
+ const beat = setInterval(() => {
300
+ const here = whereNow();
301
+ if (here) void pulse(here);
302
+ }, FRIENDS_TIMING.pulseMs);
303
+ const leave = () => {
304
+ // The page goes away: this tab's place is cleared (an old tab cannot clear a newer one: the lease).
305
+ 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);
306
+ };
307
+ window.addEventListener("pagehide", leave);
308
+
309
+ form.addEventListener("submit", (e) => {
310
+ e.preventDefault();
311
+ const name = nameInput.value.trim();
312
+ if (!name) return;
313
+ void act({ op: "request", name }, `Asked ${name}.`).then(() => {
314
+ if (!status.textContent?.startsWith("Asked")) return;
315
+ nameInput.value = "";
316
+ });
317
+ });
318
+ show.addEventListener("change", () => void act({ op: "visibility", showOnline: show.checked }));
319
+
320
+ void read();
321
+ if (whereNow()) void pulse(whereNow());
322
+ loop();
323
+
324
+ return {
325
+ opened() {
326
+ isOpen = true;
327
+ status.textContent = "";
328
+ void read();
329
+ loop();
330
+ },
331
+ closed() {
332
+ isOpen = false;
333
+ },
334
+ moved(place: string | null) {
335
+ void pulse(place);
336
+ draw();
337
+ },
338
+ stop() {
339
+ stopped = true;
340
+ if (timer) clearTimeout(timer);
341
+ clearInterval(beat);
342
+ window.removeEventListener("pagehide", leave);
343
+ leave();
344
+ },
345
+ };
346
+ }