@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.
- package/CHANGELOG.md +32 -0
- package/README.md +161 -9
- package/ai/SPEC.md +27 -4
- 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 +3 -3
- package/dist/admin/panel/overview.d.ts +1 -1
- package/dist/admin/panel/overview.js +4 -2
- package/dist/admin/panel/shelf.d.ts +4 -1
- package/dist/admin/panel/shelf.js +84 -29
- package/dist/admin/panel/shelfData.js +1 -1
- 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/dist/looks/change.d.ts +5 -5
- package/dist/looks/change.js +12 -7
- package/dist/looks/index.d.ts +2 -2
- package/dist/looks/index.js +1 -1
- package/dist/looks/json.d.ts +3 -2
- package/dist/looks/json.js +16 -9
- package/dist/looks/look.d.ts +26 -6
- package/dist/looks/look.js +37 -12
- 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 +3 -3
- package/src/admin/panel/overview.ts +4 -3
- package/src/admin/panel/shelf.ts +135 -65
- package/src/admin/panel/shelfData.ts +1 -1
- 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
- package/src/looks/change.ts +12 -9
- package/src/looks/index.ts +2 -2
- package/src/looks/json.ts +12 -7
- package/src/looks/look.ts +47 -9
package/src/admin/panel/shelf.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { diffLooks, removalProblems } from "../../looks/change.js";
|
|
|
10
10
|
import { SLOTS_MAX } from "../../looks/look.js";
|
|
11
11
|
import { looksJson, slugId } from "../../looks/json.js";
|
|
12
12
|
import { PRICE_LADDER, priceText, rungOf, rungUsd } from "../../looks/ladder.js";
|
|
13
|
-
import { COLOR_PATTERN, HATS, lookProblems, usdText, type Hat, type Look, type Problem } from "../../looks/look.js";
|
|
13
|
+
import { COLOR_PATTERN, HATS, isOutfit, lookProblems, OUTFIT, SLOT_NAME_PATTERN, usdText, wearOf, type Change, type Hat, type Look, type Problem } from "../../looks/look.js";
|
|
14
14
|
import { copy, dollars, h, plural } from "./dom.js";
|
|
15
15
|
import { icon } from "./icons.js";
|
|
16
16
|
import { aiPrompt, changeText, ownedIds, shelfStats, soldText, type LookStats, type ShelfOrder, type ShelfPlayer } from "./shelfData.js";
|
|
@@ -38,8 +38,23 @@ function hatShapes(hat: Hat, fill: string): Element[] {
|
|
|
38
38
|
return hats[hat]();
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* A small flat picture of a look: an outfit's body in its colour and its hat in its colour; an item of another wear slot as a tag with the slot's name (the game
|
|
43
|
+
* knows what the item is). (The game draws the real thing; a game can give its own picture.)
|
|
44
|
+
*/
|
|
42
45
|
export function lookPicture(l: Look): Element {
|
|
46
|
+
if (!isOutfit(l)) {
|
|
47
|
+
const wear = wearOf(l);
|
|
48
|
+
const label = svg("text", { x: 33, y: 40, "font-size": 9, "font-family": "system-ui, sans-serif", "text-anchor": "middle", fill: INK });
|
|
49
|
+
label.textContent = wear.slice(0, 8);
|
|
50
|
+
return svg(
|
|
51
|
+
"svg",
|
|
52
|
+
{ viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: worn in the ${wear} slot` },
|
|
53
|
+
shape("path", { d: "M14 20 L36 20 L52 36 L36 52 L14 52 Z", fill: "#f6e7c1" }),
|
|
54
|
+
shape("circle", { cx: 22, cy: 36, r: 3.5, fill: "#ffffff" }),
|
|
55
|
+
label,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
43
58
|
const hat = l.change.hat;
|
|
44
59
|
return svg(
|
|
45
60
|
"svg",
|
|
@@ -72,7 +87,7 @@ export function previewPicture(load: () => Promise<(l: Look) => Promise<string |
|
|
|
72
87
|
return (l) => {
|
|
73
88
|
const box = h("span", { class: "shelf-preview" }, fallback(l));
|
|
74
89
|
ready ??= load().catch(() => null);
|
|
75
|
-
const look
|
|
90
|
+
const look = copyLook(l);
|
|
76
91
|
void ready
|
|
77
92
|
.then((render) => (render ? render(look) : null))
|
|
78
93
|
.then((src) => {
|
|
@@ -85,7 +100,9 @@ export function previewPicture(load: () => Promise<(l: Look) => Promise<string |
|
|
|
85
100
|
};
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
|
|
103
|
+
/** A copy of a look that shares nothing with it: its wear slot, an outfit's change, its slots. */
|
|
104
|
+
const copyLook = (l: Look): Look => ({ id: l.id, name: l.name, priceUsd: l.priceUsd, ...(l.wear !== undefined ? { wear: l.wear } : {}), ...(l.change ? { change: { ...l.change } } : {}), ...(l.slots ? { slots: { ...l.slots } } : {}) });
|
|
105
|
+
const clone = (ls: readonly Look[]): Look[] => ls.map(copyLook);
|
|
89
106
|
|
|
90
107
|
/** The names of the slots that differ between two looks (added, removed or another value). */
|
|
91
108
|
function slotNamesChanged(before: Look, after: Look): string {
|
|
@@ -98,7 +115,8 @@ function fieldWords(d: ReturnType<typeof diffLooks>["changed"][number]): string
|
|
|
98
115
|
if (f === "name") return `name "${d.before.name}" to "${d.after.name}"`;
|
|
99
116
|
if (f === "priceUsd") return `price ${usdText(d.before.priceUsd)} to ${usdText(d.after.priceUsd)}`;
|
|
100
117
|
if (f === "body") return "body colour";
|
|
101
|
-
if (f === "hat") return `hat ${d.before.change
|
|
118
|
+
if (f === "hat") return `hat ${d.before.change?.hat ?? "none"} to ${d.after.change?.hat ?? "none"}`;
|
|
119
|
+
if (f === "wear") return `wear slot ${wearOf(d.before)} to ${wearOf(d.after)}`;
|
|
102
120
|
if (f === "slots") return `slots changed (${slotNamesChanged(d.before, d.after)})`;
|
|
103
121
|
return "hat colour";
|
|
104
122
|
});
|
|
@@ -187,7 +205,12 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
|
|
|
187
205
|
const stats = () => shelfStats(data.orders, data.players);
|
|
188
206
|
const owned = () => ownedIds(data.players);
|
|
189
207
|
const isOriginal = (id: string) => LOOKS.some((l) => l.id === id);
|
|
190
|
-
|
|
208
|
+
/** Free outfits in the draft: one must stay (every player starts with it). */
|
|
209
|
+
const freeCount = () => draft.filter((l) => l.priceUsd === 0 && wearOf(l) === OUTFIT).length;
|
|
210
|
+
/** The wear slots that the draft uses besides the outfit, for the wear slot field's suggestions. */
|
|
211
|
+
const wearSlots = () => [...new Set(draft.map(wearOf).filter((w) => w !== OUTFIT))].sort();
|
|
212
|
+
/** What a card says under the price: who has it (a free outfit), what it sold, or that it is new. */
|
|
213
|
+
const fineWords = (l: Look, s: LookStats | undefined) => (l.priceUsd === 0 && wearOf(l) === OUTFIT ? "Every player starts with it" : soldText(s));
|
|
191
214
|
/** Whether the draft differs from the game's looks.json in any way, the order of the looks too (the file's own text is compared). */
|
|
192
215
|
const changedFromGame = () => looksJson(draft) !== looksJson(LOOKS);
|
|
193
216
|
|
|
@@ -198,7 +221,7 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
|
|
|
198
221
|
const pictures = new Map<string, Element>();
|
|
199
222
|
let used = new Set<string>();
|
|
200
223
|
function pictureOf(l: Look): Element {
|
|
201
|
-
const key = JSON.stringify([l.id, l.name, l.priceUsd, l.change, l.slots ?? null]);
|
|
224
|
+
const key = JSON.stringify([l.id, l.name, l.priceUsd, l.wear ?? null, l.change ?? null, l.slots ?? null]);
|
|
202
225
|
const kept = pictures.get(key);
|
|
203
226
|
if (kept && !used.has(key)) {
|
|
204
227
|
used.add(key);
|
|
@@ -331,10 +354,16 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
|
|
|
331
354
|
|
|
332
355
|
function stage(l: Look, ...extra: (Node | null)[]): HTMLElement {
|
|
333
356
|
const box = h("div", { class: "shelf-stage" }, h("div", { class: "shelf-pic" }, pictureOf(l)), ...extra);
|
|
334
|
-
|
|
357
|
+
const body = l.change?.body;
|
|
358
|
+
if (body && COLOR_PATTERN.test(body)) box.style.setProperty("--tint", body);
|
|
335
359
|
return box;
|
|
336
360
|
}
|
|
337
361
|
|
|
362
|
+
/** An item's wear slot, under its name ("Worn in the shovel slot"); nothing for an outfit. */
|
|
363
|
+
function wearLine(l: Look): HTMLElement | null {
|
|
364
|
+
return wearOf(l) === OUTFIT ? null : h("span", { class: "fine shelf-wear" }, `Worn in the ${wearOf(l)} slot`);
|
|
365
|
+
}
|
|
366
|
+
|
|
338
367
|
function slotList(l: Look): HTMLElement | null {
|
|
339
368
|
const entries = Object.entries(l.slots ?? {});
|
|
340
369
|
if (!entries.length) return null;
|
|
@@ -353,7 +382,8 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
|
|
|
353
382
|
"div",
|
|
354
383
|
{ class: "shelf-body" },
|
|
355
384
|
h("strong", { class: "shelf-name" }, l.name || "(no name)"),
|
|
356
|
-
|
|
385
|
+
wearLine(l),
|
|
386
|
+
h("span", { class: "fine" }, fineWords(l, s)),
|
|
357
387
|
share ? h("div", { class: "share", title: `${share}% of the money from sales` }, bar, h("span", { class: "fine" }, `${share}% of sales`)) : null,
|
|
358
388
|
slotList(l),
|
|
359
389
|
),
|
|
@@ -400,7 +430,8 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
|
|
|
400
430
|
{ class: "shelf-body" },
|
|
401
431
|
h("strong", { class: "shelf-name" }, l.name || "(no name)"),
|
|
402
432
|
h("span", { class: "shelf-price" }, priceWords(l.priceUsd)),
|
|
403
|
-
|
|
433
|
+
wearLine(l),
|
|
434
|
+
h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"),
|
|
404
435
|
marks(l, own, mine, mark),
|
|
405
436
|
),
|
|
406
437
|
h(
|
|
@@ -448,7 +479,7 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
|
|
|
448
479
|
const stageBox = h("div", { class: "shelf-stage is-big" }, pic);
|
|
449
480
|
const nameLine = h("strong", { class: "shelf-name" }, l.name || "(no name)");
|
|
450
481
|
const priceLine = h("span", { class: "shelf-price" }, priceWords(l.priceUsd));
|
|
451
|
-
const tint = () => (COLOR_PATTERN.test(l.change.body) ? stageBox.style.setProperty("--tint", l.change.body) : stageBox.style.removeProperty("--tint"));
|
|
482
|
+
const tint = () => (l.change && COLOR_PATTERN.test(l.change.body) ? stageBox.style.setProperty("--tint", l.change.body) : stageBox.style.removeProperty("--tint"));
|
|
452
483
|
tint();
|
|
453
484
|
// The real picture is drawn a moment after the last change: a colour dragged across the picker draws once, not at every step.
|
|
454
485
|
let later: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -459,8 +490,10 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
|
|
|
459
490
|
if (later) clearTimeout(later);
|
|
460
491
|
later = setTimeout(() => pic.replaceChildren(pictureOf(l)), 150);
|
|
461
492
|
ladder.querySelectorAll<HTMLButtonElement>("button").forEach((c) => c.setAttribute("aria-pressed", Number(c.dataset.usd) === l.priceUsd ? "true" : "false"));
|
|
462
|
-
|
|
463
|
-
|
|
493
|
+
if (outfit) {
|
|
494
|
+
lookParts?.swatches.querySelectorAll<HTMLButtonElement>("button").forEach((c) => c.setAttribute("aria-pressed", c.dataset.colour === outfit.change.body.toLowerCase() ? "true" : "false"));
|
|
495
|
+
lookParts?.hatChoices.style.setProperty("color", outfit.change.hatColor ?? "#9aa4b2");
|
|
496
|
+
}
|
|
464
497
|
refreshFoot();
|
|
465
498
|
};
|
|
466
499
|
|
|
@@ -486,6 +519,85 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
|
|
|
486
519
|
ladder.append(step(0, "Free", "everyone", "Every player has it"));
|
|
487
520
|
for (const r of PRICE_LADDER) ladder.append(step(rungUsd(r), priceText(rungUsd(r)), r.name, `${r.name}: ${r.for}`));
|
|
488
521
|
|
|
522
|
+
// An outfit's body colour and hat (an item of another wear slot has neither).
|
|
523
|
+
const outfit = isOutfit(l) ? l : null;
|
|
524
|
+
const lookParts = outfit ? outfitFields(outfit, i, sync) : null;
|
|
525
|
+
const lastFree = l.priceUsd === 0 && wearOf(l) === OUTFIT && freeCount() === 1;
|
|
526
|
+
const keep = isOriginal(l.id) && own.has(l.id);
|
|
527
|
+
const remove = h("button", { class: "btn btn-danger", type: "button", disabled: keep || lastFree, title: keep ? "A player owns this look. Keep it in the file." : lastFree ? "Keep one free outfit: every player starts with it." : "", onclick: () => {
|
|
528
|
+
draft = draft.filter((x) => x !== l);
|
|
529
|
+
open = null;
|
|
530
|
+
redraw();
|
|
531
|
+
} }, icon("trash", 18), "Remove");
|
|
532
|
+
const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, icon("dup", 18), "Duplicate");
|
|
533
|
+
|
|
534
|
+
const openMarks = marks(l, own, mine, mark);
|
|
535
|
+
const openProblems = h("ul", { class: "card-problems" }, ...mine.map((p) => h("li", {}, p.message)));
|
|
536
|
+
openProblems.hidden = !mine.length;
|
|
537
|
+
openParts = { marks: openMarks, problems: openProblems };
|
|
538
|
+
const group = (title: string, ...kids: (Node | null)[]) => h("div", { class: "edit-group" }, h("h3", {}, title), ...kids);
|
|
539
|
+
return h(
|
|
540
|
+
"article",
|
|
541
|
+
{ class: `shelf-card shelf-editing${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}`, tabindex: -1, "aria-label": `Editing ${l.name || "a look"}` },
|
|
542
|
+
h(
|
|
543
|
+
"div",
|
|
544
|
+
{ class: "edit-top" },
|
|
545
|
+
stageBox,
|
|
546
|
+
h(
|
|
547
|
+
"div",
|
|
548
|
+
{ class: "shelf-body" },
|
|
549
|
+
nameLine,
|
|
550
|
+
priceLine,
|
|
551
|
+
wearLine(l),
|
|
552
|
+
h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"),
|
|
553
|
+
openMarks,
|
|
554
|
+
),
|
|
555
|
+
h("button", { class: "btn btn-small card-done", type: "button", "aria-expanded": "true", onclick: () => openCard(null) }, icon("check", 16), "Done"),
|
|
556
|
+
),
|
|
557
|
+
openProblems,
|
|
558
|
+
h(
|
|
559
|
+
"div",
|
|
560
|
+
{ class: "edit-groups" },
|
|
561
|
+
group("Name", h("label", { class: "field" }, h("span", {}, "What players read"), name)),
|
|
562
|
+
group("Price", h("label", { class: "field" }, h("span", {}, "Price in US dollars"), priceInput), ladder),
|
|
563
|
+
wearEditor(l, keep || lastFree, i),
|
|
564
|
+
lookParts?.group ?? null,
|
|
565
|
+
slotsEditor(l, isOriginal(l.id) && own.has(l.id)),
|
|
566
|
+
),
|
|
567
|
+
keep ? h("p", { class: "fine owned-note" }, icon("lock", 14), "A player owns this look: its id stays, and it cannot be removed.") : null,
|
|
568
|
+
h("div", { class: "shelf-actions" }, dup, remove),
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* The wear slot of a look: "outfit" (the character's own look) or one of the game's own slots ("shovel"). Typed, with the slots the draft already uses as
|
|
574
|
+
* suggestions. Locked for a look that a player owns (it stays in its slot) and for the last free outfit. An outfit that becomes an item loses its colours; an
|
|
575
|
+
* item that becomes an outfit gets the free look's blue body.
|
|
576
|
+
*/
|
|
577
|
+
function wearEditor(l: Look, locked: boolean, i: number): HTMLElement {
|
|
578
|
+
const list = `n133-wears-${i}`;
|
|
579
|
+
const input = h("input", { class: "input", type: "text", maxlength: 24, value: wearOf(l), list, autocapitalize: "none", spellcheck: false, disabled: locked, "aria-label": `Wear slot of ${l.name}`, onchange: (e: Event) => {
|
|
580
|
+
const v = (e.target as HTMLInputElement).value.trim();
|
|
581
|
+
if (v === "" || v === OUTFIT) {
|
|
582
|
+
delete l.wear;
|
|
583
|
+
l.change ??= { body: "#4aa8ff" };
|
|
584
|
+
} else {
|
|
585
|
+
l.wear = v;
|
|
586
|
+
if (SLOT_NAME_PATTERN.test(v)) delete l.change;
|
|
587
|
+
}
|
|
588
|
+
redraw();
|
|
589
|
+
} });
|
|
590
|
+
return h(
|
|
591
|
+
"div",
|
|
592
|
+
{ class: "edit-group" },
|
|
593
|
+
h("h3", {}, "Wear slot"),
|
|
594
|
+
h("label", { class: "field" }, h("span", {}, "outfit (the character's colour and hat), or a slot of your game for an item (shovel, charm)"), input),
|
|
595
|
+
h("datalist", { id: list }, ...[OUTFIT, ...wearSlots()].map((w) => h("option", { value: w }))),
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** An outfit's fields: the body colour (swatches and a picker), the hat (pictures) and the hat colour, as the editor's "Look" group. */
|
|
600
|
+
function outfitFields(l: Look & { change: Change }, i: number, sync: () => void): { group: HTMLElement; swatches: HTMLElement; hatChoices: HTMLElement } {
|
|
489
601
|
const body = h("input", { class: "colour", type: "color", value: l.change.body, "aria-label": `Body colour of ${l.name}`, oninput: (e: Event) => {
|
|
490
602
|
l.change.body = (e.target as HTMLInputElement).value;
|
|
491
603
|
sync();
|
|
@@ -524,61 +636,19 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
|
|
|
524
636
|
hatChoices.append(h("label", { class: `hat-choice${(l.change.hat ?? null) === x ? " is-on" : ""}` }, input, hatChoicePicture(x), h("span", {}, x ? HAT_WORDS[x] : "No hat")));
|
|
525
637
|
}
|
|
526
638
|
hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
draft = draft.filter((x) => x !== l);
|
|
532
|
-
open = null;
|
|
533
|
-
redraw();
|
|
534
|
-
} }, icon("trash", 18), "Remove");
|
|
535
|
-
const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, icon("dup", 18), "Duplicate");
|
|
536
|
-
|
|
537
|
-
const openMarks = marks(l, own, mine, mark);
|
|
538
|
-
const openProblems = h("ul", { class: "card-problems" }, ...mine.map((p) => h("li", {}, p.message)));
|
|
539
|
-
openProblems.hidden = !mine.length;
|
|
540
|
-
openParts = { marks: openMarks, problems: openProblems };
|
|
541
|
-
const group = (title: string, ...kids: (Node | null)[]) => h("div", { class: "edit-group" }, h("h3", {}, title), ...kids);
|
|
542
|
-
return h(
|
|
543
|
-
"article",
|
|
544
|
-
{ class: `shelf-card shelf-editing${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}`, tabindex: -1, "aria-label": `Editing ${l.name || "a look"}` },
|
|
545
|
-
h(
|
|
546
|
-
"div",
|
|
547
|
-
{ class: "edit-top" },
|
|
548
|
-
stageBox,
|
|
549
|
-
h(
|
|
550
|
-
"div",
|
|
551
|
-
{ class: "shelf-body" },
|
|
552
|
-
nameLine,
|
|
553
|
-
priceLine,
|
|
554
|
-
h("span", { class: "fine" }, isOriginal(l.id) ? (l.priceUsd === 0 ? "Every player starts with it" : soldText(st.get(l.id))) : "New: not in your game yet"),
|
|
555
|
-
openMarks,
|
|
556
|
-
),
|
|
557
|
-
h("button", { class: "btn btn-small card-done", type: "button", "aria-expanded": "true", onclick: () => openCard(null) }, icon("check", 16), "Done"),
|
|
558
|
-
),
|
|
559
|
-
openProblems,
|
|
639
|
+
const group = h(
|
|
640
|
+
"div",
|
|
641
|
+
{ class: "edit-group group-look" },
|
|
642
|
+
h("h3", {}, "Look"),
|
|
560
643
|
h(
|
|
561
644
|
"div",
|
|
562
|
-
{ class: "
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
h(
|
|
566
|
-
"div",
|
|
567
|
-
{ class: "edit-group group-look" },
|
|
568
|
-
h("h3", {}, "Look"),
|
|
569
|
-
h(
|
|
570
|
-
"div",
|
|
571
|
-
{ class: "look-fields" },
|
|
572
|
-
h("div", { class: "field" }, h("span", {}, "Body colour"), h("div", { class: "colour-row" }, swatches, body)),
|
|
573
|
-
h("div", { class: "field" }, h("span", {}, "Hat"), hatChoices),
|
|
574
|
-
h("label", { class: "field" }, h("span", {}, "Hat colour"), hatColor),
|
|
575
|
-
),
|
|
576
|
-
),
|
|
577
|
-
slotsEditor(l, isOriginal(l.id) && own.has(l.id)),
|
|
645
|
+
{ class: "look-fields" },
|
|
646
|
+
h("div", { class: "field" }, h("span", {}, "Body colour"), h("div", { class: "colour-row" }, swatches, body)),
|
|
647
|
+
h("div", { class: "field" }, h("span", {}, "Hat"), hatChoices),
|
|
648
|
+
h("label", { class: "field" }, h("span", {}, "Hat colour"), hatColor),
|
|
578
649
|
),
|
|
579
|
-
keep ? h("p", { class: "fine owned-note" }, icon("lock", 14), "A player owns this look: its id stays, and it cannot be removed.") : null,
|
|
580
|
-
h("div", { class: "shelf-actions" }, dup, remove),
|
|
581
650
|
);
|
|
651
|
+
return { group, swatches, hatChoices };
|
|
582
652
|
}
|
|
583
653
|
|
|
584
654
|
/** The slots of a look: names and values (words or #rrggbb colours) that the game reads. Rows to type in, at most SLOTS_MAX. */
|
|
@@ -622,7 +692,7 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
|
|
|
622
692
|
|
|
623
693
|
function addLook(from?: Look) {
|
|
624
694
|
const name = from ? `${from.name} copy`.slice(0, 40) : "New look";
|
|
625
|
-
const base: Look = from ? { id: "", name
|
|
695
|
+
const base: Look = from ? { ...copyLook(from), id: "", name } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
|
|
626
696
|
// An id that a player owns is never given to a new look (even when that look is not in looks.json now): the new look would be what the owners own.
|
|
627
697
|
base.id = slugId(name, [...draft.map((x) => x.id), ...owned()]);
|
|
628
698
|
const at = from ? draft.indexOf(from) + 1 : draft.length;
|
|
@@ -76,7 +76,7 @@ export function aiPrompt(text: string, diff: LooksDiff, owned: readonly string[]
|
|
|
76
76
|
diff.changed.length ? `Changed: ${names(diff.changed.map((c) => c.after))}.` : "",
|
|
77
77
|
"",
|
|
78
78
|
`Looks that players own and that must stay in the file with the same id: ${ownedOk.length ? quote(ownedOk) : "none yet"}${odd ? ` (and ${odd} more with an id that is not a valid look id: do not remove any look)` : ""}.`,
|
|
79
|
-
`Rules: keep one free
|
|
79
|
+
`Rules: keep one free outfit (priceUsd 0, no "wear"), prices in US dollars with at most 2 decimals, never change or remove the id of a look that someone owns, never change the wear slot or the slots of a look that someone owns (a look with "wear" is an item of that slot, such as a shovel, and has no "change"; slots are words and #rrggbb colours that the game reads, at most 8 on a look), and a look never changes a score or a rule. (The file had ${before.length} looks before.)`,
|
|
80
80
|
"",
|
|
81
81
|
"looks.json:",
|
|
82
82
|
"```json",
|
|
@@ -1679,7 +1679,16 @@
|
|
|
1679
1679
|
font-size: 12px;
|
|
1680
1680
|
}
|
|
1681
1681
|
|
|
1682
|
-
/* ---- The "Game" card: two switches (gameCard.ts). Each is a button of at least 44 px ---- */
|
|
1682
|
+
/* ---- The "Game" card: two switches and the friends choice (gameCard.ts). Each is a button of at least 44 px ---- */
|
|
1683
|
+
|
|
1684
|
+
/* The friends choice: three keys in a row that wraps on a narrow phone, each 44 px tall. */
|
|
1685
|
+
.n133-admin .game-card .friends-row .switch-head {
|
|
1686
|
+
flex-wrap: wrap;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
.n133-admin .game-card .friends-choice .seg-btn {
|
|
1690
|
+
min-height: 44px;
|
|
1691
|
+
}
|
|
1683
1692
|
|
|
1684
1693
|
.n133-admin .game-card .switch-row {
|
|
1685
1694
|
display: grid;
|
package/src/admin/store.ts
CHANGED
|
@@ -48,13 +48,13 @@ export type AdminLogin = { at: number; country: string | null; browser: string }
|
|
|
48
48
|
export const ADMIN_LOGINS_SHOWN = 10;
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the
|
|
51
|
+
* 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
|
|
52
52
|
* (`signOutEverywhere`). Nothing else.
|
|
53
53
|
* (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.)
|
|
54
54
|
*/
|
|
55
55
|
export interface AdminStore {
|
|
56
56
|
/** The settings that /admin has saved. A value that is not a known word is ignored by the handler, so an adapter returns what it holds. */
|
|
57
|
-
readSettings(): Promise<{ character?: unknown; signin?: unknown }>;
|
|
57
|
+
readSettings(): Promise<{ character?: unknown; signin?: unknown; friends?: unknown }>;
|
|
58
58
|
/** Saves a checked patch: all of it or none of it. */
|
|
59
59
|
saveSettings(patch: GamePatch, now: number): Promise<void>;
|
|
60
60
|
summary(now: number): Promise<StoreSummary>;
|
package/src/admin/switches.ts
CHANGED
|
@@ -1,46 +1,72 @@
|
|
|
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
|
|
|
4
4
|
/** Which character the player is: Nanoterra's island character (the one the player has there) or the game's own. */
|
|
5
5
|
export type Character = "nanoterra" | "own";
|
|
6
6
|
/** How the game signs a Nanoterra player in: the code exchange, a signed ticket (a game with no server), or not at all. */
|
|
7
7
|
export type Signin = "exchange" | "ticket" | "none";
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* 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.
|
|
10
|
+
* A three-way choice, not an On/Off switch.
|
|
11
|
+
*/
|
|
12
|
+
export type Friends = "nanoterra" | "own" | "off";
|
|
13
|
+
/** The game's own words for the switches. `friends` may be left out: it is "nanoterra" then (on by default). */
|
|
14
|
+
export type GameSettings = { character: Character; signin: Signin; friends?: Friends };
|
|
9
15
|
/** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
|
|
10
|
-
export type GamePatch = { character?: Character; signin?: "exchange" | "none" };
|
|
16
|
+
export type GamePatch = { character?: Character; signin?: "exchange" | "none"; friends?: Friends };
|
|
11
17
|
/** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
|
|
12
|
-
export type GameState =
|
|
18
|
+
export type GameState = { character: Character; signin: Signin; friends: Friends; fromAdmin: { character: boolean; signin: boolean; friends: boolean } };
|
|
13
19
|
|
|
14
20
|
export const isCharacter = (v: unknown): v is Character => v === "nanoterra" || v === "own";
|
|
15
21
|
export const isSigninChoice = (v: unknown): v is "exchange" | "none" => v === "exchange" || v === "none";
|
|
16
22
|
export const isSignin = (v: unknown): v is Signin => v === "exchange" || v === "ticket" || v === "none";
|
|
23
|
+
export const isFriends = (v: unknown): v is Friends => v === "nanoterra" || v === "own" || v === "off";
|
|
24
|
+
/** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
|
|
25
|
+
export const DEFAULT_FRIENDS: Friends = "nanoterra";
|
|
17
26
|
/** The sign-in switch is on for any way but "none". */
|
|
18
27
|
export const signinOn = (s: Signin): boolean => s !== "none";
|
|
19
28
|
|
|
20
29
|
/** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
|
|
21
30
|
export function parsePatch(body: unknown): { ok: true; patch: GamePatch } | { ok: false; error: string } {
|
|
22
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: 'Send an object with "character" or "
|
|
31
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: 'Send an object with "character", "signin" or "friends".' };
|
|
23
32
|
const patch: GamePatch = {};
|
|
24
33
|
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
|
|
25
34
|
if (key === "character" && isCharacter(value)) patch.character = value;
|
|
26
35
|
else if (key === "signin" && isSigninChoice(value)) patch.signin = value;
|
|
36
|
+
else if (key === "friends" && isFriends(value)) patch.friends = value;
|
|
37
|
+
else if (key === "friends") return { ok: false, error: '"friends" must be "nanoterra", "own" or "off".' };
|
|
27
38
|
else if (key === "character") return { ok: false, error: '"character" must be "nanoterra" or "own".' };
|
|
28
39
|
else if (key === "signin") return { ok: false, error: '"signin" must be "exchange" or "none".' };
|
|
29
40
|
else return { ok: false, error: `"${key}" is not a game switch.` };
|
|
30
41
|
}
|
|
31
|
-
if (!Object.keys(patch).length) return { ok: false, error: 'Send "character" or "
|
|
42
|
+
if (!Object.keys(patch).length) return { ok: false, error: 'Send "character", "signin" or "friends".' };
|
|
32
43
|
return { ok: true, patch };
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
/** 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. */
|
|
36
|
-
export function mergeSettings(stored: { character?: unknown; signin?: unknown }, defaults: GameSettings): GameState {
|
|
47
|
+
export function mergeSettings(stored: { character?: unknown; signin?: unknown; friends?: unknown }, defaults: GameSettings): GameState {
|
|
37
48
|
return {
|
|
38
49
|
character: isCharacter(stored.character) ? stored.character : defaults.character,
|
|
39
50
|
signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
|
|
40
|
-
|
|
51
|
+
friends: isFriends(stored.friends) ? stored.friends : isFriends(defaults.friends) ? defaults.friends : DEFAULT_FRIENDS,
|
|
52
|
+
fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin), friends: isFriends(stored.friends) },
|
|
41
53
|
};
|
|
42
54
|
}
|
|
43
55
|
|
|
56
|
+
/** 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. */
|
|
57
|
+
export const FRIENDS_TITLE = "Friends list";
|
|
58
|
+
export const FRIENDS_CHOICES: Record<Friends, { name: string; note: string }> = {
|
|
59
|
+
nanoterra: {
|
|
60
|
+
name: "Nanoterra",
|
|
61
|
+
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.",
|
|
62
|
+
},
|
|
63
|
+
own: {
|
|
64
|
+
name: "The game's own",
|
|
65
|
+
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.",
|
|
66
|
+
},
|
|
67
|
+
off: { name: "Off", note: "Off: the game shows no friends list." },
|
|
68
|
+
};
|
|
69
|
+
|
|
44
70
|
/** One switch of the card: its title, and one plain sentence for each state. */
|
|
45
71
|
export type SwitchId = "character" | "signin";
|
|
46
72
|
export const SWITCHES: Record<SwitchId, { title: string; on: string; off: string }> = {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- The friends tables of "@nano133/nanoterra/friends/d1". Add this file to the game's migrations (for example migrations/0005_friends.sql) and run
|
|
2
|
+
-- `wrangler d1 migrations apply`. The adapter reads and writes only these three tables.
|
|
3
|
+
|
|
4
|
+
-- One row for each pair of players (a < b): a request, a friendship or a block. `edge` is the opaque id the browser sees.
|
|
5
|
+
CREATE TABLE IF NOT EXISTS friend_edges (
|
|
6
|
+
edge TEXT PRIMARY KEY,
|
|
7
|
+
a TEXT NOT NULL,
|
|
8
|
+
b TEXT NOT NULL,
|
|
9
|
+
from_player TEXT NOT NULL,
|
|
10
|
+
state TEXT NOT NULL CHECK (state IN ('pending', 'friend', 'blocked')),
|
|
11
|
+
blocked_by TEXT NOT NULL DEFAULT '[]',
|
|
12
|
+
at INTEGER NOT NULL,
|
|
13
|
+
invite_from TEXT,
|
|
14
|
+
invite_where TEXT,
|
|
15
|
+
invite_at INTEGER,
|
|
16
|
+
rev INTEGER NOT NULL DEFAULT 1,
|
|
17
|
+
UNIQUE (a, b)
|
|
18
|
+
);
|
|
19
|
+
CREATE INDEX IF NOT EXISTS friend_edges_b ON friend_edges (b);
|
|
20
|
+
|
|
21
|
+
-- Where a player is now, and which tab said so. A row older than two minutes does not count.
|
|
22
|
+
CREATE TABLE IF NOT EXISTS friend_presence (
|
|
23
|
+
player TEXT PRIMARY KEY,
|
|
24
|
+
place TEXT NOT NULL,
|
|
25
|
+
lease TEXT NOT NULL,
|
|
26
|
+
at INTEGER NOT NULL
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
-- A player's "show me online" choice (1 or 0). No row: shown.
|
|
30
|
+
CREATE TABLE IF NOT EXISTS friend_prefs (
|
|
31
|
+
player TEXT PRIMARY KEY,
|
|
32
|
+
show_online INTEGER NOT NULL DEFAULT 1
|
|
33
|
+
);
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
|
|
11
|
+
import type { D1Like } from "../../admin/adapters/d1.js";
|
|
12
|
+
import { buildRows, checkPulse, pairOf, planAccept, planBlock, planDecline, planInvite, planRemove, planRequest, planUnblock, shownOthers, type EdgeRec, type Plan, type PresenceRec } from "../rules.js";
|
|
13
|
+
import { FRIEND_LIMIT, FRIEND_REQUEST_MS, FriendsError, type FriendView, type FriendsStore, type PlayerDirectory } from "../types.js";
|
|
14
|
+
|
|
15
|
+
export type { D1Like, D1StatementLike } from "../../admin/adapters/d1.js";
|
|
16
|
+
|
|
17
|
+
type Row = { edge: string; a: string; b: string; from_player: string; state: string; blocked_by: string; at: number; invite_from: string | null; invite_where: string | null; invite_at: number | null; rev: number };
|
|
18
|
+
|
|
19
|
+
const COLS = "edge, a, b, from_player, state, blocked_by, at, invite_from, invite_where, invite_at, rev";
|
|
20
|
+
|
|
21
|
+
function recOf(r: Row): EdgeRec {
|
|
22
|
+
let blockedBy: string[] = [];
|
|
23
|
+
try {
|
|
24
|
+
const v: unknown = JSON.parse(r.blocked_by);
|
|
25
|
+
if (Array.isArray(v)) blockedBy = v.filter((x): x is string => typeof x === "string");
|
|
26
|
+
} catch {
|
|
27
|
+
blockedBy = [];
|
|
28
|
+
}
|
|
29
|
+
const state = r.state === "friend" || r.state === "blocked" ? r.state : "pending";
|
|
30
|
+
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;
|
|
31
|
+
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) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CHANGED = "That changed just now. Try again.";
|
|
35
|
+
|
|
36
|
+
export function createD1Friends(db: D1Like): FriendsStore {
|
|
37
|
+
const changes = (res: unknown): number => Number((res as { meta?: { changes?: number } } | undefined)?.meta?.changes ?? 0);
|
|
38
|
+
const byEdge = async (edge: string) => {
|
|
39
|
+
const r = await db.prepare(`SELECT ${COLS} FROM friend_edges WHERE edge = ?`).bind(edge).first<Row>();
|
|
40
|
+
return r ? recOf(r) : null;
|
|
41
|
+
};
|
|
42
|
+
const byPair = async (x: string, y: string) => {
|
|
43
|
+
const [a, b] = pairOf(x, y);
|
|
44
|
+
const r = await db.prepare(`SELECT ${COLS} FROM friend_edges WHERE a = ? AND b = ?`).bind(a, b).first<Row>();
|
|
45
|
+
return r ? recOf(r) : null;
|
|
46
|
+
};
|
|
47
|
+
/** Live edges of a player, as SQL (?1 the player, ?2 the oldest request time that still counts). */
|
|
48
|
+
const LIVE_COUNT = "(SELECT COUNT(*) FROM friend_edges WHERE (a = ?1 OR b = ?1) AND (state != 'pending' OR at > ?2))";
|
|
49
|
+
const liveCount = async (player: string, now: number) => Number((await db.prepare(`SELECT ${LIVE_COUNT} AS n`).bind(player, now - FRIEND_REQUEST_MS).first<{ n: number }>())?.n ?? 0);
|
|
50
|
+
|
|
51
|
+
async function apply(plan: Plan, prev: EdgeRec | null, now: number): Promise<void> {
|
|
52
|
+
if ("none" in plan) return;
|
|
53
|
+
if ("drop" in plan) {
|
|
54
|
+
if (changes(await db.prepare("DELETE FROM friend_edges WHERE edge = ? AND rev = ?").bind(plan.drop.edge, plan.drop.rev).run()) !== 1) throw new FriendsError(409, CHANGED);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const r = plan.put;
|
|
58
|
+
const inv = r.invite;
|
|
59
|
+
if (prev && prev.edge === r.edge) {
|
|
60
|
+
const res = await db
|
|
61
|
+
.prepare("UPDATE friend_edges SET from_player = ?, state = ?, blocked_by = ?, at = ?, invite_from = ?, invite_where = ?, invite_at = ?, rev = ? WHERE edge = ? AND rev = ?")
|
|
62
|
+
.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)
|
|
63
|
+
.run();
|
|
64
|
+
if (changes(res) !== 1) throw new FriendsError(409, CHANGED);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// A new edge: only while both players have room, in the same statement (the pair's UNIQUE (a, b) refuses a second row).
|
|
68
|
+
const cut = now - FRIEND_REQUEST_MS;
|
|
69
|
+
const other = r.from === r.a ? r.b : r.a;
|
|
70
|
+
const sql =
|
|
71
|
+
`INSERT INTO friend_edges (${COLS}) SELECT ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, NULL, NULL, ?10 ` +
|
|
72
|
+
`WHERE (SELECT COUNT(*) FROM friend_edges WHERE (a = ?1 OR b = ?1) AND (state != 'pending' OR at > ?11)) < ?12 ` +
|
|
73
|
+
`AND (SELECT COUNT(*) FROM friend_edges WHERE (a = ?2 OR b = ?2) AND (state != 'pending' OR at > ?11)) < ?12 ` +
|
|
74
|
+
`ON CONFLICT (a, b) DO NOTHING`;
|
|
75
|
+
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();
|
|
76
|
+
if (changes(res) !== 1) throw new FriendsError(409, CHANGED);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function onEdge(_player: string, edge: string, plan: (rec: EdgeRec | null) => Plan, now: number) {
|
|
80
|
+
const rec = await byEdge(edge);
|
|
81
|
+
await apply(plan(rec), rec, now);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
async view(player: string, directory: PlayerDirectory, now: number): Promise<FriendView> {
|
|
86
|
+
const recs = (await db.prepare(`SELECT ${COLS} FROM friend_edges WHERE a = ?1 OR b = ?1`).bind(player).all<Row>()).results.map(recOf);
|
|
87
|
+
// 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).
|
|
88
|
+
const OTHER = "CASE WHEN e.a = ?1 THEN e.b ELSE e.a END";
|
|
89
|
+
const pres = (
|
|
90
|
+
await db
|
|
91
|
+
.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'`)
|
|
92
|
+
.bind(player)
|
|
93
|
+
.all<{ player: string; place: string; lease: string; at: number }>()
|
|
94
|
+
).results;
|
|
95
|
+
const hidden = (
|
|
96
|
+
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<{ player: string }>()
|
|
97
|
+
).results;
|
|
98
|
+
const me = await db.prepare("SELECT show_online FROM friend_prefs WHERE player = ?").bind(player).first<{ show_online: number }>();
|
|
99
|
+
const names = await directory.names(shownOthers(player, recs, now));
|
|
100
|
+
const presence = new Map<string, PresenceRec>(pres.map((p) => [p.player, { player: p.player, where: p.place, lease: p.lease, at: Number(p.at) }]));
|
|
101
|
+
const shows = new Map<string, boolean>(hidden.map((x) => [x.player, false]));
|
|
102
|
+
return { showOnline: !me || Number(me.show_online) !== 0, people: buildRows(player, recs, now, names, presence, shows) };
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async request(player, other, now) {
|
|
106
|
+
const rec = await byPair(player, other);
|
|
107
|
+
const plan = await planRequest(player, other, rec, now, async () => ({ mine: await liveCount(player, now), theirs: await liveCount(other, now) }));
|
|
108
|
+
// A request that went stale is still the pair's row: it is changed in place (its count is the one just read).
|
|
109
|
+
await apply(plan, rec, now);
|
|
110
|
+
},
|
|
111
|
+
accept: (player, edge, now) => onEdge(player, edge, (r) => planAccept(player, r, now), now),
|
|
112
|
+
decline: (player, edge) => onEdge(player, edge, (r) => planDecline(player, r, Date.now()), Date.now()),
|
|
113
|
+
remove: (player, edge) => onEdge(player, edge, (r) => planRemove(player, r, Date.now()), Date.now()),
|
|
114
|
+
block: (player, edge, now) => onEdge(player, edge, (r) => planBlock(player, r, now), now),
|
|
115
|
+
unblock: (player, edge, now) => onEdge(player, edge, (r) => planUnblock(player, r, now), now),
|
|
116
|
+
invite: (player, edge, where, now) => onEdge(player, edge, (r) => planInvite(player, r, where, now), now),
|
|
117
|
+
|
|
118
|
+
async pulse(player, where, lease, now) {
|
|
119
|
+
const p = checkPulse(where, lease);
|
|
120
|
+
if (p.where !== null) {
|
|
121
|
+
await db
|
|
122
|
+
.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")
|
|
123
|
+
.bind(player, p.where, p.lease, now)
|
|
124
|
+
.run();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Leaving: only the tab that holds the row may clear it (planPulse's rule, in the statement).
|
|
128
|
+
await db.prepare("DELETE FROM friend_presence WHERE player = ? AND lease = ?").bind(player, p.lease).run();
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async setShowOnline(player, on) {
|
|
132
|
+
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)];
|
|
133
|
+
if (!on) stmts.push(db.prepare("DELETE FROM friend_presence WHERE player = ?").bind(player));
|
|
134
|
+
await db.batch(stmts);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async forget(player) {
|
|
138
|
+
const done = await db.batch([
|
|
139
|
+
db.prepare("DELETE FROM friend_edges WHERE a = ?1 OR b = ?1").bind(player),
|
|
140
|
+
db.prepare("DELETE FROM friend_presence WHERE player = ?").bind(player),
|
|
141
|
+
db.prepare("DELETE FROM friend_prefs WHERE player = ?").bind(player),
|
|
142
|
+
]);
|
|
143
|
+
return done.reduce<number>((n, r) => n + changes(r), 0);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|