@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,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[];
@@ -0,0 +1,180 @@
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
+ import { FRIEND_INVITE_MS, FRIEND_LIMIT, FRIEND_LIVE_MS, FRIEND_REQUEST_MS, FRIEND_WHERE_MAX, FriendsError } from "./types.js";
10
+ /** The two ids of a pair in the order an adapter keeps them. */
11
+ export const pairOf = (x, y) => (x < y ? [x, y] : [y, x]);
12
+ export const otherOf = (rec, me) => (rec.a === me ? rec.b : rec.a);
13
+ export const hasPlayer = (rec, me) => rec.a === me || rec.b === me;
14
+ /** A request that nobody answered in time is gone; anything else is live. */
15
+ export const liveEdge = (rec, now) => rec.state !== "pending" || now - rec.at < FRIEND_REQUEST_MS;
16
+ /** A new opaque edge id: random, never made from a player id. */
17
+ export function newEdgeId() {
18
+ return globalThis.crypto.randomUUID();
19
+ }
20
+ /** An edge id as the browser may send one: letters, digits, "-" and "_", 8 to 64. Anything else is refused before a store is asked. */
21
+ export const EDGE_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
22
+ /** A tab's lease, as for an edge. */
23
+ export const LEASE_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;
24
+ /**
25
+ * The game's `where` text as a store keeps it: control characters removed, spaces at the ends trimmed, cut to FRIEND_WHERE_MAX characters
26
+ * (whole characters, not halves of one). Null when nothing is left, or when it is not text.
27
+ */
28
+ export function cleanWhere(where) {
29
+ if (typeof where !== "string")
30
+ return null;
31
+ // eslint-disable-next-line no-control-regex
32
+ const plain = where.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "").trim();
33
+ const cut = Array.from(plain).slice(0, FRIEND_WHERE_MAX).join("").trim();
34
+ return cut ? cut : null;
35
+ }
36
+ /** 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). */
37
+ export function stateFor(rec, me, now) {
38
+ if (!hasPlayer(rec, me) || !liveEdge(rec, now))
39
+ return null;
40
+ if (rec.state === "blocked")
41
+ return rec.blockedBy.includes(me) ? "blocked" : null;
42
+ if (rec.state === "friend")
43
+ return "friend";
44
+ return rec.from === me ? "outgoing" : "incoming";
45
+ }
46
+ const NOT_HERE = "This player is not on your list any more.";
47
+ /** 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). */
48
+ function mine(rec, me, now) {
49
+ if (!rec || stateFor(rec, me, now) === null)
50
+ throw new FriendsError(404, NOT_HERE);
51
+ return rec;
52
+ }
53
+ /**
54
+ * 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
55
+ * edge would be made (it passes a function).
56
+ */
57
+ export async function planRequest(me, other, rec, now, counts) {
58
+ if (me === other)
59
+ throw new FriendsError(400, "You cannot add yourself.");
60
+ const live = rec && liveEdge(rec, now) ? rec : null;
61
+ if (live?.state === "blocked")
62
+ throw new FriendsError(409, "This player is not available.");
63
+ if (live?.state === "friend")
64
+ return { none: true };
65
+ if (live?.state === "pending") {
66
+ if (live.from === me)
67
+ return { none: true };
68
+ // The other player already asked: this is an accept.
69
+ return { put: { ...live, from: me, state: "friend", at: now, invite: null, rev: live.rev + 1 } };
70
+ }
71
+ const c = await counts();
72
+ if (c.mine >= FRIEND_LIMIT)
73
+ throw new FriendsError(409, "Your friends list is full. Remove someone first.");
74
+ if (c.theirs >= FRIEND_LIMIT)
75
+ throw new FriendsError(409, "That player's friends list is full.");
76
+ const [a, b] = pairOf(me, other);
77
+ // An old request of the same pair is replaced (same row), so the pair still has one edge.
78
+ return { put: { edge: rec?.edge ?? newEdgeId(), a, b, from: me, state: "pending", blockedBy: [], at: now, invite: null, rev: (rec?.rev ?? 0) + 1 } };
79
+ }
80
+ export function planAccept(me, rec, now) {
81
+ const r = mine(rec, me, now);
82
+ const s = stateFor(r, me, now);
83
+ if (s === "friend")
84
+ return { none: true };
85
+ if (s !== "incoming")
86
+ throw new FriendsError(409, s === "outgoing" ? "Wait for the other player to accept." : "This request is no longer open.");
87
+ return { put: { ...r, from: me, state: "friend", at: now, invite: null, rev: r.rev + 1 } };
88
+ }
89
+ /** Declines a request to `me`, or takes back a request of `me`'s. */
90
+ export function planDecline(me, rec, now) {
91
+ const r = mine(rec, me, now);
92
+ const s = stateFor(r, me, now);
93
+ if (s !== "incoming" && s !== "outgoing")
94
+ throw new FriendsError(409, "This is not a request.");
95
+ return { drop: r };
96
+ }
97
+ /** Ends a friendship (or a request, either way). A block is ended by unblock, not by remove. */
98
+ export function planRemove(me, rec, now) {
99
+ const r = mine(rec, me, now);
100
+ if (stateFor(r, me, now) === "blocked")
101
+ throw new FriendsError(409, "Unblock this player first.");
102
+ return { drop: r };
103
+ }
104
+ export function planBlock(me, rec, now) {
105
+ const r = mine(rec, me, now);
106
+ if (r.blockedBy.includes(me))
107
+ return { none: true };
108
+ const blockedBy = r.state === "blocked" ? [...r.blockedBy, me] : [me];
109
+ return { put: { ...r, from: me, state: "blocked", blockedBy, at: now, invite: null, rev: r.rev + 1 } };
110
+ }
111
+ /** Takes `me`'s block away. With no block left the pair has no edge (they are not friends again: either may ask). */
112
+ export function planUnblock(me, rec, now) {
113
+ const r = mine(rec, me, now);
114
+ if (!r.blockedBy.includes(me))
115
+ throw new FriendsError(409, "This player is not blocked.");
116
+ const left = r.blockedBy.filter((p) => p !== me);
117
+ if (!left.length)
118
+ return { drop: r };
119
+ return { put: { ...r, blockedBy: left, at: now, rev: r.rev + 1 } };
120
+ }
121
+ export function planInvite(me, rec, where, now) {
122
+ const r = mine(rec, me, now);
123
+ if (stateFor(r, me, now) !== "friend")
124
+ throw new FriendsError(409, "You can invite only a friend.");
125
+ const place = cleanWhere(where);
126
+ if (!place)
127
+ throw new FriendsError(400, "Say where to meet.");
128
+ return { put: { ...r, invite: { from: me, where: place, at: now }, rev: r.rev + 1 } };
129
+ }
130
+ /** 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). */
131
+ export function checkPulse(where, lease) {
132
+ if (typeof lease !== "string" || !LEASE_PATTERN.test(lease))
133
+ throw new FriendsError(400, "A pulse needs a lease of 8 to 64 letters or digits.");
134
+ if (where === null)
135
+ return { where: null, lease };
136
+ const place = cleanWhere(where);
137
+ if (!place)
138
+ throw new FriendsError(400, "Say where you are, or send null to leave.");
139
+ return { where: place, lease };
140
+ }
141
+ /** 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). */
142
+ export function planPulse(player, where, lease, was, now) {
143
+ if (where !== null)
144
+ return { put: { player, where, lease, at: now } };
145
+ if (was && was.lease === lease)
146
+ return { drop: true };
147
+ return { none: true };
148
+ }
149
+ /** A presence row that still counts. */
150
+ export const presenceLive = (p, now) => Boolean(p) && now - p.at < FRIEND_LIVE_MS && now - p.at >= -FRIEND_LIVE_MS;
151
+ /** The order of the list: requests to answer first, then friends who are online, friends, requests sent, blocks; each by name. */
152
+ const RANK = { incoming: 0, friend: 1, outgoing: 3, blocked: 4 };
153
+ /**
154
+ * 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
155
+ * directory; a player it does not know reads "A player". No player id goes into a row.
156
+ */
157
+ export function buildRows(me, recs, now, names, presence, showOnline) {
158
+ const rows = [];
159
+ for (const rec of recs) {
160
+ const state = stateFor(rec, me, now);
161
+ if (!state)
162
+ continue;
163
+ const other = otherOf(rec, me);
164
+ const p = state === "friend" && showOnline.get(other) !== false ? presence.get(other) : undefined;
165
+ const online = presenceLive(p, now);
166
+ 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;
167
+ const name = names.get(other);
168
+ 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] });
169
+ }
170
+ rows.sort((x, y) => x.rank - y.rank || x.name.localeCompare(y.name) || (x.edge < y.edge ? -1 : 1));
171
+ return rows.map(({ rank: _rank, ...row }) => row);
172
+ }
173
+ /** The other players whose names a view needs: every edge `me` can see. */
174
+ export function shownOthers(me, recs, now) {
175
+ return [...new Set(recs.filter((r) => stateFor(r, me, now) !== null).map((r) => otherOf(r, me)))];
176
+ }
177
+ /** The friends of `me` (their presence is read for the view). */
178
+ export function friendsOf(me, recs, now) {
179
+ return recs.filter((r) => stateFor(r, me, now) === "friend").map((r) => otherOf(r, me));
180
+ }
@@ -0,0 +1,58 @@
1
+ /** 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). */
2
+ export type FriendState = "friend" | "incoming" | "outgoing" | "blocked";
3
+ export type FriendRow = {
4
+ edge: string;
5
+ name: string;
6
+ state: FriendState;
7
+ online: boolean;
8
+ where: string | null;
9
+ invite: {
10
+ where: string;
11
+ at: number;
12
+ } | null;
13
+ };
14
+ export type FriendView = {
15
+ showOnline: boolean;
16
+ people: FriendRow[];
17
+ };
18
+ /** Live rows a player may have (friends, requests both ways, blocks), as the island. */
19
+ export declare const FRIEND_LIMIT = 100;
20
+ /** A pulse counts as online this long. */
21
+ export declare const FRIEND_LIVE_MS = 120000;
22
+ /** An invite shows this long. */
23
+ export declare const FRIEND_INVITE_MS = 600000;
24
+ /** An unanswered request goes after this. */
25
+ export declare const FRIEND_REQUEST_MS: number;
26
+ /** The longest `where` a store keeps (after trimming and removing control characters). */
27
+ export declare const FRIEND_WHERE_MAX = 64;
28
+ /** The game's players, which only the game knows: its sign-in id for a name, and the names to show. */
29
+ export interface PlayerDirectory {
30
+ byName(name: string): Promise<string | null>;
31
+ names(ids: string[]): Promise<Map<string, string>>;
32
+ }
33
+ /** The game's database for friends. An adapter writes ONLY friend rows, presence rows and the show-online choice. */
34
+ export interface FriendsStore {
35
+ view(player: string, directory: PlayerDirectory, now: number): Promise<FriendView>;
36
+ /** A request by the other player's id; an accept when the other already asked. Refuses: self, a full list, a block. */
37
+ request(player: string, other: string, now: number): Promise<void>;
38
+ accept(player: string, edge: string, now: number): Promise<void>;
39
+ decline(player: string, edge: string): Promise<void>;
40
+ remove(player: string, edge: string): Promise<void>;
41
+ block(player: string, edge: string, now: number): Promise<void>;
42
+ unblock(player: string, edge: string, now: number): Promise<void>;
43
+ /** Only between friends; `where` is the game's own text (a room, a level), at most 64 characters. */
44
+ invite(player: string, edge: string, where: string, now: number): Promise<void>;
45
+ /** Presence: here now (`where` or null), kept FRIEND_LIVE_MS; a `lease` per tab, so an old tab cannot clear a new one. */
46
+ pulse(player: string, where: string | null, lease: string, now: number): Promise<void>;
47
+ setShowOnline(player: string, on: boolean): Promise<void>;
48
+ /** A player's "delete my data": every row of theirs; returns how many. */
49
+ forget(player: string): Promise<number>;
50
+ }
51
+ /**
52
+ * 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
53
+ * that is not theirs); anything else it throws is a fault, and the handler answers a plain 500.
54
+ */
55
+ export declare class FriendsError extends Error {
56
+ status: 400 | 401 | 403 | 404 | 409 | 413 | 429;
57
+ constructor(status: 400 | 401 | 403 | 404 | 409 | 413 | 429, message: string);
58
+ }
@@ -0,0 +1,24 @@
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
+ /** Live rows a player may have (friends, requests both ways, blocks), as the island. */
4
+ export const FRIEND_LIMIT = 100;
5
+ /** A pulse counts as online this long. */
6
+ export const FRIEND_LIVE_MS = 120_000;
7
+ /** An invite shows this long. */
8
+ export const FRIEND_INVITE_MS = 600_000;
9
+ /** An unanswered request goes after this. */
10
+ export const FRIEND_REQUEST_MS = 30 * 86_400_000;
11
+ /** The longest `where` a store keeps (after trimming and removing control characters). */
12
+ export const FRIEND_WHERE_MAX = 64;
13
+ /**
14
+ * 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
15
+ * that is not theirs); anything else it throws is a fault, and the handler answers a plain 500.
16
+ */
17
+ export class FriendsError extends Error {
18
+ status;
19
+ constructor(status, message) {
20
+ super(message);
21
+ this.status = status;
22
+ this.name = "FriendsError";
23
+ }
24
+ }
@@ -3,16 +3,16 @@ import { type Look, type Problem, type Slots } from "./look.js";
3
3
  export declare function sameSlots(a: Slots | undefined, b: Slots | undefined): boolean;
4
4
  /**
5
5
  * The mistakes that a new list would make for the players: an id that someone owns (a paid order gave it to them) is gone from the new
6
- * list, or its slots changed (the game reads the slots of the id that a player owns, so a new slot changes what they own: add a new look instead).
7
- * `owned` is every look id that any player owns. The free look is not owned by an order: it is every player's, and its slots may change.
8
- * A change of a price, a name, a body colour or a hat on an owned look is allowed.
6
+ * list, its wear slot changed (a shovel that a player owns must stay a shovel), or its slots changed (the game reads the slots of the id that a player
7
+ * owns, so a new slot changes what they own: add a new look instead). `owned` is every look id that any player owns. The free look is not owned by an
8
+ * order: it is every player's, and its slots may change. A change of a price, a name, a body colour or a hat on an owned look is allowed.
9
9
  */
10
10
  export declare function removalProblems(before: readonly Look[], after: readonly Look[], owned: Iterable<string>): Problem[];
11
- export type LookField = "name" | "priceUsd" | "body" | "hat" | "hatColor" | "slots";
11
+ export type LookField = "name" | "priceUsd" | "wear" | "body" | "hat" | "hatColor" | "slots";
12
12
  export type LooksDiff = {
13
13
  added: Look[];
14
14
  removed: Look[];
15
- /** Looks with the same id and a different name, price, colour, hat or slots. */
15
+ /** Looks with the same id and a different name, price, wear slot, colour, hat or slots. */
16
16
  changed: {
17
17
  id: string;
18
18
  before: Look;
@@ -1,4 +1,4 @@
1
- import { COLOR_PATTERN, cleanSlots } from "./look.js";
1
+ import { COLOR_PATTERN, cleanSlots, wearOf } from "./look.js";
2
2
  /** Whether two looks have the same slots: the same names with the same values, in any order. No slots and empty slots are the same; a colour is the same in upper and lower case. */
3
3
  export function sameSlots(a, b) {
4
4
  const x = cleanSlots(a) ?? {};
@@ -12,9 +12,9 @@ export function sameSlots(a, b) {
12
12
  // What a change of the list does, before it goes live. Owned looks are stored by id: a look that someone owns must stay in the file.
13
13
  /**
14
14
  * The mistakes that a new list would make for the players: an id that someone owns (a paid order gave it to them) is gone from the new
15
- * list, or its slots changed (the game reads the slots of the id that a player owns, so a new slot changes what they own: add a new look instead).
16
- * `owned` is every look id that any player owns. The free look is not owned by an order: it is every player's, and its slots may change.
17
- * A change of a price, a name, a body colour or a hat on an owned look is allowed.
15
+ * list, its wear slot changed (a shovel that a player owns must stay a shovel), or its slots changed (the game reads the slots of the id that a player
16
+ * owns, so a new slot changes what they own: add a new look instead). `owned` is every look id that any player owns. The free look is not owned by an
17
+ * order: it is every player's, and its slots may change. A change of a price, a name, a body colour or a hat on an owned look is allowed.
18
18
  */
19
19
  export function removalProblems(before, after, owned) {
20
20
  const now = new Set(after.map((l) => l.id));
@@ -28,6 +28,9 @@ export function removalProblems(before, after, owned) {
28
28
  if (!kept) {
29
29
  out.push({ look: null, field: "id", message: `The look "${l.id}" (${l.name}) is owned by a player and is not in the new list. Keep it in the file; change its price if you do not want to sell it.` });
30
30
  }
31
+ else if (wearOf(l) !== wearOf(kept.look)) {
32
+ out.push({ look: kept.place, field: "wear", message: `The look "${l.id}" (${l.name}) is worn in the "${wearOf(l)}" slot, and a player owns it. Keep it in that slot, or add a new look for the "${wearOf(kept.look)}" slot.` });
33
+ }
31
34
  else if (!sameSlots(l.slots, kept.look.slots)) {
32
35
  out.push({ look: kept.place, field: "slots", message: `The slots of the look "${l.id}" (${l.name}) changed, and a player owns it. Keep its slots as they were, or add a new look with the new slots.` });
33
36
  }
@@ -48,11 +51,13 @@ export function diffLooks(before, after) {
48
51
  fields.push("name");
49
52
  if (a.priceUsd !== b.priceUsd)
50
53
  fields.push("priceUsd");
51
- if (a.change.body.toLowerCase() !== b.change.body.toLowerCase())
54
+ if (wearOf(a) !== wearOf(b))
55
+ fields.push("wear");
56
+ if ((a.change?.body ?? "").toLowerCase() !== (b.change?.body ?? "").toLowerCase())
52
57
  fields.push("body");
53
- if (a.change.hat !== b.change.hat)
58
+ if (a.change?.hat !== b.change?.hat)
54
59
  fields.push("hat");
55
- if ((a.change.hatColor ?? "").toLowerCase() !== (b.change.hatColor ?? "").toLowerCase())
60
+ if ((a.change?.hatColor ?? "").toLowerCase() !== (b.change?.hatColor ?? "").toLowerCase())
56
61
  fields.push("hatColor");
57
62
  if (!sameSlots(a.slots, b.slots))
58
63
  fields.push("slots");
@@ -1,5 +1,5 @@
1
- export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText } from "./look.js";
2
- export type { Hat, Look, Problem, Slots } from "./look.js";
1
+ export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText, wearOf, isOutfit } from "./look.js";
2
+ export type { Change, Hat, Look, Problem, Slots } from "./look.js";
3
3
  export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
4
4
  export type { Rung } from "./ladder.js";
5
5
  export { removalProblems, diffLooks, sameSlots } from "./change.js";
@@ -1,4 +1,4 @@
1
- export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText } from "./look.js";
1
+ export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText, wearOf, isOutfit } from "./look.js";
2
2
  export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
3
3
  export { removalProblems, diffLooks, sameSlots } from "./change.js";
4
4
  export { looksJson, slugId } from "./json.js";
@@ -1,7 +1,8 @@
1
1
  import { type Look } from "./look.js";
2
2
  /**
3
- * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, change (and slots, when the look has any), as the game
4
- * starter's file is written, so a change of one look is a change of one line in a diff. A look with no slots is written exactly as before. It ends with a new line.
3
+ * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, wear (an item of a wear slot other than the outfit's),
4
+ * change (an outfit) and slots (when the look has any), as the game starter's file is written, so a change of one look is a change of one line in a diff. An
5
+ * outfit with no slots is written exactly as before. It ends with a new line.
5
6
  */
6
7
  export declare function looksJson(looks: readonly Look[]): string;
7
8
  /** An id for a name: lowercase letters, digits and dashes, at most 40, and not one of `taken` ("mint-cap", then "mint-cap-2"). */
@@ -1,18 +1,25 @@
1
- import { cleanSlots, ID_PATTERN } from "./look.js";
1
+ import { cleanSlots, ID_PATTERN, isOutfit, OUTFIT, wearOf } from "./look.js";
2
2
  /**
3
- * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, change (and slots, when the look has any), as the game
4
- * starter's file is written, so a change of one look is a change of one line in a diff. A look with no slots is written exactly as before. It ends with a new line.
3
+ * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, wear (an item of a wear slot other than the outfit's),
4
+ * change (an outfit) and slots (when the look has any), as the game starter's file is written, so a change of one look is a change of one line in a diff. An
5
+ * outfit with no slots is written exactly as before. It ends with a new line.
5
6
  */
6
7
  export function looksJson(looks) {
7
8
  const row = (l) => {
8
- const change = [`"body": ${JSON.stringify(l.change.body)}`];
9
- if (l.change.hat !== undefined)
10
- change.push(`"hat": ${JSON.stringify(l.change.hat)}`);
11
- if (l.change.hatColor !== undefined)
12
- change.push(`"hatColor": ${JSON.stringify(l.change.hatColor)}`);
9
+ let middle;
10
+ if (isOutfit(l)) {
11
+ const change = [`"body": ${JSON.stringify(l.change.body)}`];
12
+ if (l.change.hat !== undefined)
13
+ change.push(`"hat": ${JSON.stringify(l.change.hat)}`);
14
+ if (l.change.hatColor !== undefined)
15
+ change.push(`"hatColor": ${JSON.stringify(l.change.hatColor)}`);
16
+ middle = `, "change": { ${change.join(", ")} }`;
17
+ }
18
+ else
19
+ middle = wearOf(l) === OUTFIT ? "" : `, "wear": ${JSON.stringify(wearOf(l))}`;
13
20
  const slots = cleanSlots(l.slots);
14
21
  const tail = slots ? `, "slots": { ${Object.keys(slots).map((k) => `${JSON.stringify(k)}: ${JSON.stringify(slots[k])}`).join(", ")} }` : "";
15
- return ` { "id": ${JSON.stringify(l.id)}, "name": ${JSON.stringify(l.name)}, "priceUsd": ${l.priceUsd}, "change": { ${change.join(", ")} }${tail} }`;
22
+ return ` { "id": ${JSON.stringify(l.id)}, "name": ${JSON.stringify(l.name)}, "priceUsd": ${l.priceUsd}${middle}${tail} }`;
16
23
  };
17
24
  return `[\n${looks.map(row).join(",\n")}\n]\n`;
18
25
  }