@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,139 @@
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
+ import { fail, foreignOrigin, json, memoryLimiter, readLimited, sameOrigin } from "../admin/http.js";
15
+ import { EDGE_PATTERN } from "./rules.js";
16
+ import { FriendsError } from "./types.js";
17
+ export const FRIENDS_LIMITS = {
18
+ /** Calls of one player (reads and writes together). */
19
+ calls: { limit: 30, windowMs: 60_000 },
20
+ /** The biggest body of a POST. */
21
+ bodyMax: 1024,
22
+ /** The longest name a request may send. */
23
+ nameMax: 64,
24
+ };
25
+ const sharedLimit = memoryLimiter();
26
+ const OPS = new Set(["request", "accept", "decline", "remove", "block", "unblock", "invite", "pulse", "visibility"]);
27
+ export function createFriendsHandler(options) {
28
+ const base = options.base ?? "/friends";
29
+ if (!base.startsWith("/") || base.length < 2 || base.endsWith("/"))
30
+ throw new Error('base must start with "/" and not end with "/", like "/friends"');
31
+ const now = options.now ?? (() => Date.now());
32
+ const limit = options.limit ?? sharedLimit;
33
+ const report = (e) => {
34
+ try {
35
+ options.onError?.(e);
36
+ }
37
+ catch {
38
+ // nothing more can be done
39
+ }
40
+ };
41
+ async function run(req, player, at) {
42
+ if (req.method === "GET")
43
+ return json(await options.store.view(player, options.directory, at));
44
+ const text = await readLimited(req, FRIENDS_LIMITS.bodyMax);
45
+ if (text === null)
46
+ return fail(413, "That is too much to send.");
47
+ let body;
48
+ try {
49
+ const v = JSON.parse(text);
50
+ if (!v || typeof v !== "object" || Array.isArray(v))
51
+ return fail(400, "Send an object with an op.");
52
+ body = v;
53
+ }
54
+ catch {
55
+ return fail(400, "Send JSON.");
56
+ }
57
+ const op = body.op;
58
+ if (typeof op !== "string" || !OPS.has(op))
59
+ return fail(400, "Unknown op.");
60
+ const store = options.store;
61
+ const edge = () => {
62
+ if (typeof body.edge !== "string" || !EDGE_PATTERN.test(body.edge))
63
+ throw new FriendsError(400, "Choose a player on your list.");
64
+ return body.edge;
65
+ };
66
+ switch (op) {
67
+ case "request": {
68
+ const name = typeof body.name === "string" ? body.name.trim() : "";
69
+ if (!name || name.length > FRIENDS_LIMITS.nameMax)
70
+ return fail(400, "Enter a player's name.");
71
+ const other = await options.directory.byName(name);
72
+ if (!other)
73
+ return fail(404, "No player has that name.");
74
+ await store.request(player, other, at);
75
+ break;
76
+ }
77
+ case "accept":
78
+ await store.accept(player, edge(), at);
79
+ break;
80
+ case "decline":
81
+ await store.decline(player, edge());
82
+ break;
83
+ case "remove":
84
+ await store.remove(player, edge());
85
+ break;
86
+ case "block":
87
+ await store.block(player, edge(), at);
88
+ break;
89
+ case "unblock":
90
+ await store.unblock(player, edge(), at);
91
+ break;
92
+ case "invite":
93
+ await store.invite(player, edge(), typeof body.where === "string" ? body.where : "", at);
94
+ break;
95
+ case "pulse":
96
+ await store.pulse(player, body.where === null ? null : typeof body.where === "string" ? body.where : "", typeof body.lease === "string" ? body.lease : "", at);
97
+ // A pulse needs no list back: the panel reads the view on its own clock.
98
+ return json({ ok: true });
99
+ case "visibility":
100
+ if (typeof body.showOnline !== "boolean")
101
+ return fail(400, "Send showOnline as true or false.");
102
+ await store.setShowOnline(player, body.showOnline);
103
+ break;
104
+ }
105
+ return json(await store.view(player, options.directory, now()));
106
+ }
107
+ async function handle(req) {
108
+ const url = new URL(req.url);
109
+ if (url.pathname !== base)
110
+ return null;
111
+ try {
112
+ if (req.method !== "GET" && req.method !== "POST")
113
+ return fail(405, "Not allowed.", {}, { Allow: "GET, POST" });
114
+ if (req.method === "POST" ? !sameOrigin(req) : foreignOrigin(req))
115
+ return fail(403, "This call must come from the game's own page.");
116
+ let player;
117
+ try {
118
+ player = await options.player(req);
119
+ }
120
+ catch (e) {
121
+ report(e);
122
+ return fail(503, "The game's server could not check your sign-in. Try again.");
123
+ }
124
+ if (typeof player !== "string" || !player)
125
+ return fail(401, "Sign in to see your friends.");
126
+ const at = now();
127
+ if (!(await limit(`${base}|friends|${player}`, FRIENDS_LIMITS.calls.limit, FRIENDS_LIMITS.calls.windowMs, at)))
128
+ return fail(429, "Too many calls. Wait a minute.");
129
+ return await run(req, player, at);
130
+ }
131
+ catch (e) {
132
+ if (e instanceof FriendsError)
133
+ return fail(e.status, e.message);
134
+ report(e);
135
+ return fail(500, "The game's server could not do that. Try again.");
136
+ }
137
+ }
138
+ return { base, handle };
139
+ }
@@ -0,0 +1,8 @@
1
+ export { FRIEND_INVITE_MS, FRIEND_LIMIT, FRIEND_LIVE_MS, FRIEND_REQUEST_MS, FRIEND_WHERE_MAX, FriendsError } from "./types.js";
2
+ export type { FriendRow, FriendState, FriendView, FriendsStore, PlayerDirectory } from "./types.js";
3
+ export { FRIENDS_LIMITS, createFriendsHandler } from "./handler.js";
4
+ export type { FriendsHandlerOptions, FriendsOp } from "./handler.js";
5
+ export { createMemoryFriends } from "./memory.js";
6
+ export type { MemoryFriends } from "./memory.js";
7
+ export { EDGE_PATTERN, LEASE_PATTERN, cleanWhere } from "./rules.js";
8
+ export type { EdgeRec, PresenceRec } from "./rules.js";
@@ -0,0 +1,12 @@
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
+ export { FRIEND_INVITE_MS, FRIEND_LIMIT, FRIEND_LIVE_MS, FRIEND_REQUEST_MS, FRIEND_WHERE_MAX, FriendsError } from "./types.js";
10
+ export { FRIENDS_LIMITS, createFriendsHandler } from "./handler.js";
11
+ export { createMemoryFriends } from "./memory.js";
12
+ export { EDGE_PATTERN, LEASE_PATTERN, cleanWhere } from "./rules.js";
@@ -0,0 +1,9 @@
1
+ import { type EdgeRec, type PresenceRec } from "./rules.js";
2
+ import type { FriendsStore } from "./types.js";
3
+ export type MemoryFriends = FriendsStore & {
4
+ /** The rows as an adapter would keep them. */
5
+ readonly edges: Map<string, EdgeRec>;
6
+ readonly presence: Map<string, PresenceRec>;
7
+ readonly prefs: Map<string, boolean>;
8
+ };
9
+ export declare function createMemoryFriends(): MemoryFriends;
@@ -0,0 +1,79 @@
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
+ import { buildRows, checkPulse, friendsOf, hasPlayer, liveEdge, pairOf, planAccept, planBlock, planDecline, planInvite, planPulse, planRemove, planRequest, planUnblock, shownOthers } from "./rules.js";
4
+ export function createMemoryFriends() {
5
+ const edges = new Map();
6
+ const presence = new Map();
7
+ const prefs = new Map();
8
+ const of = (player) => [...edges.values()].filter((r) => hasPlayer(r, player));
9
+ const byPair = (x, y) => {
10
+ const [a, b] = pairOf(x, y);
11
+ return [...edges.values()].find((r) => r.a === a && r.b === b) ?? null;
12
+ };
13
+ const live = (player, now) => of(player).filter((r) => liveEdge(r, now)).length;
14
+ const apply = (plan) => {
15
+ if ("put" in plan)
16
+ edges.set(plan.put.edge, plan.put);
17
+ else if ("drop" in plan)
18
+ edges.delete(plan.drop.edge);
19
+ };
20
+ return {
21
+ edges,
22
+ presence,
23
+ prefs,
24
+ async view(player, directory, now) {
25
+ const recs = of(player);
26
+ const names = await directory.names(shownOthers(player, recs, now));
27
+ const friends = friendsOf(player, recs, now);
28
+ const pres = new Map(friends.flatMap((f) => (presence.has(f) ? [[f, presence.get(f)]] : [])));
29
+ const shows = new Map(friends.map((f) => [f, prefs.get(f) !== false]));
30
+ return { showOnline: prefs.get(player) !== false, people: buildRows(player, recs, now, names, pres, shows) };
31
+ },
32
+ async request(player, other, now) {
33
+ apply(await planRequest(player, other, byPair(player, other), now, async () => ({ mine: live(player, now), theirs: live(other, now) })));
34
+ },
35
+ async accept(player, edge, now) {
36
+ apply(planAccept(player, edges.get(edge) ?? null, now));
37
+ },
38
+ async decline(player, edge) {
39
+ apply(planDecline(player, edges.get(edge) ?? null, Date.now()));
40
+ },
41
+ async remove(player, edge) {
42
+ apply(planRemove(player, edges.get(edge) ?? null, Date.now()));
43
+ },
44
+ async block(player, edge, now) {
45
+ apply(planBlock(player, edges.get(edge) ?? null, now));
46
+ },
47
+ async unblock(player, edge, now) {
48
+ apply(planUnblock(player, edges.get(edge) ?? null, now));
49
+ },
50
+ async invite(player, edge, where, now) {
51
+ apply(planInvite(player, edges.get(edge) ?? null, where, now));
52
+ },
53
+ async pulse(player, where, lease, now) {
54
+ const p = checkPulse(where, lease);
55
+ const plan = planPulse(player, p.where, p.lease, presence.get(player) ?? null, now);
56
+ if ("put" in plan)
57
+ presence.set(player, plan.put);
58
+ else if ("drop" in plan)
59
+ presence.delete(player);
60
+ },
61
+ async setShowOnline(player, on) {
62
+ prefs.set(player, Boolean(on));
63
+ if (!on)
64
+ presence.delete(player);
65
+ },
66
+ async forget(player) {
67
+ let n = 0;
68
+ for (const r of of(player)) {
69
+ edges.delete(r.edge);
70
+ n++;
71
+ }
72
+ if (presence.delete(player))
73
+ n++;
74
+ if (prefs.delete(player))
75
+ n++;
76
+ return n;
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,4 @@
1
+ export { FRIENDS_TIMING, mountFriends } from "./mount.js";
2
+ export type { FriendsMode, FriendsMountOptions, MountedFriends } from "./mount.js";
3
+ export { WINDOW_SOURCE, WINDOW_V, readWindowMessage, toWindow, whereText, windowUrl } from "./window.js";
4
+ export type { FromWindow, ToWindow } from "./window.js";
@@ -0,0 +1,10 @@
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
+ export { FRIENDS_TIMING, mountFriends } from "./mount.js";
10
+ export { WINDOW_SOURCE, WINDOW_V, readWindowMessage, toWindow, whereText, windowUrl } from "./window.js";
@@ -0,0 +1,38 @@
1
+ export type FriendsMode = "nanoterra" | "own" | "off";
2
+ export type FriendsMountOptions = {
3
+ mode: FriendsMode;
4
+ /** The game's id on Nanoterra (its listing's id): the window shows the player as "Playing" it. */
5
+ game: string;
6
+ /** nano133's origin. Default "https://nano133.com". */
7
+ origin?: string;
8
+ /** The handler's path for the game's own friends. Default "/friends". */
9
+ base?: string;
10
+ /** 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`. */
11
+ where?: string | null;
12
+ /**
13
+ * 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
14
+ * `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
15
+ * move the player, and `friend` names them (with no `onJoin`, there is no Join key).
16
+ */
17
+ onJoin?: (target: string, friend?: {
18
+ name: string;
19
+ }) => void;
20
+ /** The `fetch` to use (default: the page's). */
21
+ fetch?: typeof fetch;
22
+ /** Asks the player a yes or no question (default: `window.confirm`). */
23
+ confirm?: (question: string) => boolean;
24
+ };
25
+ export type MountedFriends = {
26
+ /** Sets where the player is (friends see it). Null: no place. */
27
+ setWhere(where: string | null): void;
28
+ open(): void;
29
+ close(): void;
30
+ /** Removes the key, the panel and the window, and stops every timer. */
31
+ destroy(): void;
32
+ };
33
+ export declare const FRIENDS_TIMING: {
34
+ readonly viewOpenMs: 20000;
35
+ readonly viewClosedMs: 60000;
36
+ readonly pulseMs: 45000;
37
+ };
38
+ export declare function mountFriends(root: HTMLElement, options: FriendsMountOptions): MountedFriends;
@@ -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
+ }