@nano133/nanoterra 0.5.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.
- package/CHANGELOG.md +20 -0
- package/README.md +133 -8
- package/ai/SPEC.md +25 -3
- package/dist/admin/adapters/d1.d.ts +1 -0
- package/dist/admin/adapters/d1.js +6 -4
- package/dist/admin/adapters/firestore.d.ts +1 -0
- package/dist/admin/adapters/firestore.js +7 -5
- package/dist/admin/conformance.d.ts +1 -1
- package/dist/admin/conformance.js +13 -5
- package/dist/admin/handler.d.ts +1 -1
- package/dist/admin/handler.js +3 -3
- package/dist/admin/index.d.ts +2 -2
- package/dist/admin/index.js +2 -2
- package/dist/admin/memory.js +3 -1
- package/dist/admin/panel/gameCard.d.ts +1 -1
- package/dist/admin/panel/gameCard.js +56 -6
- package/dist/admin/panel/mount.js +1 -1
- package/dist/admin/panel/overview.d.ts +1 -1
- package/dist/admin/panel/overview.js +4 -2
- package/dist/admin/store.d.ts +2 -1
- package/dist/admin/switches.d.ts +23 -1
- package/dist/admin/switches.js +26 -5
- package/dist/friends/adapters/d1.d.ts +4 -0
- package/dist/friends/adapters/d1.js +131 -0
- package/dist/friends/adapters/firestore.d.ts +49 -0
- package/dist/friends/adapters/firestore.js +163 -0
- package/dist/friends/conformance.d.ts +11 -0
- package/dist/friends/conformance.js +229 -0
- package/dist/friends/handler.d.ts +31 -0
- package/dist/friends/handler.js +139 -0
- package/dist/friends/index.d.ts +8 -0
- package/dist/friends/index.js +12 -0
- package/dist/friends/memory.d.ts +9 -0
- package/dist/friends/memory.js +79 -0
- package/dist/friends/panel/index.d.ts +4 -0
- package/dist/friends/panel/index.js +10 -0
- package/dist/friends/panel/mount.d.ts +38 -0
- package/dist/friends/panel/mount.js +321 -0
- package/dist/friends/panel/window.d.ts +52 -0
- package/dist/friends/panel/window.js +72 -0
- package/dist/friends/rules.d.ts +92 -0
- package/dist/friends/rules.js +180 -0
- package/dist/friends/types.d.ts +58 -0
- package/dist/friends/types.js +24 -0
- package/package.json +11 -4
- package/src/admin/adapters/d1.ts +5 -4
- package/src/admin/adapters/firestore.ts +6 -5
- package/src/admin/conformance.ts +15 -6
- package/src/admin/handler.ts +3 -3
- package/src/admin/index.ts +3 -3
- package/src/admin/memory.ts +2 -1
- package/src/admin/panel/gameCard.ts +52 -6
- package/src/admin/panel/mount.ts +1 -1
- package/src/admin/panel/overview.ts +4 -3
- package/src/admin/panel/style.css +10 -1
- package/src/admin/store.ts +2 -2
- package/src/admin/switches.ts +35 -9
- package/src/friends/adapters/d1.sql +33 -0
- package/src/friends/adapters/d1.ts +146 -0
- package/src/friends/adapters/firestore.ts +197 -0
- package/src/friends/conformance.ts +260 -0
- package/src/friends/handler.ts +144 -0
- package/src/friends/index.ts +17 -0
- package/src/friends/memory.ts +83 -0
- package/src/friends/panel/index.ts +13 -0
- package/src/friends/panel/mount.ts +346 -0
- package/src/friends/panel/style.css +303 -0
- package/src/friends/panel/window.ts +79 -0
- package/src/friends/rules.ts +196 -0
- package/src/friends/types.ts +57 -0
|
@@ -28,7 +28,7 @@ export function mountAdmin(root, options) {
|
|
|
28
28
|
let game = null;
|
|
29
29
|
let gameState = null;
|
|
30
30
|
let dash = null;
|
|
31
|
-
/** The card, made once. A fault in reading the
|
|
31
|
+
/** The card, made once. A fault in reading the game switches must not hide the orders: the card is left out and the rest still shows. */
|
|
32
32
|
async function gameSection() {
|
|
33
33
|
if (game)
|
|
34
34
|
return game;
|
|
@@ -16,7 +16,7 @@ export declare function bestSellers(s: Pick<AdminSummary, "orders" | "players">,
|
|
|
16
16
|
export type OverviewView = {
|
|
17
17
|
el: HTMLElement;
|
|
18
18
|
update(s: AdminSummary): void;
|
|
19
|
-
/** The
|
|
19
|
+
/** The game switches as they are now (null: they could not be read). */
|
|
20
20
|
setGame(state: GameState | null): void;
|
|
21
21
|
};
|
|
22
22
|
export declare function overviewView(looks: readonly Look[], colourOf: (lookId: string) => string | null, go: (tab: TabId, filter?: OrderFilter) => void): OverviewView;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The Overview tab of /admin: the game at a glance. The best-selling looks with their share of the money, the last five orders, the two game switches as On or
|
|
2
2
|
// Off, and the shelf in numbers; each part has a key to its own tab. A game with no sale yet gets three first steps instead of empty lists.
|
|
3
3
|
import { priceText } from "../../looks/ladder.js";
|
|
4
|
-
import { SWITCHES, stateOf } from "../switches.js";
|
|
4
|
+
import { FRIENDS_CHOICES, FRIENDS_TITLE, SWITCHES, stateOf } from "../switches.js";
|
|
5
5
|
import { dollars, h, plural } from "./dom.js";
|
|
6
6
|
import { icon } from "./icons.js";
|
|
7
7
|
import { orderRow } from "./orders.js";
|
|
@@ -35,7 +35,9 @@ export function overviewView(looks, colourOf, go) {
|
|
|
35
35
|
const on = game ? stateOf(id, game) : null;
|
|
36
36
|
return h("li", {}, h("span", {}, SWITCHES[id].title), on === null ? h("span", { class: "pill" }, "Unknown") : h("span", { class: `pill ${on ? "pill-on" : "pill-off"}` }, on ? "On" : "Off"));
|
|
37
37
|
});
|
|
38
|
-
|
|
38
|
+
if (game)
|
|
39
|
+
rows.push(h("li", {}, h("span", {}, FRIENDS_TITLE), h("span", { class: `pill ${game.friends === "off" ? "pill-off" : "pill-on"}` }, FRIENDS_CHOICES[game.friends]?.name ?? "Unknown")));
|
|
40
|
+
gameBox.replaceChildren(h("div", { class: "card-head" }, h("h2", { id: "n133-glance-game" }, "Game switches"), linkKey("Change", () => go("settings"))), game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The game switches could not be read. Reload the page to try again."));
|
|
39
41
|
}
|
|
40
42
|
function drawShelf() {
|
|
41
43
|
const sale = looks.filter((l) => l.priceUsd > 0);
|
package/dist/admin/store.d.ts
CHANGED
|
@@ -71,7 +71,7 @@ export type AdminLogin = {
|
|
|
71
71
|
/** How many sign-ins the panel lists. */
|
|
72
72
|
export declare const ADMIN_LOGINS_SHOWN = 10;
|
|
73
73
|
/**
|
|
74
|
-
* The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the
|
|
74
|
+
* The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the three settings and, when the game turns it on, the deletion of the admin sessions
|
|
75
75
|
* (`signOutEverywhere`). Nothing else.
|
|
76
76
|
* (There is no call that writes a look, a price or an order: the Shelf gives text to copy, and a change of looks is a change of the repository.)
|
|
77
77
|
*/
|
|
@@ -80,6 +80,7 @@ export interface AdminStore {
|
|
|
80
80
|
readSettings(): Promise<{
|
|
81
81
|
character?: unknown;
|
|
82
82
|
signin?: unknown;
|
|
83
|
+
friends?: unknown;
|
|
83
84
|
}>;
|
|
84
85
|
/** Saves a checked patch: all of it or none of it. */
|
|
85
86
|
saveSettings(patch: GamePatch, now: number): Promise<void>;
|
package/dist/admin/switches.d.ts
CHANGED
|
@@ -2,25 +2,40 @@
|
|
|
2
2
|
export type Character = "nanoterra" | "own";
|
|
3
3
|
/** How the game signs a Nanoterra player in: the code exchange, a signed ticket (a game with no server), or not at all. */
|
|
4
4
|
export type Signin = "exchange" | "ticket" | "none";
|
|
5
|
+
/**
|
|
6
|
+
* Which friends list the game shows: Nanoterra's (the nano133 window, "@nano133/nanoterra/friends/panel"), the game's own (its own players and store), or none.
|
|
7
|
+
* A three-way choice, not an On/Off switch.
|
|
8
|
+
*/
|
|
9
|
+
export type Friends = "nanoterra" | "own" | "off";
|
|
10
|
+
/** The game's own words for the switches. `friends` may be left out: it is "nanoterra" then (on by default). */
|
|
5
11
|
export type GameSettings = {
|
|
6
12
|
character: Character;
|
|
7
13
|
signin: Signin;
|
|
14
|
+
friends?: Friends;
|
|
8
15
|
};
|
|
9
16
|
/** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
|
|
10
17
|
export type GamePatch = {
|
|
11
18
|
character?: Character;
|
|
12
19
|
signin?: "exchange" | "none";
|
|
20
|
+
friends?: Friends;
|
|
13
21
|
};
|
|
14
22
|
/** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
|
|
15
|
-
export type GameState =
|
|
23
|
+
export type GameState = {
|
|
24
|
+
character: Character;
|
|
25
|
+
signin: Signin;
|
|
26
|
+
friends: Friends;
|
|
16
27
|
fromAdmin: {
|
|
17
28
|
character: boolean;
|
|
18
29
|
signin: boolean;
|
|
30
|
+
friends: boolean;
|
|
19
31
|
};
|
|
20
32
|
};
|
|
21
33
|
export declare const isCharacter: (v: unknown) => v is Character;
|
|
22
34
|
export declare const isSigninChoice: (v: unknown) => v is "exchange" | "none";
|
|
23
35
|
export declare const isSignin: (v: unknown) => v is Signin;
|
|
36
|
+
export declare const isFriends: (v: unknown) => v is Friends;
|
|
37
|
+
/** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
|
|
38
|
+
export declare const DEFAULT_FRIENDS: Friends;
|
|
24
39
|
/** The sign-in switch is on for any way but "none". */
|
|
25
40
|
export declare const signinOn: (s: Signin) => boolean;
|
|
26
41
|
/** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
|
|
@@ -35,7 +50,14 @@ export declare function parsePatch(body: unknown): {
|
|
|
35
50
|
export declare function mergeSettings(stored: {
|
|
36
51
|
character?: unknown;
|
|
37
52
|
signin?: unknown;
|
|
53
|
+
friends?: unknown;
|
|
38
54
|
}, defaults: GameSettings): GameState;
|
|
55
|
+
/** The friends choice of the card: its title, and for each choice its short name (the card's three keys) and one plain sentence. Nanoterra's is the default. */
|
|
56
|
+
export declare const FRIENDS_TITLE = "Friends list";
|
|
57
|
+
export declare const FRIENDS_CHOICES: Record<Friends, {
|
|
58
|
+
name: string;
|
|
59
|
+
note: string;
|
|
60
|
+
}>;
|
|
39
61
|
/** One switch of the card: its title, and one plain sentence for each state. */
|
|
40
62
|
export type SwitchId = "character" | "signin";
|
|
41
63
|
export declare const SWITCHES: Record<SwitchId, {
|
package/dist/admin/switches.js
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// draws the card from `SWITCHES`. A switch changes ONLY these
|
|
1
|
+
// The switches of the "Game" card in /admin, and the words that say what each does. The handler checks a press with `parsePatch`; the panel
|
|
2
|
+
// draws the card from `SWITCHES` and `FRIENDS_CHOICES`. A switch changes ONLY these three settings; never a look, a price or an order.
|
|
3
3
|
export const isCharacter = (v) => v === "nanoterra" || v === "own";
|
|
4
4
|
export const isSigninChoice = (v) => v === "exchange" || v === "none";
|
|
5
5
|
export const isSignin = (v) => v === "exchange" || v === "ticket" || v === "none";
|
|
6
|
+
export const isFriends = (v) => v === "nanoterra" || v === "own" || v === "off";
|
|
7
|
+
/** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
|
|
8
|
+
export const DEFAULT_FRIENDS = "nanoterra";
|
|
6
9
|
/** The sign-in switch is on for any way but "none". */
|
|
7
10
|
export const signinOn = (s) => s !== "none";
|
|
8
11
|
/** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
|
|
9
12
|
export function parsePatch(body) {
|
|
10
13
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
11
|
-
return { ok: false, error: 'Send an object with "character" or "
|
|
14
|
+
return { ok: false, error: 'Send an object with "character", "signin" or "friends".' };
|
|
12
15
|
const patch = {};
|
|
13
16
|
for (const [key, value] of Object.entries(body)) {
|
|
14
17
|
if (key === "character" && isCharacter(value))
|
|
15
18
|
patch.character = value;
|
|
16
19
|
else if (key === "signin" && isSigninChoice(value))
|
|
17
20
|
patch.signin = value;
|
|
21
|
+
else if (key === "friends" && isFriends(value))
|
|
22
|
+
patch.friends = value;
|
|
23
|
+
else if (key === "friends")
|
|
24
|
+
return { ok: false, error: '"friends" must be "nanoterra", "own" or "off".' };
|
|
18
25
|
else if (key === "character")
|
|
19
26
|
return { ok: false, error: '"character" must be "nanoterra" or "own".' };
|
|
20
27
|
else if (key === "signin")
|
|
@@ -23,7 +30,7 @@ export function parsePatch(body) {
|
|
|
23
30
|
return { ok: false, error: `"${key}" is not a game switch.` };
|
|
24
31
|
}
|
|
25
32
|
if (!Object.keys(patch).length)
|
|
26
|
-
return { ok: false, error: 'Send "character" or "
|
|
33
|
+
return { ok: false, error: 'Send "character", "signin" or "friends".' };
|
|
27
34
|
return { ok: true, patch };
|
|
28
35
|
}
|
|
29
36
|
/** The settings from what the store kept and the game's defaults. A stored value that is not one of the known words is ignored. */
|
|
@@ -31,9 +38,23 @@ export function mergeSettings(stored, defaults) {
|
|
|
31
38
|
return {
|
|
32
39
|
character: isCharacter(stored.character) ? stored.character : defaults.character,
|
|
33
40
|
signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
|
|
34
|
-
|
|
41
|
+
friends: isFriends(stored.friends) ? stored.friends : isFriends(defaults.friends) ? defaults.friends : DEFAULT_FRIENDS,
|
|
42
|
+
fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin), friends: isFriends(stored.friends) },
|
|
35
43
|
};
|
|
36
44
|
}
|
|
45
|
+
/** The friends choice of the card: its title, and for each choice its short name (the card's three keys) and one plain sentence. Nanoterra's is the default. */
|
|
46
|
+
export const FRIENDS_TITLE = "Friends list";
|
|
47
|
+
export const FRIENDS_CHOICES = {
|
|
48
|
+
nanoterra: {
|
|
49
|
+
name: "Nanoterra",
|
|
50
|
+
note: "Nanoterra: a player signed in on nano133 sees their Nanoterra friends in a small nano133 frame in your game, and their friends see that they are playing it. The friends stay on nano133: your game gets none of them.",
|
|
51
|
+
},
|
|
52
|
+
own: {
|
|
53
|
+
name: "The game's own",
|
|
54
|
+
note: "The game's own: a friends list of this game's players, kept in this game's own database. Nanoterra friends do not show.",
|
|
55
|
+
},
|
|
56
|
+
off: { name: "Off", note: "Off: the game shows no friends list." },
|
|
57
|
+
};
|
|
37
58
|
export const SWITCHES = {
|
|
38
59
|
character: {
|
|
39
60
|
title: "Players can play as their Nanoterra character",
|
|
@@ -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[];
|