@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,303 @@
1
+ /* The Friends key and panel (mountFriends in ./mount.ts). Every rule is under .n133-friends, so nothing leaks into the game's page.
2
+ Phone first: every key is 44 px or more. On a phone held upright the panel is a sheet from the bottom; on a wide or sideways screen it is a
3
+ sheet on the right. In "nanoterra" mode the nano133 window stays in the page while the panel is closed (it holds the live connection): the
4
+ closed panel is hidden and moved off the screen, never display: none.
5
+ To theme it, set the --n133-* custom properties on the element that holds the key (or a parent): --n133-ink, --n133-ink-soft, --n133-paper,
6
+ --n133-line, --n133-gem, --n133-gem-dark, --n133-radius, --n133-font and --n133-friends-z (the panel's z-index, default 1000). */
7
+
8
+ .n133-friends {
9
+ --font: var(--n133-font, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
10
+ --gem: var(--n133-gem, #20c9a6);
11
+ --gem-dark: var(--n133-gem-dark, #0e8a72);
12
+ --ink: var(--n133-ink, #16202e);
13
+ --ink-soft: var(--n133-ink-soft, #4a5568);
14
+ --line: var(--n133-line, #dbe4ee);
15
+ --paper: var(--n133-paper, #ffffff);
16
+ --radius: var(--n133-radius, 18px);
17
+ display: inline-block;
18
+ color: var(--ink);
19
+ font: 15px/1.4 var(--font);
20
+ }
21
+
22
+ .n133-friends *,
23
+ .n133-friends *::before,
24
+ .n133-friends *::after {
25
+ box-sizing: border-box;
26
+ }
27
+
28
+ .n133-friends .n133f-key {
29
+ position: relative;
30
+ display: inline-flex;
31
+ align-items: center;
32
+ gap: 8px;
33
+ min-width: 44px;
34
+ min-height: 44px;
35
+ padding: 0 14px;
36
+ border: 1px solid var(--line);
37
+ border-radius: 999px;
38
+ background: var(--paper);
39
+ color: var(--ink);
40
+ font: 650 15px/1 var(--font);
41
+ cursor: pointer;
42
+ touch-action: manipulation;
43
+ }
44
+
45
+ .n133-friends .n133f-badge {
46
+ min-width: 20px;
47
+ height: 20px;
48
+ padding: 0 6px;
49
+ border-radius: 10px;
50
+ background: #d92d4b;
51
+ color: #fff;
52
+ font: 700 12px/20px var(--font);
53
+ text-align: center;
54
+ }
55
+
56
+ /* No requests: the badge counts the friends online, in the gem colour. */
57
+ .n133-friends .n133f-badge.is-online {
58
+ background: var(--gem-dark);
59
+ }
60
+
61
+ .n133-friends .n133f-panel {
62
+ position: fixed;
63
+ z-index: var(--n133-friends-z, 1000);
64
+ right: 0;
65
+ bottom: 0;
66
+ left: 0;
67
+ display: flex;
68
+ flex-direction: column;
69
+ height: min(80vh, 640px);
70
+ height: min(80dvh, 640px);
71
+ border: 1px solid var(--line);
72
+ border-radius: var(--radius) var(--radius) 0 0;
73
+ background: var(--paper);
74
+ box-shadow: 0 -8px 30px rgb(16 24 40 / 0.18);
75
+ padding-bottom: env(safe-area-inset-bottom);
76
+ }
77
+
78
+ /* Closed: not shown, off the screen and out of the tab order, but still in the page (the window keeps its connection). */
79
+ .n133-friends .n133f-panel[data-open="false"] {
80
+ visibility: hidden;
81
+ pointer-events: none;
82
+ transform: translateY(110%);
83
+ }
84
+
85
+ @media (min-width: 640px), (orientation: landscape) and (max-height: 520px) {
86
+ .n133-friends .n133f-panel {
87
+ top: 0;
88
+ left: auto;
89
+ width: min(380px, 100vw);
90
+ height: auto;
91
+ border-radius: var(--radius) 0 0 var(--radius);
92
+ box-shadow: -8px 0 30px rgb(16 24 40 / 0.18);
93
+ padding-bottom: 0;
94
+ padding-right: env(safe-area-inset-right);
95
+ }
96
+
97
+ .n133-friends .n133f-panel[data-open="false"] {
98
+ transform: translateX(110%);
99
+ }
100
+ }
101
+
102
+ @media (prefers-reduced-motion: no-preference) {
103
+ .n133-friends .n133f-panel {
104
+ transition: transform 180ms ease-out, visibility 0s linear 180ms;
105
+ }
106
+
107
+ .n133-friends .n133f-panel[data-open="true"] {
108
+ transition: transform 180ms ease-out, visibility 0s;
109
+ }
110
+ }
111
+
112
+ .n133-friends .n133f-head {
113
+ display: flex;
114
+ align-items: center;
115
+ justify-content: space-between;
116
+ gap: 8px;
117
+ padding: 8px 8px 8px 18px;
118
+ border-bottom: 1px solid var(--line);
119
+ }
120
+
121
+ .n133-friends .n133f-title {
122
+ margin: 0;
123
+ font: 750 18px/1.2 var(--font);
124
+ }
125
+
126
+ .n133-friends .n133f-close {
127
+ display: grid;
128
+ place-items: center;
129
+ width: 44px;
130
+ height: 44px;
131
+ border: 0;
132
+ border-radius: 12px;
133
+ background: transparent;
134
+ color: var(--ink);
135
+ cursor: pointer;
136
+ }
137
+
138
+ .n133-friends .n133f-body {
139
+ flex: 1 1 auto;
140
+ min-height: 0;
141
+ overflow: auto;
142
+ overscroll-behavior: contain;
143
+ }
144
+
145
+ .n133-friends .n133f-nanoterra .n133f-body {
146
+ overflow: hidden;
147
+ }
148
+
149
+ .n133-friends .n133f-window {
150
+ display: block;
151
+ width: 100%;
152
+ height: 100%;
153
+ border: 0;
154
+ }
155
+
156
+ .n133-friends .n133f-own .n133f-body {
157
+ padding: 12px 16px 16px;
158
+ }
159
+
160
+ .n133-friends .n133f-add {
161
+ display: flex;
162
+ gap: 8px;
163
+ }
164
+
165
+ .n133-friends .n133f-input {
166
+ flex: 1 1 auto;
167
+ min-width: 0;
168
+ min-height: 44px;
169
+ padding: 0 12px;
170
+ border: 1px solid var(--line);
171
+ border-radius: 12px;
172
+ background: var(--paper);
173
+ color: var(--ink);
174
+ font: 16px/1 var(--font);
175
+ }
176
+
177
+ .n133-friends .n133f-btn {
178
+ min-height: 44px;
179
+ padding: 0 14px;
180
+ border: 1px solid var(--line);
181
+ border-radius: 12px;
182
+ background: var(--paper);
183
+ color: var(--ink);
184
+ font: 650 14px/1 var(--font);
185
+ white-space: nowrap;
186
+ cursor: pointer;
187
+ touch-action: manipulation;
188
+ }
189
+
190
+ .n133-friends .n133f-primary {
191
+ border-color: var(--gem-dark);
192
+ background: var(--gem-dark);
193
+ color: #fff;
194
+ }
195
+
196
+ .n133-friends .n133f-quiet {
197
+ color: var(--ink-soft);
198
+ }
199
+
200
+ .n133-friends :is(.n133f-key, .n133f-close, .n133f-btn, .n133f-input, .n133f-check):focus-visible {
201
+ outline: 3px solid var(--gem);
202
+ outline-offset: 2px;
203
+ }
204
+
205
+ .n133-friends .n133f-status:empty {
206
+ display: none;
207
+ }
208
+
209
+ .n133-friends .n133f-status {
210
+ margin: 8px 0 0;
211
+ color: var(--ink-soft);
212
+ font-size: 14px;
213
+ }
214
+
215
+ .n133-friends .n133f-group {
216
+ margin: 16px 0 6px;
217
+ color: var(--ink-soft);
218
+ font: 700 13px/1.2 var(--font);
219
+ text-transform: uppercase;
220
+ letter-spacing: 0.04em;
221
+ }
222
+
223
+ .n133-friends .n133f-rows {
224
+ display: grid;
225
+ gap: 8px;
226
+ margin: 0;
227
+ padding: 0;
228
+ list-style: none;
229
+ }
230
+
231
+ .n133-friends .n133f-row {
232
+ display: grid;
233
+ grid-template-columns: 12px minmax(0, 1fr);
234
+ gap: 4px 10px;
235
+ align-items: start;
236
+ padding: 10px 12px;
237
+ border: 1px solid var(--line);
238
+ border-radius: 14px;
239
+ }
240
+
241
+ .n133-friends .n133f-dot {
242
+ width: 10px;
243
+ height: 10px;
244
+ margin-top: 6px;
245
+ border-radius: 50%;
246
+ background: var(--line);
247
+ }
248
+
249
+ .n133-friends .n133f-row.is-online .n133f-dot {
250
+ background: var(--gem);
251
+ }
252
+
253
+ .n133-friends .n133f-who {
254
+ display: grid;
255
+ min-width: 0;
256
+ }
257
+
258
+ .n133-friends .n133f-who strong {
259
+ overflow-wrap: anywhere;
260
+ }
261
+
262
+ .n133-friends .n133f-line,
263
+ .n133-friends .n133f-invite {
264
+ margin: 0;
265
+ color: var(--ink-soft);
266
+ font-size: 14px;
267
+ overflow-wrap: anywhere;
268
+ }
269
+
270
+ .n133-friends .n133f-invite {
271
+ color: var(--gem-dark);
272
+ font-weight: 650;
273
+ }
274
+
275
+ .n133-friends .n133f-keys {
276
+ display: flex;
277
+ flex-wrap: wrap;
278
+ grid-column: 2;
279
+ gap: 6px;
280
+ }
281
+
282
+ .n133-friends .n133f-keys:empty {
283
+ display: none;
284
+ }
285
+
286
+ .n133-friends .n133f-empty {
287
+ margin: 16px 0;
288
+ color: var(--ink-soft);
289
+ }
290
+
291
+ .n133-friends .n133f-show {
292
+ display: flex;
293
+ align-items: center;
294
+ gap: 10px;
295
+ min-height: 44px;
296
+ margin-top: 16px;
297
+ cursor: pointer;
298
+ }
299
+
300
+ .n133-friends .n133f-check {
301
+ width: 22px;
302
+ height: 22px;
303
+ }
@@ -0,0 +1,79 @@
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
+
6
+ /** The `source` of every message, both ways. A message with another `source` is not for this contract. */
7
+ export const WINDOW_SOURCE = "nanoterra-friends";
8
+ /** The version of the messages. A message with another `v` is ignored. */
9
+ export const WINDOW_V = 1;
10
+
11
+ type Head = { source: "nanoterra-friends"; v: 1 };
12
+
13
+ /** Messages from the game's page to the window (sent with the window's origin as the target). */
14
+ export type ToWindow = Head & ({ type: "open" } | { type: "closed" } | { type: "where"; where: string | null });
15
+
16
+ /** Messages from the window that the kit acts on. Anything else is ignored. */
17
+ export type FromWindow = Head &
18
+ ({ type: "ready" } | { type: "size"; height: number } | { type: "badge"; online: number; requests: number } | { type: "join"; url: string } | { type: "signin" });
19
+
20
+ /** The window's address for a game. */
21
+ export function windowUrl(origin: string, game: string): string {
22
+ return `${origin}/embed/friends?game=${encodeURIComponent(game)}&v=${WINDOW_V}`;
23
+ }
24
+
25
+ /** A message to the window. */
26
+ export function toWindow(m: { type: "open" } | { type: "closed" } | { type: "where"; where: string | null }): ToWindow {
27
+ return { source: WINDOW_SOURCE, v: WINDOW_V, ...m };
28
+ }
29
+
30
+ /**
31
+ * 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
32
+ * shows it as text only). Null when nothing is left.
33
+ */
34
+ export function whereText(where: unknown): string | null {
35
+ if (typeof where !== "string") return null;
36
+ // eslint-disable-next-line no-control-regex
37
+ const plain = where.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "").trim();
38
+ const cut = Array.from(plain).slice(0, 64).join("").trim();
39
+ return cut || null;
40
+ }
41
+
42
+ const count = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.min(99, Math.floor(v)) : null);
43
+
44
+ /** A message from the window, checked: its source, its version, its type and each field. Null for anything else (a bad field too). */
45
+ export function readWindowMessage(data: unknown): FromWindow | null {
46
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
47
+ const m = data as Record<string, unknown>;
48
+ if (m.source !== WINDOW_SOURCE || m.v !== WINDOW_V || typeof m.type !== "string") return null;
49
+ const head: Head = { source: WINDOW_SOURCE, v: WINDOW_V };
50
+ switch (m.type) {
51
+ case "ready":
52
+ case "signin":
53
+ return { ...head, type: m.type };
54
+ case "size": {
55
+ if (typeof m.height !== "number" || !Number.isFinite(m.height) || m.height < 0) return null;
56
+ return { ...head, type: "size", height: Math.min(10_000, Math.ceil(m.height)) };
57
+ }
58
+ case "badge": {
59
+ const online = count(m.online);
60
+ const requests = count(m.requests);
61
+ if (online === null || requests === null) return null;
62
+ return { ...head, type: "badge", online, requests };
63
+ }
64
+ case "join": {
65
+ if (typeof m.url !== "string" || m.url.length > 2048) return null;
66
+ let url: URL;
67
+ try {
68
+ url = new URL(m.url);
69
+ } catch {
70
+ return null;
71
+ }
72
+ // https only: an island friend's room on nano133, or a friend's game at its listed address. No name or password in it.
73
+ if (url.protocol !== "https:" || url.username || url.password) return null;
74
+ return { ...head, type: "join", url: url.href };
75
+ }
76
+ default:
77
+ return null;
78
+ }
79
+ }
@@ -0,0 +1,196 @@
1
+ // The rules of the friends list, as pure functions: every adapter (memory, D1, Firestore) reads its rows, asks these what to write, and writes it
2
+ // in one step. So the rules live in one place, and the conformance suite (./conformance.ts) proves that an adapter keeps them.
3
+ //
4
+ // One pair of players has at most one edge. An edge is a request ("pending", from one of them), a friendship, or a block (by one or both).
5
+ // - A block hides the pair from the player who is blocked, both ways, and wins over a friendship or a request. The player who blocked sees a
6
+ // "blocked" row, so that they can unblock it.
7
+ // - A request that nobody answered goes after FRIEND_REQUEST_MS: it is not shown and does not count.
8
+ // - Every live edge counts toward FRIEND_LIMIT for both players.
9
+
10
+ import { FRIEND_INVITE_MS, FRIEND_LIMIT, FRIEND_LIVE_MS, FRIEND_REQUEST_MS, FRIEND_WHERE_MAX, FriendsError, type FriendRow, type FriendState } from "./types.js";
11
+
12
+ /** 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). */
13
+ export type EdgeRec = {
14
+ edge: string;
15
+ a: string;
16
+ b: string;
17
+ from: string;
18
+ state: "pending" | "friend" | "blocked";
19
+ blockedBy: string[];
20
+ at: number;
21
+ invite: { from: string; where: string; at: number } | null;
22
+ rev: number;
23
+ };
24
+
25
+ /** What an adapter does after a rule: write this edge (new or changed), delete it, or nothing. */
26
+ export type Plan = { put: EdgeRec } | { drop: EdgeRec } | { none: true };
27
+
28
+ /** A presence row: where a player is, which tab said so, and when. */
29
+ export type PresenceRec = { player: string; where: string; lease: string; at: number };
30
+
31
+ /** The two ids of a pair in the order an adapter keeps them. */
32
+ export const pairOf = (x: string, y: string): [string, string] => (x < y ? [x, y] : [y, x]);
33
+ export const otherOf = (rec: EdgeRec, me: string): string => (rec.a === me ? rec.b : rec.a);
34
+ export const hasPlayer = (rec: EdgeRec, me: string): boolean => rec.a === me || rec.b === me;
35
+
36
+ /** A request that nobody answered in time is gone; anything else is live. */
37
+ export const liveEdge = (rec: EdgeRec, now: number): boolean => rec.state !== "pending" || now - rec.at < FRIEND_REQUEST_MS;
38
+
39
+ /** A new opaque edge id: random, never made from a player id. */
40
+ export function newEdgeId(): string {
41
+ return globalThis.crypto.randomUUID();
42
+ }
43
+
44
+ /** An edge id as the browser may send one: letters, digits, "-" and "_", 8 to 64. Anything else is refused before a store is asked. */
45
+ export const EDGE_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
46
+ /** A tab's lease, as for an edge. */
47
+ export const LEASE_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
48
+
49
+ /**
50
+ * The game's `where` text as a store keeps it: control characters removed, spaces at the ends trimmed, cut to FRIEND_WHERE_MAX characters
51
+ * (whole characters, not halves of one). Null when nothing is left, or when it is not text.
52
+ */
53
+ export function cleanWhere(where: unknown): string | null {
54
+ if (typeof where !== "string") return null;
55
+ // eslint-disable-next-line no-control-regex
56
+ const plain = where.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "").trim();
57
+ const cut = Array.from(plain).slice(0, FRIEND_WHERE_MAX).join("").trim();
58
+ return cut ? cut : null;
59
+ }
60
+
61
+ /** 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). */
62
+ export function stateFor(rec: EdgeRec, me: string, now: number): FriendState | null {
63
+ if (!hasPlayer(rec, me) || !liveEdge(rec, now)) return null;
64
+ if (rec.state === "blocked") return rec.blockedBy.includes(me) ? "blocked" : null;
65
+ if (rec.state === "friend") return "friend";
66
+ return rec.from === me ? "outgoing" : "incoming";
67
+ }
68
+
69
+ const NOT_HERE = "This player is not on your list any more.";
70
+
71
+ /** The edge that `me` named, or a refusal: an edge of other players, or one they must not see, reads as gone (it says nothing about why). */
72
+ function mine(rec: EdgeRec | null, me: string, now: number): EdgeRec {
73
+ if (!rec || stateFor(rec, me, now) === null) throw new FriendsError(404, NOT_HERE);
74
+ return rec;
75
+ }
76
+
77
+ /**
78
+ * 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
79
+ * edge would be made (it passes a function).
80
+ */
81
+ export async function planRequest(me: string, other: string, rec: EdgeRec | null, now: number, counts: () => Promise<{ mine: number; theirs: number }>): Promise<Plan> {
82
+ if (me === other) throw new FriendsError(400, "You cannot add yourself.");
83
+ const live = rec && liveEdge(rec, now) ? rec : null;
84
+ if (live?.state === "blocked") throw new FriendsError(409, "This player is not available.");
85
+ if (live?.state === "friend") return { none: true };
86
+ if (live?.state === "pending") {
87
+ if (live.from === me) return { none: true };
88
+ // The other player already asked: this is an accept.
89
+ return { put: { ...live, from: me, state: "friend", at: now, invite: null, rev: live.rev + 1 } };
90
+ }
91
+ const c = await counts();
92
+ if (c.mine >= FRIEND_LIMIT) throw new FriendsError(409, "Your friends list is full. Remove someone first.");
93
+ if (c.theirs >= FRIEND_LIMIT) throw new FriendsError(409, "That player's friends list is full.");
94
+ const [a, b] = pairOf(me, other);
95
+ // An old request of the same pair is replaced (same row), so the pair still has one edge.
96
+ return { put: { edge: rec?.edge ?? newEdgeId(), a, b, from: me, state: "pending", blockedBy: [], at: now, invite: null, rev: (rec?.rev ?? 0) + 1 } };
97
+ }
98
+
99
+ export function planAccept(me: string, rec: EdgeRec | null, now: number): Plan {
100
+ const r = mine(rec, me, now);
101
+ const s = stateFor(r, me, now);
102
+ if (s === "friend") return { none: true };
103
+ if (s !== "incoming") throw new FriendsError(409, s === "outgoing" ? "Wait for the other player to accept." : "This request is no longer open.");
104
+ return { put: { ...r, from: me, state: "friend", at: now, invite: null, rev: r.rev + 1 } };
105
+ }
106
+
107
+ /** Declines a request to `me`, or takes back a request of `me`'s. */
108
+ export function planDecline(me: string, rec: EdgeRec | null, now: number): Plan {
109
+ const r = mine(rec, me, now);
110
+ const s = stateFor(r, me, now);
111
+ if (s !== "incoming" && s !== "outgoing") throw new FriendsError(409, "This is not a request.");
112
+ return { drop: r };
113
+ }
114
+
115
+ /** Ends a friendship (or a request, either way). A block is ended by unblock, not by remove. */
116
+ export function planRemove(me: string, rec: EdgeRec | null, now: number): Plan {
117
+ const r = mine(rec, me, now);
118
+ if (stateFor(r, me, now) === "blocked") throw new FriendsError(409, "Unblock this player first.");
119
+ return { drop: r };
120
+ }
121
+
122
+ export function planBlock(me: string, rec: EdgeRec | null, now: number): Plan {
123
+ const r = mine(rec, me, now);
124
+ if (r.blockedBy.includes(me)) return { none: true };
125
+ const blockedBy = r.state === "blocked" ? [...r.blockedBy, me] : [me];
126
+ return { put: { ...r, from: me, state: "blocked", blockedBy, at: now, invite: null, rev: r.rev + 1 } };
127
+ }
128
+
129
+ /** Takes `me`'s block away. With no block left the pair has no edge (they are not friends again: either may ask). */
130
+ export function planUnblock(me: string, rec: EdgeRec | null, now: number): Plan {
131
+ const r = mine(rec, me, now);
132
+ if (!r.blockedBy.includes(me)) throw new FriendsError(409, "This player is not blocked.");
133
+ const left = r.blockedBy.filter((p) => p !== me);
134
+ if (!left.length) return { drop: r };
135
+ return { put: { ...r, blockedBy: left, at: now, rev: r.rev + 1 } };
136
+ }
137
+
138
+ export function planInvite(me: string, rec: EdgeRec | null, where: unknown, now: number): Plan {
139
+ const r = mine(rec, me, now);
140
+ if (stateFor(r, me, now) !== "friend") throw new FriendsError(409, "You can invite only a friend.");
141
+ const place = cleanWhere(where);
142
+ if (!place) throw new FriendsError(400, "Say where to meet.");
143
+ return { put: { ...r, invite: { from: me, where: place, at: now }, rev: r.rev + 1 } };
144
+ }
145
+
146
+ /** 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). */
147
+ export function checkPulse(where: unknown, lease: unknown): { where: string | null; lease: string } {
148
+ if (typeof lease !== "string" || !LEASE_PATTERN.test(lease)) throw new FriendsError(400, "A pulse needs a lease of 8 to 64 letters or digits.");
149
+ if (where === null) return { where: null, lease };
150
+ const place = cleanWhere(where);
151
+ if (!place) throw new FriendsError(400, "Say where you are, or send null to leave.");
152
+ return { where: place, lease };
153
+ }
154
+
155
+ /** 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). */
156
+ export function planPulse(player: string, where: string | null, lease: string, was: PresenceRec | null, now: number): { put: PresenceRec } | { drop: true } | { none: true } {
157
+ if (where !== null) return { put: { player, where, lease, at: now } };
158
+ if (was && was.lease === lease) return { drop: true };
159
+ return { none: true };
160
+ }
161
+
162
+ /** A presence row that still counts. */
163
+ export const presenceLive = (p: PresenceRec | null | undefined, now: number): p is PresenceRec => Boolean(p) && now - p!.at < FRIEND_LIVE_MS && now - p!.at >= -FRIEND_LIVE_MS;
164
+
165
+ /** The order of the list: requests to answer first, then friends who are online, friends, requests sent, blocks; each by name. */
166
+ const RANK: Record<FriendState, number> = { incoming: 0, friend: 1, outgoing: 3, blocked: 4 };
167
+
168
+ /**
169
+ * 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
170
+ * directory; a player it does not know reads "A player". No player id goes into a row.
171
+ */
172
+ export function buildRows(me: string, recs: EdgeRec[], now: number, names: Map<string, string>, presence: Map<string, PresenceRec>, showOnline: Map<string, boolean>): FriendRow[] {
173
+ const rows: (FriendRow & { rank: number })[] = [];
174
+ for (const rec of recs) {
175
+ const state = stateFor(rec, me, now);
176
+ if (!state) continue;
177
+ const other = otherOf(rec, me);
178
+ const p = state === "friend" && showOnline.get(other) !== false ? presence.get(other) : undefined;
179
+ const online = presenceLive(p, now);
180
+ const invite = state === "friend" && rec.invite && rec.invite.from !== me && now - rec.invite.at < FRIEND_INVITE_MS && now - rec.invite.at >= -FRIEND_INVITE_MS ? { where: rec.invite.where, at: rec.invite.at } : null;
181
+ const name = names.get(other);
182
+ rows.push({ edge: rec.edge, name: typeof name === "string" && name ? name : "A player", state, online, where: online ? p!.where : null, invite, rank: state === "friend" && online ? 0.5 : RANK[state] });
183
+ }
184
+ rows.sort((x, y) => x.rank - y.rank || x.name.localeCompare(y.name) || (x.edge < y.edge ? -1 : 1));
185
+ return rows.map(({ rank: _rank, ...row }) => row);
186
+ }
187
+
188
+ /** The other players whose names a view needs: every edge `me` can see. */
189
+ export function shownOthers(me: string, recs: EdgeRec[], now: number): string[] {
190
+ return [...new Set(recs.filter((r) => stateFor(r, me, now) !== null).map((r) => otherOf(r, me)))];
191
+ }
192
+
193
+ /** The friends of `me` (their presence is read for the view). */
194
+ export function friendsOf(me: string, recs: EdgeRec[], now: number): string[] {
195
+ return recs.filter((r) => stateFor(r, me, now) === "friend").map((r) => otherOf(r, me));
196
+ }
@@ -0,0 +1,57 @@
1
+ // The friends list of a game's OWN players: the types, the limits and the store contract. The package never sees a Nanoterra account here: a
2
+ // player is the game's own id, and a name comes from the game's own directory. (Nanoterra's friends are another thing: the nano133 window, ./panel.)
3
+
4
+ /** A row of a player's list, as the browser gets it. `edge` is the store's opaque id for the pair (never a player id). */
5
+ export type FriendState = "friend" | "incoming" | "outgoing" | "blocked";
6
+ export type FriendRow = { edge: string; name: string; state: FriendState; online: boolean; where: string | null; invite: { where: string; at: number } | null };
7
+ export type FriendView = { showOnline: boolean; people: FriendRow[] };
8
+
9
+ /** Live rows a player may have (friends, requests both ways, blocks), as the island. */
10
+ export const FRIEND_LIMIT = 100;
11
+ /** A pulse counts as online this long. */
12
+ export const FRIEND_LIVE_MS = 120_000;
13
+ /** An invite shows this long. */
14
+ export const FRIEND_INVITE_MS = 600_000;
15
+ /** An unanswered request goes after this. */
16
+ export const FRIEND_REQUEST_MS = 30 * 86_400_000;
17
+ /** The longest `where` a store keeps (after trimming and removing control characters). */
18
+ export const FRIEND_WHERE_MAX = 64;
19
+
20
+ /** The game's players, which only the game knows: its sign-in id for a name, and the names to show. */
21
+ export interface PlayerDirectory {
22
+ byName(name: string): Promise<string | null>;
23
+ names(ids: string[]): Promise<Map<string, string>>;
24
+ }
25
+
26
+ /** The game's database for friends. An adapter writes ONLY friend rows, presence rows and the show-online choice. */
27
+ export interface FriendsStore {
28
+ view(player: string, directory: PlayerDirectory, now: number): Promise<FriendView>;
29
+ /** A request by the other player's id; an accept when the other already asked. Refuses: self, a full list, a block. */
30
+ request(player: string, other: string, now: number): Promise<void>;
31
+ accept(player: string, edge: string, now: number): Promise<void>;
32
+ decline(player: string, edge: string): Promise<void>;
33
+ remove(player: string, edge: string): Promise<void>;
34
+ block(player: string, edge: string, now: number): Promise<void>;
35
+ unblock(player: string, edge: string, now: number): Promise<void>;
36
+ /** Only between friends; `where` is the game's own text (a room, a level), at most 64 characters. */
37
+ invite(player: string, edge: string, where: string, now: number): Promise<void>;
38
+ /** Presence: here now (`where` or null), kept FRIEND_LIVE_MS; a `lease` per tab, so an old tab cannot clear a new one. */
39
+ pulse(player: string, where: string | null, lease: string, now: number): Promise<void>;
40
+ setShowOnline(player: string, on: boolean): Promise<void>;
41
+ /** A player's "delete my data": every row of theirs; returns how many. */
42
+ forget(player: string): Promise<number>;
43
+ }
44
+
45
+ /**
46
+ * A refusal of a store or the handler: the status to answer and words a player can read. A store throws it for a rule (a block, a full list, an edge
47
+ * that is not theirs); anything else it throws is a fault, and the handler answers a plain 500.
48
+ */
49
+ export class FriendsError extends Error {
50
+ constructor(
51
+ public status: 400 | 401 | 403 | 404 | 409 | 413 | 429,
52
+ message: string,
53
+ ) {
54
+ super(message);
55
+ this.name = "FriendsError";
56
+ }
57
+ }