@nano133/nanoterra 0.1.0 → 0.2.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 (77) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +189 -2
  3. package/ai/SPEC.md +2 -1
  4. package/dist/admin/adapters/d1.d.ts +27 -0
  5. package/dist/admin/adapters/d1.js +73 -0
  6. package/dist/admin/adapters/firestore.d.ts +40 -0
  7. package/dist/admin/adapters/firestore.js +53 -0
  8. package/dist/admin/amount.d.ts +5 -0
  9. package/dist/admin/amount.js +11 -0
  10. package/dist/admin/conformance.d.ts +132 -0
  11. package/dist/admin/conformance.js +467 -0
  12. package/dist/admin/guard.d.ts +38 -0
  13. package/dist/admin/guard.js +57 -0
  14. package/dist/admin/handler.d.ts +63 -0
  15. package/dist/admin/handler.js +170 -0
  16. package/dist/admin/http.d.ts +17 -0
  17. package/dist/admin/http.js +76 -0
  18. package/dist/admin/index.d.ts +13 -0
  19. package/dist/admin/index.js +14 -0
  20. package/dist/admin/memory.d.ts +32 -0
  21. package/dist/admin/memory.js +40 -0
  22. package/dist/admin/panel/api.d.ts +11 -0
  23. package/dist/admin/panel/api.js +33 -0
  24. package/dist/admin/panel/dom.d.ts +7 -0
  25. package/dist/admin/panel/dom.js +33 -0
  26. package/dist/admin/panel/gameCard.d.ts +4 -0
  27. package/dist/admin/panel/gameCard.js +56 -0
  28. package/dist/admin/panel/index.d.ts +10 -0
  29. package/dist/admin/panel/index.js +13 -0
  30. package/dist/admin/panel/mount.d.ts +30 -0
  31. package/dist/admin/panel/mount.js +147 -0
  32. package/dist/admin/panel/shelf.d.ts +17 -0
  33. package/dist/admin/panel/shelf.js +247 -0
  34. package/dist/admin/panel/shelfData.d.ts +37 -0
  35. package/dist/admin/panel/shelfData.js +68 -0
  36. package/dist/admin/panel/signins.d.ts +6 -0
  37. package/dist/admin/panel/signins.js +69 -0
  38. package/dist/admin/store.d.ts +121 -0
  39. package/dist/admin/store.js +3 -0
  40. package/dist/admin/summary.d.ts +39 -0
  41. package/dist/admin/summary.js +85 -0
  42. package/dist/admin/switches.d.ts +58 -0
  43. package/dist/admin/switches.js +61 -0
  44. package/dist/looks/change.d.ts +8 -4
  45. package/dist/looks/change.js +25 -3
  46. package/dist/looks/index.d.ts +3 -3
  47. package/dist/looks/index.js +2 -2
  48. package/dist/looks/json.d.ts +2 -2
  49. package/dist/looks/json.js +6 -4
  50. package/dist/looks/look.d.ts +29 -3
  51. package/dist/looks/look.js +60 -4
  52. package/package.json +9 -2
  53. package/src/admin/adapters/d1.ts +94 -0
  54. package/src/admin/adapters/firestore.ts +78 -0
  55. package/src/admin/amount.ts +14 -0
  56. package/src/admin/conformance.ts +605 -0
  57. package/src/admin/guard.ts +86 -0
  58. package/src/admin/handler.ts +200 -0
  59. package/src/admin/http.ts +74 -0
  60. package/src/admin/index.ts +21 -0
  61. package/src/admin/memory.ts +59 -0
  62. package/src/admin/panel/api.ts +37 -0
  63. package/src/admin/panel/dom.ts +31 -0
  64. package/src/admin/panel/gameCard.ts +63 -0
  65. package/src/admin/panel/index.ts +18 -0
  66. package/src/admin/panel/mount.ts +227 -0
  67. package/src/admin/panel/shelf.ts +337 -0
  68. package/src/admin/panel/shelfData.ts +87 -0
  69. package/src/admin/panel/signins.ts +68 -0
  70. package/src/admin/panel/style.css +542 -0
  71. package/src/admin/store.ts +98 -0
  72. package/src/admin/summary.ts +103 -0
  73. package/src/admin/switches.ts +74 -0
  74. package/src/looks/change.ts +25 -6
  75. package/src/looks/index.ts +3 -3
  76. package/src/looks/json.ts +6 -4
  77. package/src/looks/look.ts +66 -5
@@ -0,0 +1,227 @@
1
+ // The admin panel: orders, owned looks, totals and the two game switches, from the game's own database. Only the game's owner can read them (the
2
+ // handler checks it on every call); this page only shows them. `mountAdmin(element, options)` draws the panel into `element`.
3
+
4
+ import type { Look } from "../../looks/look.js";
5
+ import type { AdminSummary } from "../store.js";
6
+ import type { GameState } from "../switches.js";
7
+ import { ApiError, createApi, type Api } from "./api.js";
8
+ import { h, shortAddress } from "./dom.js";
9
+ import { gameCard } from "./gameCard.js";
10
+ import { shelfView } from "./shelf.js";
11
+ import { signinsCard } from "./signins.js";
12
+
13
+ export type MountOptions = {
14
+ /** The game's looks now (its looks.json). The Shelf starts from them. */
15
+ looks: readonly Look[];
16
+ /** Draws the wallet sign-in of the game into `into` and calls `done` when the owner has signed in. Only a wallet signature opens the admin: use the game's admin scope. */
17
+ signIn: (into: HTMLElement, done: () => void) => void;
18
+ /** Signs out of the ADMIN login only (the game's own login, another person's perhaps, stays). */
19
+ signOut: () => Promise<unknown>;
20
+ /** Where the routes are (the handler's basePath). Default "/api/admin". */
21
+ api?: string;
22
+ /** What to tell a game that has no owner address yet. */
23
+ noAddressHelp?: string;
24
+ /** Where the game's default switches are written ("game.config.json"). */
25
+ defaultsFile?: string;
26
+ /** A picture of a look for the Shelf (default: a flat one). */
27
+ lookPicture?: (look: Look) => Element;
28
+ /** What the Shelf says when the status gives no repository. */
29
+ repoHelp?: string;
30
+ /** What the Sign-ins card says about what the game keeps and for how long ("30 days"). Default: only the time, the rough country and a short browser name. */
31
+ loginsNote?: string;
32
+ /** The `fetch` to use (default: the page's). */
33
+ fetch?: typeof fetch;
34
+ };
35
+
36
+ export type MountedAdmin = {
37
+ /** Reads everything again and redraws. */
38
+ reload(): Promise<void>;
39
+ /** Resolves when the first read has been drawn (a test waits for it). */
40
+ ready: Promise<void>;
41
+ };
42
+
43
+ const STATUS_WORDS: Record<string, string> = { open: "Open", paid: "Paid", expired: "Expired", paid_late: "Paid late" };
44
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
45
+ const when = (ms: number | null) => (ms ? new Date(ms).toLocaleString() : "");
46
+
47
+ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdmin {
48
+ const api: Api = createApi(options.api ?? "/api/admin", options.fetch);
49
+ const gate = h("section", { id: "n133-gate", class: "admin-card", "aria-live": "polite" });
50
+ const data = h("section", { id: "n133-data" });
51
+ data.hidden = true;
52
+ root.classList.add("n133-admin");
53
+ root.replaceChildren(gate, data);
54
+
55
+ let filter = "all";
56
+ /** The Shelf keeps its draft while the page redraws (a tab, a payment check): one view for the life of the page. */
57
+ let shelf: ReturnType<typeof shelfView> | null = null;
58
+ /** The "Game" card keeps its own state for the life of the page (a payment check redraws the rest, not this). */
59
+ let game: HTMLElement | null = null;
60
+
61
+ /** The card, made once. A fault in reading the two switches must not hide the orders: the card is left out and the rest still shows. */
62
+ async function gameSection(): Promise<HTMLElement | null> {
63
+ if (game) return game;
64
+ try {
65
+ game = gameCard(api, await api.get<GameState>("/game"), options.defaultsFile);
66
+ } catch {
67
+ game = null;
68
+ }
69
+ return game;
70
+ }
71
+
72
+ async function load(): Promise<void> {
73
+ try {
74
+ const s = await api.get<AdminSummary>("/summary");
75
+ await gameSection();
76
+ gate.hidden = true;
77
+ render(s);
78
+ } catch (e) {
79
+ if (!(e instanceof ApiError)) {
80
+ gate.hidden = false;
81
+ gate.replaceChildren(h("p", {}, "The game's server did not answer. Try again."));
82
+ return;
83
+ }
84
+ data.hidden = true;
85
+ gate.hidden = false;
86
+ if (e.status === 403 && e.body.signedInAs) {
87
+ gate.replaceChildren(
88
+ h("h2", {}, "Not the game's address"),
89
+ h("p", {}, `You are signed in as ${shortAddress(String(e.body.signedInAs))}. Only the game's owner address can open this page.`),
90
+ signOutButton(),
91
+ );
92
+ return;
93
+ }
94
+ if (e.status === 403 && e.body.reason === "nanoterra") {
95
+ gate.replaceChildren(
96
+ h("h2", {}, "You signed in from Nanoterra"),
97
+ h("p", {}, "A Nanoterra sign-in names your address but proves no key, so it cannot open this page. Sign out, then sign in with the wallet that holds your game's address."),
98
+ signOutButton(),
99
+ );
100
+ return;
101
+ }
102
+ if (e.status === 403 && e.body.reason === "no_address") {
103
+ gate.replaceChildren(h("h2", {}, "No owner address yet"), h("p", {}, e.message), h("p", { class: "fine" }, options.noAddressHelp ?? "Set the game's owner address, then deploy again."));
104
+ return;
105
+ }
106
+ if (e.status === 403) {
107
+ gate.replaceChildren(h("h2", {}, "This page is closed to you"), h("p", {}, e.message), signOutButton());
108
+ return;
109
+ }
110
+ if (e.status === 401) {
111
+ const into = h("div", {});
112
+ gate.replaceChildren(h("h2", {}, "Sign in with your game's address"), h("p", {}, e.message), into);
113
+ options.signIn(into, () => void load());
114
+ return;
115
+ }
116
+ gate.replaceChildren(h("p", {}, e.message));
117
+ }
118
+ }
119
+
120
+ function signOutButton(): HTMLButtonElement {
121
+ return h(
122
+ "button",
123
+ {
124
+ class: "btn",
125
+ type: "button",
126
+ onclick: async () => {
127
+ await options.signOut().catch(() => null);
128
+ void load();
129
+ },
130
+ },
131
+ "Sign out",
132
+ );
133
+ }
134
+
135
+ function shelfCard(s: AdminSummary): HTMLElement {
136
+ const d = { orders: s.orders, players: s.players, repo: s.repo ?? null };
137
+ if (shelf) shelf.update(d);
138
+ else shelf = shelfView(d, options.looks, options.lookPicture, options.repoHelp);
139
+ return shelf.el;
140
+ }
141
+
142
+ function render(s: AdminSummary): void {
143
+ data.hidden = false;
144
+ const check = h("button", { class: "btn btn-primary", type: "button" }, "Check payments now");
145
+ const checkNote = h("span", { class: "fine", role: "status" });
146
+ check.addEventListener("click", async () => {
147
+ check.disabled = true;
148
+ checkNote.textContent = "Checking…";
149
+ try {
150
+ const r = await api.post<{ scan: { scanned: boolean; settled: unknown[]; error?: string }; summary: AdminSummary }>("/check");
151
+ render(r.summary);
152
+ } catch (e) {
153
+ checkNote.textContent = e instanceof Error ? e.message : "The check failed.";
154
+ check.disabled = false;
155
+ }
156
+ });
157
+
158
+ const tabs = h("div", { class: "tabs", role: "group", "aria-label": "Show orders" });
159
+ for (const key of ["all", "open", "paid", "expired", "paid_late"]) {
160
+ const n = key === "all" ? s.orders.length : s.counts[key as keyof AdminSummary["counts"]];
161
+ tabs.append(
162
+ h(
163
+ "button",
164
+ {
165
+ class: `tab${filter === key ? " tab-on" : ""}`,
166
+ type: "button",
167
+ "aria-pressed": filter === key ? "true" : "false",
168
+ onclick: () => {
169
+ filter = key;
170
+ render(s);
171
+ },
172
+ },
173
+ `${key === "all" ? "All" : STATUS_WORDS[key]} (${n})`,
174
+ ),
175
+ );
176
+ }
177
+
178
+ const rows = s.orders.filter((o) => filter === "all" || o.status === filter);
179
+ const orders = h("div", { class: "orders" });
180
+ if (!rows.length) orders.append(h("p", { class: "fine" }, "No orders here yet."));
181
+ for (const o of rows) {
182
+ orders.append(
183
+ h(
184
+ "article",
185
+ { class: `order order-${o.status}` },
186
+ h("div", { class: "order-top" }, h("strong", {}, o.look), h("span", { class: `badge badge-${o.status}` }, STATUS_WORDS[o.status] ?? o.status)),
187
+ h("div", { class: "order-amount" }, `${o.xno} XNO · $${o.usd}`),
188
+ h("div", { class: "fine" }, `Player ${shortAddress(o.player)} · made ${when(o.createdAt)}`),
189
+ o.paidAt ? h("div", { class: "fine" }, `Payment seen ${when(o.paidAt)}${o.payer ? ` from ${shortAddress(o.payer)}` : ""}`) : null,
190
+ o.status === "paid_late" && o.payer ? h("div", { class: "late-note" }, "Send this amount back by hand to ", h("code", {}, o.payer), ". The game holds no key, so it cannot send it.") : null,
191
+ o.hash ? h("div", { class: "fine hash" }, `Block ${o.hash}`) : null,
192
+ ),
193
+ );
194
+ }
195
+
196
+ const players = h("div", { class: "players" });
197
+ if (!s.players.length) players.append(h("p", { class: "fine" }, "Nobody owns a paid look yet."));
198
+ for (const p of s.players) players.append(h("div", { class: "player" }, h("code", {}, p.player), h("span", {}, p.looks.map((l) => l.name).join(", "))));
199
+
200
+ const parts: (Node | null)[] = [
201
+ s.warnings.length ? h("section", { class: "admin-card warnings", role: "alert" }, h("h2", {}, "Check these"), h("ul", {}, ...s.warnings.map((w) => h("li", {}, w)))) : null,
202
+ game,
203
+ h(
204
+ "section",
205
+ { class: "totals" },
206
+ h("div", { class: "total" }, h("span", {}, "Paid"), h("strong", {}, `${s.totals.paid.xno} XNO`), h("small", {}, `$${s.totals.paid.usd} · ${plural(s.totals.paid.count, "order")}`)),
207
+ h("div", { class: "total" }, h("span", {}, "Paid late (send back)"), h("strong", {}, `${s.totals.paidLate.xno} XNO`), h("small", {}, `$${s.totals.paidLate.usd} · ${plural(s.totals.paidLate.count, "order")}`)),
208
+ h("div", { class: "total" }, h("span", {}, "Open now"), h("strong", {}, String(s.counts.open)), h("small", {}, `${s.counts.expired} expired`)),
209
+ ),
210
+ shelfCard(s),
211
+ h("section", { class: "admin-card" }, h("div", { class: "card-head" }, h("h2", {}, "Orders"), h("div", {}, ...(s.canCheck === false ? [] : [check, " ", checkNote]))), tabs, orders),
212
+ h("section", { class: "admin-card" }, h("h2", {}, "Owned looks"), players),
213
+ signinsCard(s, api, () => void load(), options.loginsNote),
214
+ h(
215
+ "section",
216
+ { class: "admin-card fine" },
217
+ s.address ? h("p", {}, "Signed in as ", h("code", {}, s.address), ".") : null,
218
+ s.nodes?.length ? h("p", {}, `Payments are checked on: ${s.nodes.join(", ")}.`) : null,
219
+ s.rate ? h("p", {}, `Last price: $${s.rate.usd} per XNO (${s.rate.sources.join(", ")}), ${when(s.rate.at)}.`) : null,
220
+ signOutButton(),
221
+ ),
222
+ ];
223
+ data.replaceChildren(...parts.filter((p): p is Node => p !== null));
224
+ }
225
+
226
+ return { reload: () => load(), ready: load() };
227
+ }
@@ -0,0 +1,337 @@
1
+ // The Shelf screen in /admin: the game's looks with what each has sold, and a draft you can change in this page. It runs only in
2
+ // the creator's own browser, on the creator's own game. It changes NOTHING on the game: the new looks.json is text to copy and put
3
+ // in the repository (there is no server call that writes looks, on purpose). Its rules are in the package's looks path (../../looks).
4
+
5
+ import { diffLooks, removalProblems } from "../../looks/change.js";
6
+ import { SLOTS_MAX } from "../../looks/look.js";
7
+ import { looksJson, slugId } from "../../looks/json.js";
8
+ import { PRICE_LADDER, priceText, rungOf, rungUsd } from "../../looks/ladder.js";
9
+ import { HATS, lookProblems, usdText, type Hat, type Look, type Problem } from "../../looks/look.js";
10
+ import { copy, h } from "./dom.js";
11
+ import { aiPrompt, changeText, ownedIds, shelfStats, soldText, type ShelfOrder, type ShelfPlayer } from "./shelfData.js";
12
+
13
+ export type ShelfData = { orders: ShelfOrder[]; players: ShelfPlayer[]; repo: string | null };
14
+
15
+ const NS = "http://www.w3.org/2000/svg";
16
+ const INK = "#1d1530";
17
+ const svg = (tag: string, attrs: Record<string, string | number> = {}, ...kids: Element[]) => {
18
+ const el = document.createElementNS(NS, tag);
19
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
20
+ el.append(...kids);
21
+ return el;
22
+ };
23
+ const shape = (tag: string, attrs: Record<string, string | number>) => svg(tag, { stroke: INK, "stroke-width": 1.4, "stroke-linejoin": "round", ...attrs });
24
+
25
+ /** A small flat picture of a look: the body in its colour and the hat in its colour. (The game draws the real character; a game can give its own picture.) */
26
+ export function lookPicture(l: Look): Element {
27
+ const hat = l.change.hat;
28
+ const hc = l.change.hatColor ?? "#444444";
29
+ const hats: Record<Hat, Element[]> = {
30
+ cap: [shape("path", { d: "M19 24 A13 13 0 0 1 45 24 Z", fill: hc }), shape("rect", { x: 30, y: 22, width: 22, height: 4, rx: 2, fill: hc })],
31
+ beanie: [shape("path", { d: "M18 23 A14 14 0 0 1 46 23 Z", fill: hc }), shape("rect", { x: 18, y: 21, width: 28, height: 5, rx: 2, fill: hc }), shape("circle", { cx: 32, cy: 9, r: 3.5, fill: hc })],
32
+ crown: [shape("polygon", { points: "20,22 20,11 26,17 32,9 38,17 44,11 44,22", fill: hc })],
33
+ tophat: [shape("rect", { x: 23, y: 3, width: 18, height: 17, rx: 2, fill: hc }), shape("rect", { x: 17, y: 19, width: 30, height: 4, rx: 2, fill: hc })],
34
+ };
35
+ return svg(
36
+ "svg",
37
+ { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: ${l.change.hat ?? "no hat"}` },
38
+ shape("rect", { x: 23, y: 61, width: 7, height: 9, rx: 2, fill: "#3a3f5c" }),
39
+ shape("rect", { x: 34, y: 61, width: 7, height: 9, rx: 2, fill: "#3a3f5c" }),
40
+ shape("rect", { x: 16, y: 37, width: 32, height: 28, rx: 12, fill: l.change.body }),
41
+ shape("circle", { cx: 32, cy: 26, r: 13, fill: "#f2c9a0" }),
42
+ svg("circle", { cx: 27, cy: 27, r: 1.7, fill: INK }),
43
+ svg("circle", { cx: 37, cy: 27, r: 1.7, fill: INK }),
44
+ ...(hat ? hats[hat] : []),
45
+ );
46
+ }
47
+
48
+ const clone = (ls: readonly Look[]): Look[] => ls.map((l) => ({ id: l.id, name: l.name, priceUsd: l.priceUsd, change: { ...l.change }, ...(l.slots ? { slots: { ...l.slots } } : {}) }));
49
+
50
+ /** "wings ember, trail #ffcf5a" */
51
+ const slotsText = (l: Look): string => Object.entries(l.slots ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
52
+
53
+ /** The names of the slots that differ between two looks (added, removed or another value). */
54
+ function slotNamesChanged(before: Look, after: Look): string {
55
+ const names = new Set([...Object.keys(before.slots ?? {}), ...Object.keys(after.slots ?? {})]);
56
+ return [...names].filter((n) => (before.slots?.[n] ?? null) !== (after.slots?.[n] ?? null)).join(", ");
57
+ }
58
+
59
+ function fieldWords(d: ReturnType<typeof diffLooks>["changed"][number]): string {
60
+ const parts = d.fields.map((f) => {
61
+ if (f === "name") return `name "${d.before.name}" to "${d.after.name}"`;
62
+ if (f === "priceUsd") return `price ${usdText(d.before.priceUsd)} to ${usdText(d.after.priceUsd)}`;
63
+ if (f === "body") return "body colour";
64
+ if (f === "hat") return `hat ${d.before.change.hat ?? "none"} to ${d.after.change.hat ?? "none"}`;
65
+ if (f === "slots") return `slots changed (${slotNamesChanged(d.before, d.after)})`;
66
+ return "hat colour";
67
+ });
68
+ return `${d.after.name}: ${parts.join(", ")}`;
69
+ }
70
+
71
+ /**
72
+ * The screen. `looks` is the game's looks now (its looks.json): the draft starts from them. `update` hands the screen fresh numbers (after the page reloads
73
+ * the summary) and keeps the draft. `picture` draws a look (default: the flat picture above). `repoHelp` is what to tell a game that has no repository set.
74
+ */
75
+ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l: Look) => Element = lookPicture, repoHelp = "To get a link to your looks.json on GitHub, give the admin your repository (for example your-name/your-game)."): { el: HTMLElement; update: (d: ShelfData) => void } {
76
+ const LOOKS = looks;
77
+ let data = first;
78
+ let draft = clone(LOOKS);
79
+ let editing = false;
80
+ const el = h("section", { class: "admin-card shelf", "aria-labelledby": "shelf-title" });
81
+
82
+ const stats = () => shelfStats(data.orders, data.players);
83
+ const owned = () => ownedIds(data.players);
84
+ const isOriginal = (id: string) => LOOKS.some((l) => l.id === id);
85
+ const freeCount = () => draft.filter((l) => l.priceUsd === 0).length;
86
+ /**
87
+ * The rows of a look's slot editor, as typed: a name can be typed twice (an object cannot hold it), so the rows are the draft's own truth while it is edited, and the
88
+ * look's `slots` is made from them. A row with no name and no value is a row not filled in yet: it is not a slot.
89
+ */
90
+ const slotRows = new WeakMap<Look, [string, string][]>();
91
+ const rowsOf = (l: Look): [string, string][] => slotRows.get(l) ?? Object.entries(l.slots ?? {});
92
+ function setRows(l: Look, rows: [string, string][]) {
93
+ slotRows.set(l, rows);
94
+ const filled = rows.filter(([k, v]) => k !== "" || v !== "");
95
+ if (filled.length) l.slots = Object.fromEntries(filled);
96
+ else delete l.slots;
97
+ }
98
+
99
+ function problems(): Problem[] {
100
+ const dup: Problem[] = [];
101
+ draft.forEach((l, i) => {
102
+ const seen = new Set<string>();
103
+ for (const [k] of rowsOf(l)) {
104
+ if (k !== "" && seen.has(k)) dup.push({ look: i + 1, field: "slots", message: `looks.json, look ${i + 1}: the slot name "${k}" is used twice` });
105
+ seen.add(k);
106
+ }
107
+ });
108
+ const own = [...dup, ...lookProblems(draft)];
109
+ return own.length ? own : removalProblems(LOOKS, draft, owned());
110
+ }
111
+
112
+ function redraw() {
113
+ const st = stats();
114
+ const own = owned();
115
+ const head = h(
116
+ "div",
117
+ { class: "card-head" },
118
+ h("h2", { id: "shelf-title" }, "Shelf"),
119
+ h(
120
+ "button",
121
+ {
122
+ class: `btn ${editing ? "" : "btn-primary"}`,
123
+ type: "button",
124
+ "aria-pressed": editing ? "true" : "false",
125
+ onclick: () => {
126
+ editing = !editing;
127
+ redraw();
128
+ },
129
+ },
130
+ editing ? "Close the editor" : "Edit the shelf",
131
+ ),
132
+ );
133
+ const intro = h(
134
+ "p",
135
+ { class: "fine" },
136
+ editing
137
+ ? "Change a look here to see what it would be. This page changes nothing in your game: when you are happy, copy the new looks.json into your repository and push it. Your game builds again and the new looks go live."
138
+ : "The looks your game sells now, with what each has sold in your own database. Nobody else sees these numbers.",
139
+ );
140
+
141
+ const list = h("div", { class: "shelf-list" });
142
+ draft.forEach((l, i) => list.append(card(l, i, st, own)));
143
+ const parts: (Node | null)[] = [head, intro, list];
144
+
145
+ if (editing) {
146
+ parts.push(
147
+ h(
148
+ "div",
149
+ { class: "shelf-add" },
150
+ h("button", { class: "btn", type: "button", onclick: () => addLook() }, "Add a look"),
151
+ " ",
152
+ h("button", { class: "btn", type: "button", disabled: JSON.stringify(draft) === JSON.stringify(LOOKS), onclick: () => ((draft = clone(LOOKS)), redraw()) }, "Start over"),
153
+ ),
154
+ foot(),
155
+ );
156
+ }
157
+ el.replaceChildren(...parts.filter((p): p is Node => p !== null));
158
+ }
159
+
160
+ function card(l: Look, i: number, st: ReturnType<typeof stats>, own: Set<string>) {
161
+ const rung = rungOf(l.priceUsd);
162
+ const price = l.priceUsd === 0 ? "Free" : `${priceText(l.priceUsd)}${rung ? ` · ${rung.name}` : ""}`;
163
+ const pic = h("div", { class: "shelf-pic" }, picture(l));
164
+ const priceLine = h("span", { class: "shelf-price" }, price);
165
+ const top = h("div", { class: "shelf-top" }, pic, h("div", { class: "shelf-text" }, h("strong", {}, l.name || "(no name)"), priceLine, 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"), l.slots && Object.keys(l.slots).length ? h("span", { class: "fine shelf-slots" }, `Slots: ${slotsText(l)}`) : null));
166
+ if (!editing) return h("article", { class: "shelf-card" }, top);
167
+
168
+ const sync = () => {
169
+ pic.replaceChildren(picture(l));
170
+ const r = rungOf(l.priceUsd);
171
+ priceLine.textContent = Number.isFinite(l.priceUsd) ? (l.priceUsd === 0 ? "Free" : `${priceText(l.priceUsd)}${r ? ` · ${r.name}` : ""}`) : "No price";
172
+ top.querySelector("strong")!.textContent = l.name || "(no name)";
173
+ refreshFoot();
174
+ };
175
+ const name = h("input", { class: "shelf-input", type: "text", maxlength: 40, value: l.name, "aria-label": `Name of look ${i + 1}`, oninput: (e: Event) => {
176
+ l.name = (e.target as HTMLInputElement).value;
177
+ if (!isOriginal(l.id)) l.id = slugId(l.name, [...draft.filter((x) => x !== l).map((x) => x.id), ...owned()]);
178
+ sync();
179
+ } });
180
+ const priceInput = h("input", { class: "shelf-input shelf-num", type: "number", step: 0.01, min: 0, max: 1000, inputmode: "decimal", value: Number.isFinite(l.priceUsd) ? l.priceUsd : "", "aria-label": `Price in US dollars of ${l.name}`, oninput: (e: Event) => {
181
+ const v = (e.target as HTMLInputElement).value;
182
+ l.priceUsd = v === "" ? Number.NaN : Number(v);
183
+ sync();
184
+ } });
185
+ const ladder = h("div", { class: "shelf-ladder", role: "group", "aria-label": "Price ladder" });
186
+ const setPrice = (usd: number) => {
187
+ l.priceUsd = usd;
188
+ priceInput.value = String(usd);
189
+ sync();
190
+ };
191
+ ladder.append(h("button", { class: "chip", type: "button", onclick: () => setPrice(0) }, "Free"));
192
+ for (const r of PRICE_LADDER) ladder.append(h("button", { class: "chip", type: "button", title: `${r.name}: ${r.for}`, onclick: () => setPrice(rungUsd(r)) }, priceText(rungUsd(r))));
193
+ const body = h("input", { class: "shelf-color", type: "color", value: l.change.body, "aria-label": `Body colour of ${l.name}`, oninput: (e: Event) => {
194
+ l.change.body = (e.target as HTMLInputElement).value;
195
+ sync();
196
+ } });
197
+ const hatColor = h("input", { class: "shelf-color", type: "color", value: l.change.hatColor ?? "#1c6e4f", "aria-label": `Hat colour of ${l.name}`, disabled: !l.change.hat, oninput: (e: Event) => {
198
+ l.change.hatColor = (e.target as HTMLInputElement).value;
199
+ sync();
200
+ } });
201
+ const hat = h("select", { class: "shelf-input", "aria-label": `Hat of ${l.name}`, onchange: (e: Event) => {
202
+ const v = (e.target as HTMLSelectElement).value;
203
+ if (v === "") {
204
+ delete l.change.hat;
205
+ delete l.change.hatColor;
206
+ hatColor.disabled = true;
207
+ } else {
208
+ l.change.hat = v as Hat;
209
+ l.change.hatColor ??= hatColor.value;
210
+ hatColor.disabled = false;
211
+ }
212
+ sync();
213
+ } }, h("option", { value: "" }, "No hat"), ...HATS.map((x) => h("option", { value: x }, x)));
214
+ hat.value = l.change.hat ?? "";
215
+
216
+ const lastFree = l.priceUsd === 0 && freeCount() === 1;
217
+ const keep = isOriginal(l.id) && own.has(l.id);
218
+ const remove = h("button", { class: "btn", type: "button", disabled: keep || lastFree, title: keep ? "A player owns this look. Keep it in the file." : lastFree ? "Keep one free look: every player starts with it." : "", onclick: () => ((draft = draft.filter((x) => x !== l)), redraw()) }, "Remove");
219
+ const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, "Duplicate");
220
+
221
+ return h(
222
+ "article",
223
+ { class: "shelf-card shelf-editing" },
224
+ top,
225
+ h("label", { class: "shelf-field" }, h("span", {}, "Name"), name),
226
+ h("div", { class: "shelf-field" }, h("span", {}, "Price in US dollars"), priceInput, ladder),
227
+ h("div", { class: "shelf-colors" }, h("label", { class: "shelf-field" }, h("span", {}, "Body colour"), body), h("label", { class: "shelf-field" }, h("span", {}, "Hat"), hat), h("label", { class: "shelf-field" }, h("span", {}, "Hat colour"), hatColor)),
228
+ slotsEditor(l, isOriginal(l.id) && own.has(l.id)),
229
+ keep ? h("p", { class: "fine" }, "A player owns this look: its id stays, and it cannot be removed.") : null,
230
+ h("div", { class: "shelf-actions" }, dup, " ", remove),
231
+ );
232
+ }
233
+
234
+ /** The slots of a look: names and values (words or #rrggbb colours) that the game reads. Rows to type in, at most SLOTS_MAX. */
235
+ function slotsEditor(l: Look, owned: boolean): HTMLElement {
236
+ const rows = rowsOf(l);
237
+ const list = h("div", { class: "shelf-slot-list" });
238
+ rows.forEach(([name, value], r) => {
239
+ const edit = (index: 0 | 1) => (e: Event) => {
240
+ const cur = rowsOf(l);
241
+ cur[r] = index === 0 ? [(e.target as HTMLInputElement).value, cur[r][1]] : [cur[r][0], (e.target as HTMLInputElement).value];
242
+ setRows(l, cur);
243
+ refreshFoot();
244
+ };
245
+ list.append(
246
+ h(
247
+ "div",
248
+ { class: "shelf-slot" },
249
+ h("input", { class: "shelf-input", type: "text", maxlength: 24, value: name, placeholder: "name", autocapitalize: "none", spellcheck: false, "aria-label": `Name of slot ${r + 1} of ${l.name}`, oninput: edit(0) }),
250
+ h("input", { class: "shelf-input", type: "text", maxlength: 40, value: value, placeholder: "word or #rrggbb", autocapitalize: "none", spellcheck: false, "aria-label": `Value of slot ${r + 1} of ${l.name}`, oninput: edit(1) }),
251
+ h("button", { class: "btn btn-small", type: "button", "aria-label": `Remove slot ${name || r + 1} of ${l.name}`, onclick: () => (setRows(l, rowsOf(l).filter((_, i) => i !== r)), redraw()) }, "Remove"),
252
+ ),
253
+ );
254
+ });
255
+ const add = h("button", { class: "btn btn-small", type: "button", disabled: rows.length >= SLOTS_MAX, onclick: () => {
256
+ const cur = rowsOf(l);
257
+ let n = cur.length + 1;
258
+ while (cur.some(([k]) => k === `slot-${n}`)) n++;
259
+ setRows(l, [...cur, [`slot-${n}`, ""]]);
260
+ redraw();
261
+ } }, "Add a slot");
262
+ return h(
263
+ "div",
264
+ { class: "shelf-field shelf-slots-edit" },
265
+ h("span", {}, `Slots (${rows.length} of ${SLOTS_MAX}): words and colours that your game reads`),
266
+ list,
267
+ add,
268
+ owned ? h("p", { class: "fine" }, "A player owns this look: its slots cannot change. Add a new look with the new slots instead.") : null,
269
+ );
270
+ }
271
+
272
+ function addLook(from?: Look) {
273
+ const name = from ? `${from.name} copy`.slice(0, 40) : "New look";
274
+ const base: Look = from ? { id: "", name, priceUsd: from.priceUsd, change: { ...from.change }, ...(from.slots ? { slots: { ...from.slots } } : {}) } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
275
+ // 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.
276
+ base.id = slugId(name, [...draft.map((x) => x.id), ...owned()]);
277
+ draft = [...draft, base];
278
+ redraw();
279
+ el.querySelectorAll<HTMLInputElement>(".shelf-card:last-of-type input[type=text]").forEach((i) => i.focus());
280
+ }
281
+
282
+ const footBox = h("div", { class: "shelf-foot" });
283
+ const foot = () => {
284
+ refreshFoot();
285
+ return footBox;
286
+ };
287
+
288
+ function refreshFoot() {
289
+ const bad = problems();
290
+ const diff = diffLooks(LOOKS, draft);
291
+ const changed = diff.added.length + diff.removed.length + diff.changed.length > 0;
292
+ const text = bad.length ? "" : looksJson(draft);
293
+ const out: (Node | null)[] = [];
294
+ if (bad.length) out.push(h("div", { class: "late-note", role: "alert" }, h("strong", {}, "Fix these first"), h("ul", {}, ...bad.map((p) => h("li", {}, p.message)))));
295
+ out.push(
296
+ h(
297
+ "div",
298
+ { class: "shelf-changes" },
299
+ h("strong", {}, changed ? changeText(diff) : "No change yet"),
300
+ changed
301
+ ? h(
302
+ "ul",
303
+ {},
304
+ ...diff.added.map((l) => h("li", {}, `Added: ${l.name} (${l.priceUsd === 0 ? "free" : usdText(l.priceUsd)})`)),
305
+ ...diff.changed.map((c) => h("li", {}, fieldWords(c))),
306
+ ...diff.removed.map((l) => h("li", {}, `Removed: ${l.name}`)),
307
+ )
308
+ : null,
309
+ ),
310
+ );
311
+ const ready = !bad.length && changed;
312
+ const copyJson = h("button", { class: "btn btn-primary", type: "button", disabled: !ready, onclick: (e: Event) => void copy(text, e.currentTarget as HTMLButtonElement) }, "Copy looks.json");
313
+ const copyAi = h("button", { class: "btn", type: "button", disabled: !ready, onclick: (e: Event) => void copy(aiPrompt(text, diff, [...owned()].sort(), LOOKS), e.currentTarget as HTMLButtonElement) }, "Copy a message for my AI");
314
+ out.push(
315
+ h(
316
+ "div",
317
+ { class: "shelf-actions" },
318
+ copyJson,
319
+ " ",
320
+ copyAi,
321
+ data.repo ? h("a", { class: "btn", href: `https://github.com/${data.repo}/edit/main/looks.json`, target: "_blank", rel: "noopener noreferrer" }, "Open looks.json on GitHub") : null,
322
+ ),
323
+ !data.repo ? h("p", { class: "fine" }, repoHelp) : null,
324
+ ready ? h("details", { class: "shelf-file" }, h("summary", {}, "Show the new looks.json"), h("pre", {}, text)) : null,
325
+ );
326
+ footBox.replaceChildren(...out.filter((n): n is Node => n !== null));
327
+ }
328
+
329
+ redraw();
330
+ return {
331
+ el,
332
+ update(d) {
333
+ data = d;
334
+ redraw();
335
+ },
336
+ };
337
+ }
@@ -0,0 +1,87 @@
1
+ // The numbers and the words of the Shelf screen in /admin (shelf.ts draws it). Pure: no page, no network. Everything
2
+ // here comes from the game's own database (the admin summary) and from looks.json; nothing is sent anywhere.
3
+
4
+ import type { LooksDiff } from "../../looks/change.js";
5
+ import { ID_PATTERN, type Look } from "../../looks/look.js";
6
+
7
+ export type ShelfOrder = { lookId: string; status: string; usd: string };
8
+ export type ShelfPlayer = { looks: { id: string }[] };
9
+
10
+ /** What one look has done in this game. */
11
+ export type LookStats = {
12
+ /** Players who own it (a paid order gave it to them). */
13
+ owners: number;
14
+ /** Paid orders, and their dollars in cents. */
15
+ paid: number;
16
+ paidCents: number;
17
+ /** Orders that were paid after the order ran out (the money is to be sent back; the look was not given). */
18
+ late: number;
19
+ };
20
+
21
+ const none = (): LookStats => ({ owners: 0, paid: 0, paidCents: 0, late: 0 });
22
+
23
+ /** "0.25" is 25 cents. */
24
+ export const centsOf = (usd: string): number => Math.round(Number(usd) * 100) || 0;
25
+
26
+ /** The stats for each look id that appears in the orders or in a player's looks. */
27
+ export function shelfStats(orders: readonly ShelfOrder[], players: readonly ShelfPlayer[]): Map<string, LookStats> {
28
+ const stats = new Map<string, LookStats>();
29
+ const of = (id: string) => stats.get(id) ?? stats.set(id, none()).get(id)!;
30
+ for (const o of orders) {
31
+ if (o.status === "paid") {
32
+ const s = of(o.lookId);
33
+ s.paid++;
34
+ s.paidCents += centsOf(o.usd);
35
+ } else if (o.status === "paid_late") of(o.lookId).late++;
36
+ }
37
+ for (const p of players) for (const l of new Set(p.looks.map((x) => x.id))) of(l).owners++;
38
+ return stats;
39
+ }
40
+
41
+ /** Every look id that a player owns. */
42
+ export function ownedIds(players: readonly ShelfPlayer[]): Set<string> {
43
+ return new Set(players.flatMap((p) => p.looks.map((l) => l.id)));
44
+ }
45
+
46
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
47
+
48
+ /** "1 added, 2 changed, 0 removed" */
49
+ export function changeText(d: LooksDiff): string {
50
+ return `${d.added.length} added, ${d.changed.length} changed, ${d.removed.length} removed`;
51
+ }
52
+
53
+ /** What to say when a player's money is in a look: "3 sold, $0.75, 3 owners", or "Not sold yet". */
54
+ export function soldText(s: LookStats | undefined): string {
55
+ if (!s || (!s.paid && !s.owners)) return "Not sold yet";
56
+ return `${s.paid} sold, $${(s.paidCents / 100).toFixed(2)}, ${plural(s.owners, "owner")}`;
57
+ }
58
+
59
+ /**
60
+ * A message to give to an AI assistant that works in the game's repository: the whole new looks.json, what changed, and the rules.
61
+ * It holds no address, no order and no secret: only the looks, and the ids that players own.
62
+ */
63
+ export function aiPrompt(text: string, diff: LooksDiff, owned: readonly string[], before: readonly Look[]): string {
64
+ // Only a real id goes in, written as a JSON string: an id that comes from a database is data, and a message that a person pastes into an AI assistant must not carry a line
65
+ // that an attacker wrote there.
66
+ const quote = (ids: readonly string[]) => ids.filter((id) => ID_PATTERN.test(id)).map((id) => JSON.stringify(id)).join(", ");
67
+ const names = (ls: readonly Look[]) => quote(ls.map((l) => l.id));
68
+ const ownedOk = owned.filter((id) => ID_PATTERN.test(id));
69
+ const odd = owned.length - ownedOk.length;
70
+ const lines = [
71
+ "Replace looks.json at the top of this game with the text below, then run `npm run check` and push to GitHub. Do not change anything else.",
72
+ "",
73
+ `The change: ${changeText(diff)}.`,
74
+ diff.added.length ? `Added: ${names(diff.added)}.` : "",
75
+ diff.removed.length ? `Removed: ${names(diff.removed)}.` : "",
76
+ diff.changed.length ? `Changed: ${names(diff.changed.map((c) => c.after))}.` : "",
77
+ "",
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 look (priceUsd 0), prices in US dollars with at most 2 decimals, never change or remove the id of a look that someone owns, never change the slots of a look that someone owns (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
+ "",
81
+ "looks.json:",
82
+ "```json",
83
+ text.trimEnd(),
84
+ "```",
85
+ ];
86
+ return lines.filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n");
87
+ }