@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
@@ -4,11 +4,22 @@
4
4
  export const HATS = ["cap", "beanie", "crown", "tophat"];
5
5
  export const ID_PATTERN = /^[a-z0-9-]{1,40}$/;
6
6
  export const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
7
+ /** At most this many slots on a look. */
8
+ export const SLOTS_MAX = 8;
9
+ /** A slot's name: 1 to 24 lowercase letters, digits or dashes, starting with a letter. */
10
+ export const SLOT_NAME_PATTERN = /^[a-z][a-z0-9-]{0,23}$/;
11
+ /** Names that a slot may not have: they mean something to an object (`constructor`, `prototype`). */
12
+ export const RESERVED_SLOT_NAMES = ["constructor", "prototype"];
13
+ /** A slot's value that is a word: 1 to 40 lowercase letters, digits or dashes. (The other kind of value is a colour, COLOR_PATTERN.) */
14
+ export const SLOT_WORD_PATTERN = /^[a-z0-9-]{1,40}$/;
7
15
  export const NAME_MAX = 40;
8
16
  export const PRICE_MAX_USD = 1000;
9
- /** Whether a price has no fraction of a cent. `Math.round(x * 100) === x * 100` is wrong for 0.07, 0.29 and 1.1 (floating point). */
17
+ /**
18
+ * Whether a price has no fraction of a cent. `Math.round(x * 100) === x * 100` is wrong for 0.07, 0.29 and 1.1 (floating point), and a tolerance is wrong the other way (0.300000001
19
+ * and 1e-9 would pass): a price is whole cents when it is exactly the number that its own two-decimal text names.
20
+ */
10
21
  export function wholeCents(usd) {
11
- return Math.abs(usd * 100 - Math.round(usd * 100)) < 1e-6;
22
+ return typeof usd === "number" && Number.isFinite(usd) && Number(usd.toFixed(2)) === usd;
12
23
  }
13
24
  /** Every mistake in a list of looks (looks.json as it was read), in the order of the list. An empty array means the list is good. */
14
25
  export function lookProblems(raw) {
@@ -41,17 +52,62 @@ export function lookProblems(raw) {
41
52
  add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
42
53
  if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor)))
43
54
  add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
55
+ if (v.slots !== undefined)
56
+ slotProblems(v.slots).forEach((s) => add(s.field, s.message));
44
57
  });
45
58
  if (!out.length && !free)
46
59
  out.push({ look: null, field: "free", message: "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
47
60
  return out;
48
61
  }
62
+ /** Whether `v` is a plain object (not null, not a list, not a class instance). */
63
+ const isPlain = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
64
+ /** Whether a slot's value is good: a word or a colour. */
65
+ export const slotValueOk = (v) => typeof v === "string" && (SLOT_WORD_PATTERN.test(v) || COLOR_PATTERN.test(v));
66
+ /** The mistakes in one look's `slots` (the look's own message is added by the caller): the field is "slots" or "slots.<name>". */
67
+ function slotProblems(slots) {
68
+ if (!isPlain(slots))
69
+ return [{ field: "slots", message: '"slots" must be an object of names and values, like { "wings": "ember" }' }];
70
+ const names = Object.keys(slots);
71
+ const out = [];
72
+ if (names.length > SLOTS_MAX)
73
+ out.push({ field: "slots", message: `"slots" can have at most ${SLOTS_MAX} slots (this one has ${names.length})` });
74
+ for (const name of names) {
75
+ if (!SLOT_NAME_PATTERN.test(name))
76
+ out.push({ field: `slots.${name}`, message: `the slot name "${name}" must be 1 to 24 lowercase letters, digits or dashes, starting with a letter` });
77
+ else if (RESERVED_SLOT_NAMES.includes(name))
78
+ out.push({ field: `slots.${name}`, message: `the slot name "${name}" is reserved: choose another` });
79
+ else if (!slotValueOk(slots[name]))
80
+ out.push({ field: `slots.${name}`, message: `"slots.${name}" must be a word (1 to 40 lowercase letters, digits or dashes) or a colour like "#ffcf5a"` });
81
+ }
82
+ return out;
83
+ }
84
+ /**
85
+ * The slots as a clean copy, or undefined when there are none (an empty `slots` is the same as none). The creator's order of names is kept. A COLOUR is written in lower case
86
+ * ("#ffcf5a"), so the game always reads one text for one colour: a change of only the case of a colour is then no change at all, for the game as for the check.
87
+ */
88
+ export function cleanSlots(slots) {
89
+ if (!slots)
90
+ return undefined;
91
+ const names = Object.keys(slots);
92
+ return names.length ? Object.fromEntries(names.map((n) => [n, typeof slots[n] === "string" && COLOR_PATTERN.test(slots[n]) ? slots[n].toLowerCase() : slots[n]])) : undefined;
93
+ }
49
94
  /** The looks, or an Error that says the first mistake in plain words. The same words as the starter's `checkLooks`. */
50
- export function checkLooks(raw) {
95
+ export function checkLooks(rawInput) {
96
+ // One snapshot of the data is checked AND copied: a getter or a changing value cannot be good for the check and something else for the copy. (JSON cannot hold one.)
97
+ let raw = rawInput;
98
+ try {
99
+ raw = rawInput === undefined ? undefined : JSON.parse(JSON.stringify(rawInput));
100
+ }
101
+ catch {
102
+ throw new Error("looks.json must be plain JSON data");
103
+ }
51
104
  const first = lookProblems(raw)[0];
52
105
  if (first)
53
106
  throw new Error(first.message);
54
- return raw.map((l) => ({ id: l.id, name: l.name, priceUsd: l.priceUsd, change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor } }));
107
+ return raw.map((l) => {
108
+ const slots = cleanSlots(l.slots);
109
+ return { id: l.id, name: l.name, priceUsd: l.priceUsd, change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor }, ...(slots ? { slots } : {}) };
110
+ });
55
111
  }
56
112
  /** "$0.25" */
57
113
  export const usdText = (usd) => `$${usd.toFixed(2)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton) and the looks ladder.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,7 +10,13 @@
10
10
  "./character": { "types": "./dist/character/index.d.ts", "import": "./dist/character/index.js" },
11
11
  "./character/record": { "types": "./dist/character/pure.d.ts", "import": "./dist/character/pure.js" },
12
12
  "./ticket": { "types": "./dist/ticket/index.d.ts", "import": "./dist/ticket/index.js" },
13
- "./looks": { "types": "./dist/looks/index.d.ts", "import": "./dist/looks/index.js" }
13
+ "./looks": { "types": "./dist/looks/index.d.ts", "import": "./dist/looks/index.js" },
14
+ "./admin": { "types": "./dist/admin/index.d.ts", "import": "./dist/admin/index.js" },
15
+ "./admin/conformance": { "types": "./dist/admin/conformance.d.ts", "import": "./dist/admin/conformance.js" },
16
+ "./admin/d1": { "types": "./dist/admin/adapters/d1.d.ts", "import": "./dist/admin/adapters/d1.js" },
17
+ "./admin/firestore": { "types": "./dist/admin/adapters/firestore.d.ts", "import": "./dist/admin/adapters/firestore.js" },
18
+ "./admin/panel": { "types": "./dist/admin/panel/index.d.ts", "import": "./dist/admin/panel/index.js" },
19
+ "./admin/style.css": "./src/admin/panel/style.css"
14
20
  },
15
21
  "sideEffects": false,
16
22
  "files": ["dist", "src", "README.md", "CHANGELOG.md", "LICENSE", "ai"],
@@ -25,6 +31,7 @@
25
31
  "devDependencies": {
26
32
  "@types/node": "^26.6.3",
27
33
  "@types/three": "^0.186.0",
34
+ "happy-dom": "^20.14.6",
28
35
  "three": "^0.186.0",
29
36
  "tsx": "^4.23.15",
30
37
  "typescript": "5.9"
@@ -0,0 +1,94 @@
1
+ // The admin store for a game on Cloudflare D1, with the tables of the game starter (migrations/0001_init.sql): `meta`, `orders` and `owned`.
2
+ // It writes ONLY the two settings (`setting:character`, `setting:signin` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
3
+ // (`logins: true`): then the one other write is `DELETE FROM sessions WHERE scope = 'admin'` ("sign out everywhere").
4
+ //
5
+ // import { createD1Store } from "@nano133/nanoterra/admin/d1";
6
+ // const store = createD1Store(env.DB);
7
+
8
+ import { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, type OrderRow } from "../summary.js";
9
+ import type { AdminStore, StoreSummary } from "../store.js";
10
+ import type { GamePatch } from "../switches.js";
11
+
12
+ /** The part of D1 that the adapter uses. Cloudflare's `D1Database` fits it. */
13
+ export interface D1StatementLike {
14
+ bind(...values: unknown[]): D1StatementLike;
15
+ all<T = unknown>(): Promise<{ results: T[] }>;
16
+ first<T = unknown>(): Promise<T | null>;
17
+ run(): Promise<unknown>;
18
+ }
19
+ export interface D1Like {
20
+ prepare(query: string): D1StatementLike;
21
+ batch(statements: D1StatementLike[]): Promise<unknown[]>;
22
+ }
23
+
24
+ export const D1_KEYS = { character: "setting:character", signin: "setting:signin" } as const;
25
+
26
+ type Row = { id: string; player: string; look_id: string; usd: string; amount: string; status: string; created_at: number; expires_at: number; paid_at: number | null; payer: string | null; hash: string | null };
27
+
28
+ /**
29
+ * The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
30
+ * (`DELETE FROM sessions WHERE scope = 'admin'`, and `UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0` so that a request signed a moment ago
31
+ * cannot make a new login afterwards, and a wallet app that is answered later is told that the request has ended): the game's `sessions` and `signin_requests` tables must have a `scope` column ('admin' for an admin login or request) and the game must write
32
+ * `admin_logins` when it makes an admin login. Without the option the store has neither call, so a game that has not made those tables is not broken by it.
33
+ */
34
+ export function createD1Store(db: D1Like, options: { logins?: boolean } = {}): AdminStore {
35
+ const store: AdminStore = {
36
+ async readSettings() {
37
+ const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?)").bind(D1_KEYS.character, D1_KEYS.signin).all<{ key: string; value: string }>()).results;
38
+ const row = (key: string) => rows.find((r) => r.key === key)?.value;
39
+ return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin) };
40
+ },
41
+
42
+ async saveSettings(patch: GamePatch, now: number) {
43
+ const write = (key: string, value: string) =>
44
+ db.prepare("INSERT INTO meta (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at").bind(key, value, now);
45
+ const batch = [];
46
+ if (patch.character) batch.push(write(D1_KEYS.character, patch.character));
47
+ if (patch.signin) batch.push(write(D1_KEYS.signin, patch.signin));
48
+ if (batch.length) await db.batch(batch);
49
+ },
50
+
51
+ async summary(now: number): Promise<StoreSummary> {
52
+ // The game starter's own queries: the counts and the totals are over ALL orders, the list is the newest ORDERS_SHOWN.
53
+ const orders = (
54
+ await db
55
+ .prepare("SELECT id, player, look_id, usd, amount, status, created_at, expires_at, paid_at, payer, hash FROM orders ORDER BY created_at DESC LIMIT ?")
56
+ .bind(ORDERS_SHOWN)
57
+ .all<Row>()
58
+ ).results;
59
+ const owned = (await db.prepare("SELECT player, look_id, granted_at FROM owned ORDER BY player, granted_at").all<{ player: string; look_id: string; granted_at: number }>()).results;
60
+ const sums = (await db.prepare("SELECT status, amount, usd FROM orders WHERE status IN ('paid', 'paid_late')").all<{ status: string; amount: string; usd: string }>()).results;
61
+ const grouped = (await db.prepare("SELECT status, expires_at < ? AS past, COUNT(*) AS n FROM orders GROUP BY status, past").bind(now).all<{ status: string; past: number; n: number }>()).results;
62
+ const rate = await db.prepare("SELECT value, at FROM meta WHERE key = 'rate'").first<{ value: string; at: number }>();
63
+
64
+ const counts = { open: 0, paid: 0, expired: 0, paid_late: 0 };
65
+ for (const c of grouped) {
66
+ const key = c.status === "open" && c.past ? "expired" : c.status;
67
+ if (Object.hasOwn(counts, key)) counts[key as keyof typeof counts] += c.n;
68
+ }
69
+ const money = (status: string) => moneyOf(sums.filter((s) => s.status === status).map((s) => ({ amountRaw: s.amount, usd: s.usd })));
70
+ const rows: OrderRow[] = orders.map((o) => ({ id: o.id, player: o.player, lookId: o.look_id, usd: o.usd, amountRaw: o.amount, status: o.status, createdAt: o.created_at, expiresAt: o.expires_at, paidAt: o.paid_at, payer: o.payer, hash: o.hash }));
71
+ return {
72
+ counts,
73
+ totals: { paid: money("paid"), paidLate: money("paid_late") },
74
+ orders: shapeOrders(rows, now),
75
+ players: shapePlayers(owned.map((o) => ({ player: o.player, lookId: o.look_id, at: o.granted_at }))),
76
+ rate: parseRate(rate?.value, rate?.at),
77
+ };
78
+ },
79
+ };
80
+ if (options.logins) {
81
+ store.recentLogins = async (limit) => {
82
+ const n = Math.min(Math.max(Math.floor(Number(limit)) || 0, 0), 50);
83
+ const rows = (await db.prepare("SELECT at, country, browser FROM admin_logins ORDER BY at DESC, id DESC LIMIT ?").bind(n).all<{ at: number; country: string | null; browser: string }>()).results;
84
+ return rows.map((r) => ({ at: r.at, country: r.country, browser: r.browser }));
85
+ };
86
+ // One batch: the admin sessions are deleted, AND every admin sign-in request that was started but not delivered is marked delivered, so a request that the owner signed a moment
87
+ // ago cannot make a new admin login after "Sign out everywhere" (its check would answer "used").
88
+ store.signOutEverywhere = async () => {
89
+ const done = (await db.batch([db.prepare("DELETE FROM sessions WHERE scope = 'admin'"), db.prepare("UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0")])) as ({ meta?: { changes?: number } } | undefined)[];
90
+ return done[0]?.meta?.changes ?? 0;
91
+ };
92
+ }
93
+ return store;
94
+ }
@@ -0,0 +1,78 @@
1
+ // The admin store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface below, which the
2
+ // Admin SDK's `Firestore` fits. It writes ONLY the two settings; everything else it does is a read.
3
+ //
4
+ // The data model (the names a game must have; only the three collection names can be changed):
5
+ // meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "rate" (value is JSON { usd, sources })
6
+ // orders/{id} { player, lookId, usd, amount, status, createdAt, expiresAt, paidAt, payer, hash } `amount` is the raw amount as text
7
+ // owned/{player__lookId} { player, lookId, orderId, grantedAt }
8
+ //
9
+ // The summary reads the newest 5000 orders (the counts and the totals are over those) and every owned look: for a small game. A big game should keep running totals.
10
+
11
+ import { parseRate, summarize, type OrderRow } from "../summary.js";
12
+ import type { AdminStore, StoreSummary } from "../store.js";
13
+ import type { GamePatch } from "../switches.js";
14
+
15
+ export interface FirestoreDocLike {
16
+ get(): Promise<{ exists: boolean; data(): Record<string, unknown> | undefined }>;
17
+ }
18
+ export interface FirestoreQueryLike {
19
+ orderBy(field: string, direction?: "asc" | "desc"): FirestoreQueryLike;
20
+ limit(n: number): FirestoreQueryLike;
21
+ get(): Promise<{ docs: { id: string; data(): Record<string, unknown> }[] }>;
22
+ }
23
+ export interface FirestoreCollectionLike extends FirestoreQueryLike {
24
+ doc(id: string): FirestoreDocLike;
25
+ }
26
+ export interface FirestoreBatchLike {
27
+ set(doc: FirestoreDocLike, data: Record<string, unknown>, options?: { merge?: boolean }): unknown;
28
+ commit(): Promise<unknown>;
29
+ }
30
+ export interface FirestoreLike {
31
+ collection(name: string): FirestoreCollectionLike;
32
+ batch(): FirestoreBatchLike;
33
+ }
34
+
35
+ export const FIRESTORE_SCAN_LIMIT = 5000;
36
+ export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin" } as const;
37
+
38
+ const text = (v: unknown, fallback = ""): string => (typeof v === "string" ? v : fallback);
39
+ const num = (v: unknown, fallback = 0): number => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
40
+ const orNull = (v: unknown): string | null => (typeof v === "string" ? v : null);
41
+
42
+ export function createFirestoreStore(db: FirestoreLike, collections: { meta?: string; orders?: string; owned?: string } = {}): AdminStore {
43
+ const meta = collections.meta ?? "meta";
44
+ const orders = collections.orders ?? "orders";
45
+ const owned = collections.owned ?? "owned";
46
+ const valueOf = async (key: string) => {
47
+ const snap = await db.collection(meta).doc(key).get();
48
+ return snap.exists ? snap.data() : undefined;
49
+ };
50
+ return {
51
+ async readSettings() {
52
+ const [character, signin] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin)]);
53
+ return { character: character?.value, signin: signin?.value };
54
+ },
55
+
56
+ async saveSettings(patch: GamePatch, now: number) {
57
+ const batch = db.batch();
58
+ let n = 0;
59
+ if (patch.character) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.character), { value: patch.character, at: now }), n++);
60
+ if (patch.signin) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
61
+ if (n) await batch.commit();
62
+ },
63
+
64
+ async summary(now: number): Promise<StoreSummary> {
65
+ const [orderDocs, ownedDocs, rate] = await Promise.all([
66
+ db.collection(orders).orderBy("createdAt", "desc").limit(FIRESTORE_SCAN_LIMIT).get(),
67
+ db.collection(owned).get(),
68
+ valueOf("rate"),
69
+ ]);
70
+ const rows: OrderRow[] = orderDocs.docs.map((d) => {
71
+ const o = d.data();
72
+ return { id: d.id, player: text(o.player), lookId: text(o.lookId), usd: text(o.usd, "0"), amountRaw: text(o.amount, "0"), status: text(o.status, "open"), createdAt: num(o.createdAt), expiresAt: num(o.expiresAt), paidAt: typeof o.paidAt === "number" ? o.paidAt : null, payer: orNull(o.payer), hash: orNull(o.hash) };
73
+ });
74
+ const list = ownedDocs.docs.map((d) => ({ player: text(d.data().player), lookId: text(d.data().lookId), at: num(d.data().grantedAt) }));
75
+ return summarize(rows, list, parseRate(rate?.value, rate?.at), now);
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,14 @@
1
+ // Nano amounts for the summary. 1 XNO is 10^30 raw, more than a JavaScript number holds exactly, so amounts are decimal strings and the sums are BigInt.
2
+
3
+ export const RAW_PER_XNO = 10n ** 30n;
4
+
5
+ /** A raw amount as XNO with every significant digit: "0.2512". Throws on text that is not a whole number. */
6
+ export function xnoText(raw: string): string {
7
+ const n = BigInt(raw);
8
+ const whole = n / RAW_PER_XNO;
9
+ const frac = (n % RAW_PER_XNO).toString().padStart(30, "0").replace(/0+$/, "");
10
+ return frac ? `${whole}.${frac}` : `${whole}`;
11
+ }
12
+
13
+ /** A raw amount that can be added: digits only, at most 39 of them. Anything else is not counted (a bad row must not break the summary). */
14
+ export const isRawText = (s: unknown): s is string => typeof s === "string" && /^[0-9]{1,39}$/.test(s);