@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,131 @@
1
+ // The friends store for a game on Cloudflare D1. The tables are in ./d1.sql (the package ships it: "@nano133/nanoterra/friends/d1.sql"); add it to
2
+ // the game's migrations. It reads and writes ONLY friend_edges, friend_presence and friend_prefs.
3
+ //
4
+ // import { createD1Friends } from "@nano133/nanoterra/friends/d1";
5
+ // const friends = createD1Friends(env.DB);
6
+ //
7
+ // D1 has no transaction that reads and then writes, so each write is ONE statement that holds its own check: a change of an edge says which `rev`
8
+ // it read (a row that changed in between is not written, and the player is asked to try again), and a new edge is written only while both players
9
+ // have room (the count is in the same statement) and the pair has no row yet (UNIQUE (a, b)).
10
+ import { buildRows, checkPulse, pairOf, planAccept, planBlock, planDecline, planInvite, planRemove, planRequest, planUnblock, shownOthers } from "../rules.js";
11
+ import { FRIEND_LIMIT, FRIEND_REQUEST_MS, FriendsError } from "../types.js";
12
+ const COLS = "edge, a, b, from_player, state, blocked_by, at, invite_from, invite_where, invite_at, rev";
13
+ function recOf(r) {
14
+ let blockedBy = [];
15
+ try {
16
+ const v = JSON.parse(r.blocked_by);
17
+ if (Array.isArray(v))
18
+ blockedBy = v.filter((x) => typeof x === "string");
19
+ }
20
+ catch {
21
+ blockedBy = [];
22
+ }
23
+ const state = r.state === "friend" || r.state === "blocked" ? r.state : "pending";
24
+ const invite = typeof r.invite_from === "string" && typeof r.invite_where === "string" && typeof r.invite_at === "number" ? { from: r.invite_from, where: r.invite_where, at: Number(r.invite_at) } : null;
25
+ return { edge: r.edge, a: r.a, b: r.b, from: r.from_player, state, blockedBy, at: Number(r.at), invite, rev: Number(r.rev) };
26
+ }
27
+ const CHANGED = "That changed just now. Try again.";
28
+ export function createD1Friends(db) {
29
+ const changes = (res) => Number(res?.meta?.changes ?? 0);
30
+ const byEdge = async (edge) => {
31
+ const r = await db.prepare(`SELECT ${COLS} FROM friend_edges WHERE edge = ?`).bind(edge).first();
32
+ return r ? recOf(r) : null;
33
+ };
34
+ const byPair = async (x, y) => {
35
+ const [a, b] = pairOf(x, y);
36
+ const r = await db.prepare(`SELECT ${COLS} FROM friend_edges WHERE a = ? AND b = ?`).bind(a, b).first();
37
+ return r ? recOf(r) : null;
38
+ };
39
+ /** Live edges of a player, as SQL (?1 the player, ?2 the oldest request time that still counts). */
40
+ const LIVE_COUNT = "(SELECT COUNT(*) FROM friend_edges WHERE (a = ?1 OR b = ?1) AND (state != 'pending' OR at > ?2))";
41
+ const liveCount = async (player, now) => Number((await db.prepare(`SELECT ${LIVE_COUNT} AS n`).bind(player, now - FRIEND_REQUEST_MS).first())?.n ?? 0);
42
+ async function apply(plan, prev, now) {
43
+ if ("none" in plan)
44
+ return;
45
+ if ("drop" in plan) {
46
+ if (changes(await db.prepare("DELETE FROM friend_edges WHERE edge = ? AND rev = ?").bind(plan.drop.edge, plan.drop.rev).run()) !== 1)
47
+ throw new FriendsError(409, CHANGED);
48
+ return;
49
+ }
50
+ const r = plan.put;
51
+ const inv = r.invite;
52
+ if (prev && prev.edge === r.edge) {
53
+ const res = await db
54
+ .prepare("UPDATE friend_edges SET from_player = ?, state = ?, blocked_by = ?, at = ?, invite_from = ?, invite_where = ?, invite_at = ?, rev = ? WHERE edge = ? AND rev = ?")
55
+ .bind(r.from, r.state, JSON.stringify(r.blockedBy), r.at, inv?.from ?? null, inv?.where ?? null, inv?.at ?? null, r.rev, r.edge, prev.rev)
56
+ .run();
57
+ if (changes(res) !== 1)
58
+ throw new FriendsError(409, CHANGED);
59
+ return;
60
+ }
61
+ // A new edge: only while both players have room, in the same statement (the pair's UNIQUE (a, b) refuses a second row).
62
+ const cut = now - FRIEND_REQUEST_MS;
63
+ const other = r.from === r.a ? r.b : r.a;
64
+ const sql = `INSERT INTO friend_edges (${COLS}) SELECT ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, NULL, NULL, ?10 ` +
65
+ `WHERE (SELECT COUNT(*) FROM friend_edges WHERE (a = ?1 OR b = ?1) AND (state != 'pending' OR at > ?11)) < ?12 ` +
66
+ `AND (SELECT COUNT(*) FROM friend_edges WHERE (a = ?2 OR b = ?2) AND (state != 'pending' OR at > ?11)) < ?12 ` +
67
+ `ON CONFLICT (a, b) DO NOTHING`;
68
+ const res = await db.prepare(sql).bind(r.from, other, r.edge, r.a, r.b, r.from, r.state, JSON.stringify(r.blockedBy), r.at, r.rev, cut, FRIEND_LIMIT).run();
69
+ if (changes(res) !== 1)
70
+ throw new FriendsError(409, CHANGED);
71
+ }
72
+ async function onEdge(_player, edge, plan, now) {
73
+ const rec = await byEdge(edge);
74
+ await apply(plan(rec), rec, now);
75
+ }
76
+ return {
77
+ async view(player, directory, now) {
78
+ const recs = (await db.prepare(`SELECT ${COLS} FROM friend_edges WHERE a = ?1 OR b = ?1`).bind(player).all()).results.map(recOf);
79
+ // The presence and the choice of the player's friends, in one statement each (a friend's id is the other side of a friend edge).
80
+ const OTHER = "CASE WHEN e.a = ?1 THEN e.b ELSE e.a END";
81
+ const pres = (await db
82
+ .prepare(`SELECT p.player, p.place, p.lease, p.at FROM friend_edges e JOIN friend_presence p ON p.player = ${OTHER} WHERE (e.a = ?1 OR e.b = ?1) AND e.state = 'friend'`)
83
+ .bind(player)
84
+ .all()).results;
85
+ const hidden = (await db.prepare(`SELECT f.player FROM friend_edges e JOIN friend_prefs f ON f.player = ${OTHER} WHERE (e.a = ?1 OR e.b = ?1) AND e.state = 'friend' AND f.show_online = 0`).bind(player).all()).results;
86
+ const me = await db.prepare("SELECT show_online FROM friend_prefs WHERE player = ?").bind(player).first();
87
+ const names = await directory.names(shownOthers(player, recs, now));
88
+ const presence = new Map(pres.map((p) => [p.player, { player: p.player, where: p.place, lease: p.lease, at: Number(p.at) }]));
89
+ const shows = new Map(hidden.map((x) => [x.player, false]));
90
+ return { showOnline: !me || Number(me.show_online) !== 0, people: buildRows(player, recs, now, names, presence, shows) };
91
+ },
92
+ async request(player, other, now) {
93
+ const rec = await byPair(player, other);
94
+ const plan = await planRequest(player, other, rec, now, async () => ({ mine: await liveCount(player, now), theirs: await liveCount(other, now) }));
95
+ // A request that went stale is still the pair's row: it is changed in place (its count is the one just read).
96
+ await apply(plan, rec, now);
97
+ },
98
+ accept: (player, edge, now) => onEdge(player, edge, (r) => planAccept(player, r, now), now),
99
+ decline: (player, edge) => onEdge(player, edge, (r) => planDecline(player, r, Date.now()), Date.now()),
100
+ remove: (player, edge) => onEdge(player, edge, (r) => planRemove(player, r, Date.now()), Date.now()),
101
+ block: (player, edge, now) => onEdge(player, edge, (r) => planBlock(player, r, now), now),
102
+ unblock: (player, edge, now) => onEdge(player, edge, (r) => planUnblock(player, r, now), now),
103
+ invite: (player, edge, where, now) => onEdge(player, edge, (r) => planInvite(player, r, where, now), now),
104
+ async pulse(player, where, lease, now) {
105
+ const p = checkPulse(where, lease);
106
+ if (p.where !== null) {
107
+ await db
108
+ .prepare("INSERT INTO friend_presence (player, place, lease, at) VALUES (?, ?, ?, ?) ON CONFLICT (player) DO UPDATE SET place = excluded.place, lease = excluded.lease, at = excluded.at")
109
+ .bind(player, p.where, p.lease, now)
110
+ .run();
111
+ return;
112
+ }
113
+ // Leaving: only the tab that holds the row may clear it (planPulse's rule, in the statement).
114
+ await db.prepare("DELETE FROM friend_presence WHERE player = ? AND lease = ?").bind(player, p.lease).run();
115
+ },
116
+ async setShowOnline(player, on) {
117
+ const stmts = [db.prepare("INSERT INTO friend_prefs (player, show_online) VALUES (?, ?) ON CONFLICT (player) DO UPDATE SET show_online = excluded.show_online").bind(player, on ? 1 : 0)];
118
+ if (!on)
119
+ stmts.push(db.prepare("DELETE FROM friend_presence WHERE player = ?").bind(player));
120
+ await db.batch(stmts);
121
+ },
122
+ async forget(player) {
123
+ const done = await db.batch([
124
+ db.prepare("DELETE FROM friend_edges WHERE a = ?1 OR b = ?1").bind(player),
125
+ db.prepare("DELETE FROM friend_presence WHERE player = ?").bind(player),
126
+ db.prepare("DELETE FROM friend_prefs WHERE player = ?").bind(player),
127
+ ]);
128
+ return done.reduce((n, r) => n + changes(r), 0);
129
+ },
130
+ };
131
+ }
@@ -0,0 +1,49 @@
1
+ import { type FriendsStore } from "../types.js";
2
+ export type FriendsDocSnap = {
3
+ exists: boolean;
4
+ data(): Record<string, unknown> | undefined;
5
+ };
6
+ export interface FriendsDocRef {
7
+ get(): Promise<FriendsDocSnap>;
8
+ }
9
+ export type FriendsQuerySnap = {
10
+ docs: {
11
+ id: string;
12
+ ref: FriendsDocRef;
13
+ data(): Record<string, unknown>;
14
+ }[];
15
+ };
16
+ export interface FriendsQuery {
17
+ where(field: string, op: "==" | "array-contains", value: unknown): FriendsQuery;
18
+ limit(n: number): FriendsQuery;
19
+ get(): Promise<FriendsQuerySnap>;
20
+ }
21
+ export interface FriendsCollection extends FriendsQuery {
22
+ doc(id: string): FriendsDocRef;
23
+ }
24
+ /** A transaction: every read before the first write, as Firestore asks. */
25
+ export interface FriendsTransaction {
26
+ get(ref: FriendsDocRef): Promise<FriendsDocSnap>;
27
+ get(query: FriendsQuery): Promise<FriendsQuerySnap>;
28
+ set(ref: FriendsDocRef, data: Record<string, unknown>): unknown;
29
+ delete(ref: FriendsDocRef): unknown;
30
+ }
31
+ export interface FriendsBatch {
32
+ set(ref: FriendsDocRef, data: Record<string, unknown>, options?: {
33
+ merge?: boolean;
34
+ }): unknown;
35
+ delete(ref: FriendsDocRef): unknown;
36
+ commit(): Promise<unknown>;
37
+ }
38
+ export interface FirestoreFriendsLike {
39
+ collection(name: string): FriendsCollection;
40
+ runTransaction<T>(fn: (tx: FriendsTransaction) => Promise<T>): Promise<T>;
41
+ batch(): FriendsBatch;
42
+ }
43
+ /** How many edges of one player a read takes at most: the limit, the blocks by others and old requests fit under it. */
44
+ export declare const FIRESTORE_FRIENDS_SCAN = 1000;
45
+ export declare function createFirestoreFriends(db: FirestoreFriendsLike, collections?: {
46
+ edges?: string;
47
+ presence?: string;
48
+ prefs?: string;
49
+ }): FriendsStore;
@@ -0,0 +1,163 @@
1
+ // The friends store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface below,
2
+ // which the Admin SDK's `Firestore` fits. It reads and writes ONLY its three collections.
3
+ //
4
+ // import { createFirestoreFriends } from "@nano133/nanoterra/friends/firestore";
5
+ // const friends = createFirestoreFriends(getFirestore());
6
+ //
7
+ // The data model (the three collection names can be changed):
8
+ // friend_edges/{pair} { edge, a, b, players: [a, b], from, state, blockedBy, at, invite: { from, where, at } | null, rev }
9
+ // friend_presence/{player} { player, where, lease, at }
10
+ // friend_prefs/{player} { player, showOnline }
11
+ // `{pair}` and `{player}` are SHA-256 hex digests (of "a\u0000b" and of the player's id), so any id makes a valid document id, and a pair has one
12
+ // document: two requests at the same moment meet on it in their transactions. `edge` is a random id; the browser sees only that.
13
+ // Indexes: the single-field indexes Firestore makes by itself are enough (`players` array-contains, `edge` ==).
14
+ import { buildRows, checkPulse, friendsOf, hasPlayer, liveEdge, pairOf, planAccept, planBlock, planDecline, planInvite, planPulse, planRemove, planRequest, planUnblock, shownOthers } from "../rules.js";
15
+ import { FriendsError } from "../types.js";
16
+ /** How many edges of one player a read takes at most: the limit, the blocks by others and old requests fit under it. */
17
+ export const FIRESTORE_FRIENDS_SCAN = 1000;
18
+ async function sha256(text) {
19
+ const bytes = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
20
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
21
+ }
22
+ const text = (v, fallback = "") => (typeof v === "string" ? v : fallback);
23
+ const num = (v, fallback = 0) => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
24
+ function recOf(d) {
25
+ const inv = d.invite;
26
+ const state = d.state === "friend" || d.state === "blocked" ? d.state : "pending";
27
+ return {
28
+ edge: text(d.edge),
29
+ a: text(d.a),
30
+ b: text(d.b),
31
+ from: text(d.from),
32
+ state,
33
+ blockedBy: Array.isArray(d.blockedBy) ? d.blockedBy.filter((x) => typeof x === "string") : [],
34
+ at: num(d.at),
35
+ invite: inv && typeof inv.from === "string" && typeof inv.where === "string" && typeof inv.at === "number" ? { from: inv.from, where: inv.where, at: inv.at } : null,
36
+ rev: num(d.rev),
37
+ };
38
+ }
39
+ const dataOf = (r) => ({ edge: r.edge, a: r.a, b: r.b, players: [r.a, r.b], from: r.from, state: r.state, blockedBy: r.blockedBy, at: r.at, invite: r.invite, rev: r.rev });
40
+ export function createFirestoreFriends(db, collections = {}) {
41
+ const edges = () => db.collection(collections.edges ?? "friend_edges");
42
+ const presenceCol = () => db.collection(collections.presence ?? "friend_presence");
43
+ const prefsCol = () => db.collection(collections.prefs ?? "friend_prefs");
44
+ const pairRef = async (x, y) => {
45
+ const [a, b] = pairOf(x, y);
46
+ return edges().doc(await sha256(`${a}\u0000${b}`));
47
+ };
48
+ const playerRef = async (col, player) => col.doc(await sha256(player));
49
+ const ofPlayer = (player) => edges().where("players", "array-contains", player).limit(FIRESTORE_FRIENDS_SCAN);
50
+ /** Writes a plan in the transaction, on the pair's document. */
51
+ async function write(tx, plan) {
52
+ if ("none" in plan)
53
+ return;
54
+ const rec = "put" in plan ? plan.put : plan.drop;
55
+ const ref = await pairRef(rec.a, rec.b);
56
+ if ("put" in plan)
57
+ tx.set(ref, dataOf(plan.put));
58
+ else
59
+ tx.delete(ref);
60
+ }
61
+ /** An operation on an edge the browser named: the edge is found, then read again in the transaction on its pair's document. */
62
+ async function onEdge(player, edge, plan) {
63
+ const found = await edges().where("edge", "==", edge).limit(2).get();
64
+ const first = found.docs.map((d) => recOf(d.data())).find((r) => hasPlayer(r, player));
65
+ if (!first) {
66
+ plan(null);
67
+ return;
68
+ }
69
+ const ref = await pairRef(first.a, first.b);
70
+ await db.runTransaction(async (tx) => {
71
+ const snap = await tx.get(ref);
72
+ const now = snap.exists ? recOf(snap.data() ?? {}) : null;
73
+ // The pair's document holds another edge now (it was removed and asked again): the old edge is gone.
74
+ await write(tx, plan(now && now.edge === edge ? now : null));
75
+ });
76
+ }
77
+ const liveOf = (snap, now) => snap.docs.map((d) => recOf(d.data())).filter((r) => liveEdge(r, now)).length;
78
+ return {
79
+ async view(player, directory, now) {
80
+ const recs = (await ofPlayer(player).get()).docs.map((d) => recOf(d.data())).filter((r) => hasPlayer(r, player));
81
+ const friends = friendsOf(player, recs, now);
82
+ const [mePref, pres, prefs, names] = await Promise.all([
83
+ (await playerRef(prefsCol(), player)).get(),
84
+ Promise.all(friends.map(async (f) => (await playerRef(presenceCol(), f)).get())),
85
+ Promise.all(friends.map(async (f) => (await playerRef(prefsCol(), f)).get())),
86
+ directory.names(shownOthers(player, recs, now)),
87
+ ]);
88
+ const presence = new Map();
89
+ pres.forEach((s, i) => {
90
+ const d = s.exists ? s.data() : undefined;
91
+ if (d && d.player === friends[i])
92
+ presence.set(friends[i], { player: friends[i], where: text(d.where), lease: text(d.lease), at: num(d.at) });
93
+ });
94
+ const shows = new Map();
95
+ prefs.forEach((s, i) => {
96
+ const d = s.exists ? s.data() : undefined;
97
+ if (d && d.player === friends[i] && d.showOnline === false)
98
+ shows.set(friends[i], false);
99
+ });
100
+ const mine = mePref.exists ? mePref.data() : undefined;
101
+ return { showOnline: mine?.showOnline !== false, people: buildRows(player, recs, now, names, presence, shows) };
102
+ },
103
+ async request(player, other, now) {
104
+ if (player === other)
105
+ throw new FriendsError(400, "You cannot add yourself.");
106
+ const ref = await pairRef(player, other);
107
+ await db.runTransaction(async (tx) => {
108
+ const snap = await tx.get(ref);
109
+ const rec = snap.exists ? recOf(snap.data() ?? {}) : null;
110
+ // The counts are read in the transaction too (every read before the first write).
111
+ const plan = await planRequest(player, other, rec, now, async () => {
112
+ const [mine, theirs] = await Promise.all([tx.get(ofPlayer(player)), tx.get(ofPlayer(other))]);
113
+ return { mine: liveOf(mine, now), theirs: liveOf(theirs, now) };
114
+ });
115
+ await write(tx, plan);
116
+ });
117
+ },
118
+ accept: (player, edge, now) => onEdge(player, edge, (r) => planAccept(player, r, now)),
119
+ decline: (player, edge) => onEdge(player, edge, (r) => planDecline(player, r, Date.now())),
120
+ remove: (player, edge) => onEdge(player, edge, (r) => planRemove(player, r, Date.now())),
121
+ block: (player, edge, now) => onEdge(player, edge, (r) => planBlock(player, r, now)),
122
+ unblock: (player, edge, now) => onEdge(player, edge, (r) => planUnblock(player, r, now)),
123
+ invite: (player, edge, where, now) => onEdge(player, edge, (r) => planInvite(player, r, where, now)),
124
+ async pulse(player, where, lease, now) {
125
+ const p = checkPulse(where, lease);
126
+ const ref = await playerRef(presenceCol(), player);
127
+ await db.runTransaction(async (tx) => {
128
+ const snap = await tx.get(ref);
129
+ const d = snap.exists ? snap.data() : undefined;
130
+ const was = d ? { player, where: text(d.where), lease: text(d.lease), at: num(d.at) } : null;
131
+ const plan = planPulse(player, p.where, p.lease, was, now);
132
+ if ("put" in plan)
133
+ tx.set(ref, { player, where: plan.put.where, lease: plan.put.lease, at: plan.put.at });
134
+ else if ("drop" in plan)
135
+ tx.delete(ref);
136
+ });
137
+ },
138
+ async setShowOnline(player, on) {
139
+ const batch = db.batch();
140
+ batch.set(await playerRef(prefsCol(), player), { player, showOnline: Boolean(on) });
141
+ if (!on)
142
+ batch.delete(await playerRef(presenceCol(), player));
143
+ await batch.commit();
144
+ },
145
+ async forget(player) {
146
+ const docs = (await ofPlayer(player).get()).docs.filter((d) => hasPlayer(recOf(d.data()), player));
147
+ const [pres, pref] = await Promise.all([(await playerRef(presenceCol(), player)).get(), (await playerRef(prefsCol(), player)).get()]);
148
+ const refs = docs.map((d) => d.ref);
149
+ if (pres.exists)
150
+ refs.push(await playerRef(presenceCol(), player));
151
+ if (pref.exists)
152
+ refs.push(await playerRef(prefsCol(), player));
153
+ // A batch holds at most 500 writes.
154
+ for (let i = 0; i < refs.length; i += 400) {
155
+ const batch = db.batch();
156
+ for (const ref of refs.slice(i, i + 400))
157
+ batch.delete(ref);
158
+ await batch.commit();
159
+ }
160
+ return refs.length;
161
+ },
162
+ };
163
+ }
@@ -0,0 +1,11 @@
1
+ import { type FriendsStore } from "./types.js";
2
+ export type FriendsHarness = {
3
+ store: FriendsStore;
4
+ /** Every row the store keeps (edges, presence, prefs) as one text. `forget` must leave none that holds the player's id. */
5
+ dump(): Promise<string>;
6
+ };
7
+ export type FriendsConformanceCase = {
8
+ name: string;
9
+ run: () => Promise<void>;
10
+ };
11
+ export declare function friendsStoreConformance(make: () => Promise<FriendsHarness>): FriendsConformanceCase[];
@@ -0,0 +1,229 @@
1
+ // The conformance suite of the friends rules: a game that writes its own store (another database) runs it, and so proves that its store keeps
2
+ // every rule the package's adapters keep. The package runs it on the memory store, D1 (on SQLite) and Firestore.
3
+ //
4
+ // import { friendsStoreConformance } from "@nano133/nanoterra/friends/conformance";
5
+ // for (const c of friendsStoreConformance(async () => ({ store: myStore(), rows: () => countMyRows() }))) it(c.name, c.run);
6
+ //
7
+ // Each case gets a NEW, empty store from `make`. The suite names players with ids that it can find in a text (so it can prove that no view holds one)
8
+ // and gives the store its own directory.
9
+ import { FRIEND_INVITE_MS, FRIEND_LIMIT, FRIEND_LIVE_MS, FRIEND_REQUEST_MS, FriendsError } from "./types.js";
10
+ function check(ok, message) {
11
+ if (!ok)
12
+ throw new Error(message);
13
+ }
14
+ /** A player id the suite can find in any text. */
15
+ const id = (n) => `player-id-${n}-7f3e`;
16
+ const NAMES = {};
17
+ const directory = {
18
+ async byName(name) {
19
+ return Object.entries(NAMES).find(([, v]) => v === name)?.[0] ?? null;
20
+ },
21
+ async names(ids) {
22
+ return new Map(ids.filter((i) => NAMES[i]).map((i) => [i, NAMES[i]]));
23
+ },
24
+ };
25
+ for (const n of ["ann", "bob", "cyd", "dee"])
26
+ NAMES[id(n)] = n[0].toUpperCase() + n.slice(1);
27
+ const ANN = id("ann");
28
+ const BOB = id("bob");
29
+ const CYD = id("cyd");
30
+ async function rejects(p, status, what) {
31
+ try {
32
+ await p;
33
+ }
34
+ catch (e) {
35
+ check(e instanceof FriendsError, `${what}: expected a FriendsError, got ${String(e)}`);
36
+ check(e.status === status, `${what}: expected ${status}, got ${e.status} (${e.message})`);
37
+ return;
38
+ }
39
+ throw new Error(`${what}: expected a refusal`);
40
+ }
41
+ export function friendsStoreConformance(make) {
42
+ const cases = [];
43
+ const add = (name, run) => cases.push({ name, run: async () => run(await make(), Date.now()) });
44
+ const view = (h, who, now) => h.store.view(who, directory, now);
45
+ const row = async (h, who, name, now) => (await view(h, who, now)).people.find((r) => r.name === name);
46
+ const friends = async (h, x, y, now) => {
47
+ await h.store.request(x, y, now);
48
+ await h.store.request(y, x, now + 1);
49
+ };
50
+ add("a request shows as outgoing to the sender and incoming to the other, with the other's name and no id", async (h, now) => {
51
+ await h.store.request(ANN, BOB, now);
52
+ const a = await row(h, ANN, "Bob", now);
53
+ const b = await row(h, BOB, "Ann", now);
54
+ check(a?.state === "outgoing" && b?.state === "incoming", `states: ${JSON.stringify([a, b])}`);
55
+ check(a.edge === b.edge && a.edge.length >= 8, "the two rows name the same edge");
56
+ check(!a.online && a.where === null && a.invite === null, "a request is not online and has no invite");
57
+ });
58
+ add("a request when the other already asked is an accept: both are friends, on one edge", async (h, now) => {
59
+ await friends(h, ANN, BOB, now);
60
+ const a = (await view(h, ANN, now + 2)).people;
61
+ const b = (await view(h, BOB, now + 2)).people;
62
+ check(a.length === 1 && b.length === 1 && a[0].state === "friend" && b[0].state === "friend", `views: ${JSON.stringify([a, b])}`);
63
+ });
64
+ add("a request twice, or to a friend, changes nothing: one edge for the pair", async (h, now) => {
65
+ await h.store.request(ANN, BOB, now);
66
+ await h.store.request(ANN, BOB, now + 1);
67
+ check((await view(h, ANN, now + 2)).people.length === 1, "one row after two requests");
68
+ await h.store.request(BOB, ANN, now + 3);
69
+ await h.store.request(ANN, BOB, now + 4);
70
+ const v = await view(h, ANN, now + 5);
71
+ check(v.people.length === 1 && v.people[0].state === "friend", `still one friend row: ${JSON.stringify(v)}`);
72
+ });
73
+ add("a player cannot ask themself", async (h, now) => {
74
+ await rejects(h.store.request(ANN, ANN, now), 400, "a request to self");
75
+ });
76
+ add("accept: only the player who was asked can accept; then both are friends", async (h, now) => {
77
+ await h.store.request(ANN, BOB, now);
78
+ const edge = (await row(h, ANN, "Bob", now)).edge;
79
+ await rejects(h.store.accept(ANN, edge, now + 1), 409, "the sender accepts their own request");
80
+ await h.store.accept(BOB, edge, now + 2);
81
+ check((await row(h, ANN, "Bob", now + 3))?.state === "friend", "Ann sees a friend");
82
+ });
83
+ add("decline takes the request away for both; the sender may also take it back", async (h, now) => {
84
+ await h.store.request(ANN, BOB, now);
85
+ let edge = (await row(h, BOB, "Ann", now)).edge;
86
+ await h.store.decline(BOB, edge);
87
+ check((await view(h, ANN, now + 1)).people.length === 0 && (await view(h, BOB, now + 1)).people.length === 0, "no rows after a decline");
88
+ await h.store.request(ANN, BOB, now + 2);
89
+ edge = (await row(h, ANN, "Bob", now + 2)).edge;
90
+ await h.store.decline(ANN, edge);
91
+ check((await view(h, BOB, now + 3)).people.length === 0, "no rows after the sender takes it back");
92
+ });
93
+ add("remove ends a friendship for both", async (h, now) => {
94
+ await friends(h, ANN, BOB, now);
95
+ await h.store.remove(BOB, (await row(h, BOB, "Ann", now + 2)).edge);
96
+ check((await view(h, ANN, now + 3)).people.length === 0 && (await view(h, BOB, now + 3)).people.length === 0, "no rows after remove");
97
+ });
98
+ add("a block wins: the blocker sees a blocked row, the other sees nothing, and neither can ask again", async (h, now) => {
99
+ await friends(h, ANN, BOB, now);
100
+ await h.store.block(ANN, (await row(h, ANN, "Bob", now + 2)).edge, now + 3);
101
+ check((await row(h, ANN, "Bob", now + 4))?.state === "blocked", "Ann sees Bob as blocked");
102
+ check((await view(h, BOB, now + 4)).people.length === 0, "Bob does not see Ann at all");
103
+ await rejects(h.store.request(BOB, ANN, now + 5), 409, "a request by the blocked player");
104
+ await rejects(h.store.request(ANN, BOB, now + 5), 409, "a request by the blocker");
105
+ });
106
+ add("the blocked player cannot use the old edge for anything", async (h, now) => {
107
+ await friends(h, ANN, BOB, now);
108
+ const edge = (await row(h, BOB, "Ann", now + 2)).edge;
109
+ await h.store.block(ANN, edge, now + 3);
110
+ await rejects(h.store.accept(BOB, edge, now + 4), 404, "accept on a blocked edge");
111
+ await rejects(h.store.invite(BOB, edge, "Room 1", now + 4), 404, "invite on a blocked edge");
112
+ await rejects(h.store.remove(BOB, edge), 404, "remove on a blocked edge");
113
+ await rejects(h.store.unblock(BOB, edge, now + 4), 404, "unblock by the one who did not block");
114
+ check((await row(h, ANN, "Bob", now + 5))?.state === "blocked", "the block stands");
115
+ });
116
+ add("unblock leaves no edge (not friends again); only the player who blocked can unblock", async (h, now) => {
117
+ await friends(h, ANN, BOB, now);
118
+ const edge = (await row(h, ANN, "Bob", now + 2)).edge;
119
+ await h.store.block(ANN, edge, now + 3);
120
+ await h.store.unblock(ANN, edge, now + 4);
121
+ check((await view(h, ANN, now + 5)).people.length === 0 && (await view(h, BOB, now + 5)).people.length === 0, "no edge after unblock");
122
+ // Bob blocks this time: Ann sees nothing, so she cannot unblock his block.
123
+ await friends(h, ANN, BOB, now + 11);
124
+ const e3 = (await row(h, BOB, "Ann", now + 13)).edge;
125
+ await h.store.block(BOB, e3, now + 14);
126
+ check((await view(h, ANN, now + 15)).people.length === 0, "Ann does not see Bob's block");
127
+ await rejects(h.store.unblock(ANN, e3, now + 16), 404, "Ann cannot unblock Bob's block");
128
+ await h.store.unblock(BOB, e3, now + 17);
129
+ check((await view(h, ANN, now + 18)).people.length === 0 && (await view(h, BOB, now + 18)).people.length === 0, "nothing left after Bob unblocks");
130
+ });
131
+ add(`a full list (${FRIEND_LIMIT} live rows) refuses a new request, both ways`, async (h, now) => {
132
+ for (let i = 0; i < FRIEND_LIMIT; i++) {
133
+ const other = id(`fan${i}`);
134
+ NAMES[other] = `Fan${i}`;
135
+ await h.store.request(other, ANN, now + i);
136
+ }
137
+ await rejects(h.store.request(ANN, BOB, now + FRIEND_LIMIT), 409, "Ann asks with a full list");
138
+ await rejects(h.store.request(CYD, ANN, now + FRIEND_LIMIT), 409, "Cyd asks Ann, whose list is full");
139
+ // An answer to one of the requests is not a new row: it is allowed.
140
+ const edge = (await row(h, ANN, "Fan0", now + FRIEND_LIMIT)).edge;
141
+ await h.store.accept(ANN, edge, now + FRIEND_LIMIT + 1);
142
+ });
143
+ add("a request nobody answers goes after its time: not shown, not counted, and it may be sent again", async (h, now) => {
144
+ await h.store.request(ANN, BOB, now);
145
+ const later = now + FRIEND_REQUEST_MS + 1;
146
+ check((await view(h, ANN, later)).people.length === 0 && (await view(h, BOB, later)).people.length === 0, "the old request is gone");
147
+ await h.store.request(BOB, ANN, later);
148
+ const v = await view(h, ANN, later + 1);
149
+ check(v.people.length === 1 && v.people[0].state === "incoming", `a new request after the old one went: ${JSON.stringify(v)}`);
150
+ });
151
+ add("an invite works only between friends; the other sees it for its time, the sender does not", async (h, now) => {
152
+ await h.store.request(ANN, BOB, now);
153
+ const edge = (await row(h, ANN, "Bob", now)).edge;
154
+ await rejects(h.store.invite(ANN, edge, "Room 1", now + 1), 409, "an invite to a request");
155
+ await h.store.accept(BOB, edge, now + 2);
156
+ await h.store.invite(ANN, edge, "Room 1", now + 3);
157
+ const b = await row(h, BOB, "Ann", now + 4);
158
+ check(b?.invite?.where === "Room 1" && b.invite.at === now + 3, `Bob sees the invite: ${JSON.stringify(b)}`);
159
+ check((await row(h, ANN, "Bob", now + 4))?.invite === null, "Ann does not see her own invite");
160
+ check((await row(h, BOB, "Ann", now + 3 + FRIEND_INVITE_MS))?.invite === null, "the invite is gone after its time");
161
+ });
162
+ add("an invite's place is plain text: control characters go and it is cut to 64 characters; an empty one is refused", async (h, now) => {
163
+ await friends(h, ANN, BOB, now);
164
+ const edge = (await row(h, ANN, "Bob", now + 2)).edge;
165
+ await h.store.invite(ANN, edge, ` Lev\u0000el\n 3 ${"x".repeat(100)}`, now + 3);
166
+ const w = (await row(h, BOB, "Ann", now + 4)).invite.where;
167
+ check(!/[\u0000-\u001f]/.test(w) && Array.from(w).length <= 64 && w.startsWith("Level 3"), `the place: ${JSON.stringify(w)}`);
168
+ await rejects(h.store.invite(ANN, edge, " \n\t ", now + 5), 400, "an empty place");
169
+ });
170
+ add("online only within its time, only to friends, and only while the friend shows online", async (h, now) => {
171
+ await h.store.request(ANN, CYD, now);
172
+ await friends(h, ANN, BOB, now);
173
+ await h.store.pulse(BOB, "Room 2", "lease-bob-1", now + 2);
174
+ await h.store.pulse(CYD, "Room 3", "lease-cyd-1", now + 2);
175
+ const b = await row(h, ANN, "Bob", now + 3);
176
+ check(b?.online === true && b.where === "Room 2", `Bob is online: ${JSON.stringify(b)}`);
177
+ const c = await row(h, ANN, "Cyd", now + 3);
178
+ check(c?.online === false && c.where === null, "a player who is only asked is not shown online");
179
+ check((await row(h, ANN, "Bob", now + 2 + FRIEND_LIVE_MS))?.online === false, "a pulse is old after its time");
180
+ await h.store.pulse(BOB, "Room 2", "lease-bob-1", now + 4);
181
+ await h.store.setShowOnline(BOB, false);
182
+ const hidden = await row(h, ANN, "Bob", now + 5);
183
+ check(hidden?.online === false && hidden.where === null, "a friend who hides is not online");
184
+ check((await view(h, BOB, now + 5)).showOnline === false && (await view(h, ANN, now + 5)).showOnline === true, "each player's own choice");
185
+ await h.store.setShowOnline(BOB, true);
186
+ check((await row(h, ANN, "Bob", now + 6))?.online === false, "hiding cleared the old pulse");
187
+ });
188
+ add("presence: a tab that leaves clears only its own pulse, so an old tab cannot clear a new one", async (h, now) => {
189
+ await friends(h, ANN, BOB, now);
190
+ await h.store.pulse(BOB, "Room 1", "lease-old-tab", now + 2);
191
+ await h.store.pulse(BOB, "Room 2", "lease-new-tab", now + 3);
192
+ await h.store.pulse(BOB, null, "lease-old-tab", now + 4);
193
+ check((await row(h, ANN, "Bob", now + 5))?.where === "Room 2", "the new tab's place stands");
194
+ await h.store.pulse(BOB, null, "lease-new-tab", now + 6);
195
+ check((await row(h, ANN, "Bob", now + 7))?.online === false, "the new tab left");
196
+ await rejects(h.store.pulse(BOB, "Room 1", "short", now + 8), 400, "a lease that is too short");
197
+ });
198
+ add("a player cannot use an edge of two other players", async (h, now) => {
199
+ await friends(h, ANN, BOB, now);
200
+ const edge = (await row(h, ANN, "Bob", now + 2)).edge;
201
+ await rejects(h.store.remove(CYD, edge), 404, "remove");
202
+ await rejects(h.store.block(CYD, edge, now + 3), 404, "block");
203
+ await rejects(h.store.invite(CYD, edge, "Room", now + 3), 404, "invite");
204
+ await rejects(h.store.accept(CYD, "no-such-edge-1", now + 3), 404, "an edge that does not exist");
205
+ });
206
+ add("no view holds a player id", async (h, now) => {
207
+ await friends(h, ANN, BOB, now);
208
+ await h.store.request(CYD, ANN, now + 2);
209
+ await h.store.pulse(BOB, "Room 9", "lease-bob-9", now + 3);
210
+ await h.store.invite(BOB, (await row(h, BOB, "Ann", now + 3)).edge, "Room 9", now + 4);
211
+ for (const who of [ANN, BOB, CYD]) {
212
+ const text = JSON.stringify(await view(h, who, now + 5));
213
+ check(!/player-id-/.test(text), `a view holds an id: ${text}`);
214
+ }
215
+ });
216
+ add("forget leaves nothing of the player: no edge, no presence, no choice; the others' lists lose them", async (h, now) => {
217
+ await friends(h, ANN, BOB, now);
218
+ await h.store.request(CYD, ANN, now + 2);
219
+ await h.store.pulse(ANN, "Room", "lease-ann-1", now + 3);
220
+ await h.store.setShowOnline(ANN, false);
221
+ await h.store.pulse(BOB, "Room", "lease-bob-1", now + 3);
222
+ const n = await h.store.forget(ANN);
223
+ check(n >= 3, `forget said ${n}`);
224
+ check(!(await h.dump()).includes(ANN), "a row still holds the player's id");
225
+ check((await view(h, BOB, now + 4)).people.length === 0 && (await view(h, CYD, now + 4)).people.length === 0, "the others no longer see the player");
226
+ check((await h.dump()).includes(BOB), "the other players' own rows stay");
227
+ });
228
+ return cases;
229
+ }
@@ -0,0 +1,31 @@
1
+ import { type FriendsStore, type PlayerDirectory } from "./types.js";
2
+ export type FriendsOp = "request" | "accept" | "decline" | "remove" | "block" | "unblock" | "invite" | "pulse" | "visibility";
3
+ export type FriendsHandlerOptions = {
4
+ store: FriendsStore;
5
+ directory: PlayerDirectory;
6
+ /** The game's signed-in player of this request (its own id), or null. */
7
+ player(request: Request): Promise<string | null>;
8
+ /** Where the routes are. Default "/friends". */
9
+ base?: string;
10
+ /** A limiter: false means "too many". Default: a fixed window in memory, 30 calls a minute for each player. */
11
+ limit?: (key: string, limit: number, windowMs: number, now: number) => boolean | Promise<boolean>;
12
+ /** The time now in ms. Default Date.now. */
13
+ now?: () => number;
14
+ /** Told about a fault in the store or the directory. The player gets a plain 500 and never sees it. */
15
+ onError?: (e: unknown) => void;
16
+ };
17
+ export declare const FRIENDS_LIMITS: {
18
+ /** Calls of one player (reads and writes together). */
19
+ readonly calls: {
20
+ readonly limit: 30;
21
+ readonly windowMs: 60000;
22
+ };
23
+ /** The biggest body of a POST. */
24
+ readonly bodyMax: 1024;
25
+ /** The longest name a request may send. */
26
+ readonly nameMax: 64;
27
+ };
28
+ export declare function createFriendsHandler(options: FriendsHandlerOptions): {
29
+ base: string;
30
+ handle(request: Request): Promise<Response | null>;
31
+ };