@nano133/nanoterra 0.8.0 → 0.9.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 (132) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/LICENSE +3 -0
  3. package/README.md +363 -25
  4. package/ai/SPEC.md +65 -3
  5. package/dist/admin/panel/dom.d.ts +2 -0
  6. package/dist/admin/panel/dom.js +6 -0
  7. package/dist/admin/panel/mount.d.ts +3 -0
  8. package/dist/admin/panel/mount.js +4 -2
  9. package/dist/emotes/panel/mount.d.ts +5 -0
  10. package/dist/emotes/panel/mount.js +7 -3
  11. package/dist/friends/panel/mount.d.ts +5 -0
  12. package/dist/friends/panel/mount.js +5 -2
  13. package/dist/options/panel/mount.d.ts +5 -0
  14. package/dist/options/panel/mount.js +5 -3
  15. package/dist/player-signin/adapters/d1.d.ts +10 -0
  16. package/dist/player-signin/adapters/d1.js +108 -0
  17. package/dist/player-signin/adapters/firestore.d.ts +45 -0
  18. package/dist/player-signin/adapters/firestore.js +168 -0
  19. package/dist/player-signin/conformance.d.ts +11 -0
  20. package/dist/player-signin/conformance.js +160 -0
  21. package/dist/player-signin/handler.d.ts +64 -0
  22. package/dist/player-signin/handler.js +364 -0
  23. package/dist/player-signin/host.d.ts +4 -0
  24. package/dist/player-signin/host.js +10 -0
  25. package/dist/player-signin/index.d.ts +9 -0
  26. package/dist/player-signin/index.js +15 -0
  27. package/dist/player-signin/memory.d.ts +15 -0
  28. package/dist/player-signin/memory.js +101 -0
  29. package/dist/player-signin/panel/index.d.ts +9 -0
  30. package/dist/player-signin/panel/index.js +7 -0
  31. package/dist/player-signin/panel/memo.d.ts +12 -0
  32. package/dist/player-signin/panel/memo.js +41 -0
  33. package/dist/player-signin/panel/mount.d.ts +21 -0
  34. package/dist/player-signin/panel/mount.js +105 -0
  35. package/dist/player-signin/panel/view.d.ts +45 -0
  36. package/dist/player-signin/panel/view.js +360 -0
  37. package/dist/player-signin/panel/words.d.ts +8 -0
  38. package/dist/player-signin/panel/words.js +26 -0
  39. package/dist/player-signin/scheme-check.d.ts +3 -0
  40. package/dist/player-signin/scheme-check.js +14 -0
  41. package/dist/player-signin/scheme.d.ts +13 -0
  42. package/dist/player-signin/scheme.js +28 -0
  43. package/dist/player-signin/types.d.ts +116 -0
  44. package/dist/player-signin/types.js +33 -0
  45. package/dist/shop/adapters/d1.d.ts +25 -0
  46. package/dist/shop/adapters/d1.js +200 -0
  47. package/dist/shop/adapters/firestore.d.ts +53 -0
  48. package/dist/shop/adapters/firestore.js +245 -0
  49. package/dist/shop/address.d.ts +14 -0
  50. package/dist/shop/address.js +104 -0
  51. package/dist/shop/amount.d.ts +26 -0
  52. package/dist/shop/amount.js +59 -0
  53. package/dist/shop/conformance.d.ts +13 -0
  54. package/dist/shop/conformance.js +203 -0
  55. package/dist/shop/handler.d.ts +76 -0
  56. package/dist/shop/handler.js +156 -0
  57. package/dist/shop/index.d.ts +19 -0
  58. package/dist/shop/index.js +21 -0
  59. package/dist/shop/match.d.ts +23 -0
  60. package/dist/shop/match.js +58 -0
  61. package/dist/shop/memory.d.ts +13 -0
  62. package/dist/shop/memory.js +106 -0
  63. package/dist/shop/nodes.d.ts +47 -0
  64. package/dist/shop/nodes.js +106 -0
  65. package/dist/shop/orders.d.ts +47 -0
  66. package/dist/shop/orders.js +82 -0
  67. package/dist/shop/panel/index.d.ts +7 -0
  68. package/dist/shop/panel/index.js +11 -0
  69. package/dist/shop/panel/looks.d.ts +30 -0
  70. package/dist/shop/panel/looks.js +61 -0
  71. package/dist/shop/panel/mount.d.ts +52 -0
  72. package/dist/shop/panel/mount.js +194 -0
  73. package/dist/shop/panel/pay.d.ts +24 -0
  74. package/dist/shop/panel/pay.js +115 -0
  75. package/dist/shop/panel/qr.d.ts +9 -0
  76. package/dist/shop/panel/qr.js +281 -0
  77. package/dist/shop/price.d.ts +23 -0
  78. package/dist/shop/price.js +43 -0
  79. package/dist/shop/scan.d.ts +24 -0
  80. package/dist/shop/scan.js +80 -0
  81. package/dist/shop/security.d.ts +10 -0
  82. package/dist/shop/security.js +56 -0
  83. package/dist/shop/store.d.ts +101 -0
  84. package/dist/shop/store.js +4 -0
  85. package/dist/shop/worker.d.ts +43 -0
  86. package/dist/shop/worker.js +76 -0
  87. package/package.json +21 -6
  88. package/src/admin/panel/dom.ts +6 -0
  89. package/src/admin/panel/mount.ts +7 -2
  90. package/src/emotes/panel/mount.ts +9 -2
  91. package/src/friends/panel/mount.ts +8 -2
  92. package/src/options/panel/mount.ts +8 -3
  93. package/src/player-signin/adapters/d1.sql +45 -0
  94. package/src/player-signin/adapters/d1.ts +120 -0
  95. package/src/player-signin/adapters/firestore.ts +194 -0
  96. package/src/player-signin/conformance.ts +184 -0
  97. package/src/player-signin/handler.ts +404 -0
  98. package/src/player-signin/host.ts +12 -0
  99. package/src/player-signin/index.ts +20 -0
  100. package/src/player-signin/memory.ts +94 -0
  101. package/src/player-signin/panel/index.ts +12 -0
  102. package/src/player-signin/panel/memo.ts +49 -0
  103. package/src/player-signin/panel/mount.ts +127 -0
  104. package/src/player-signin/panel/style.css +473 -0
  105. package/src/player-signin/panel/view.ts +428 -0
  106. package/src/player-signin/panel/words.ts +32 -0
  107. package/src/player-signin/scheme-check.ts +14 -0
  108. package/src/player-signin/scheme.ts +32 -0
  109. package/src/player-signin/types.ts +103 -0
  110. package/src/shop/adapters/d1.sql +52 -0
  111. package/src/shop/adapters/d1.ts +245 -0
  112. package/src/shop/adapters/firestore.ts +281 -0
  113. package/src/shop/address.ts +102 -0
  114. package/src/shop/amount.ts +66 -0
  115. package/src/shop/conformance.ts +232 -0
  116. package/src/shop/handler.ts +202 -0
  117. package/src/shop/index.ts +30 -0
  118. package/src/shop/match.ts +63 -0
  119. package/src/shop/memory.ts +102 -0
  120. package/src/shop/nodes.ts +115 -0
  121. package/src/shop/orders.ts +93 -0
  122. package/src/shop/panel/index.ts +15 -0
  123. package/src/shop/panel/looks.ts +106 -0
  124. package/src/shop/panel/mount.ts +241 -0
  125. package/src/shop/panel/pay.ts +171 -0
  126. package/src/shop/panel/qr.ts +256 -0
  127. package/src/shop/panel/style.css +534 -0
  128. package/src/shop/price.ts +52 -0
  129. package/src/shop/scan.ts +90 -0
  130. package/src/shop/security.ts +50 -0
  131. package/src/shop/store.ts +99 -0
  132. package/src/shop/worker.ts +107 -0
@@ -0,0 +1,13 @@
1
+ import { type ShopOrder, type ShopStore } from "./store.js";
2
+ export type MemoryShop = ShopStore & {
3
+ /** The rows as an adapter would keep them. */
4
+ readonly orders: Map<string, ShopOrder>;
5
+ /** "player\u0000lookId" -> the owned row. */
6
+ readonly ownedRows: Map<string, {
7
+ player: string;
8
+ lookId: string;
9
+ orderId: string;
10
+ grantedAt: number;
11
+ }>;
12
+ };
13
+ export declare function createMemoryShop(): MemoryShop;
@@ -0,0 +1,106 @@
1
+ // A shop store in memory, for tests (and a game's first try). Nothing is kept after the process ends, and nothing is shared between copies of a server:
2
+ // never sell with it. It keeps the same rules as the real adapters (the conformance suite runs on it), so it is the reference for what a store does.
3
+ import { paidStatus } from "./orders.js";
4
+ import { FORGOTTEN } from "./store.js";
5
+ export function createMemoryShop() {
6
+ const orders = new Map();
7
+ const ownedRows = new Map();
8
+ let rate = null;
9
+ let scanAt = null;
10
+ const copy = (o) => ({ ...o });
11
+ const holder = (amount) => [...orders.values()].find((o) => o.amountLock === amount);
12
+ const ownKey = (player, lookId) => `${player}\u0000${lookId}`;
13
+ const tidy = async (now) => {
14
+ for (const o of orders.values()) {
15
+ if (o.status === "open" && o.expiresAt < now)
16
+ o.status = "expired";
17
+ if (o.amountLock !== null && o.holdUntil < now)
18
+ o.amountLock = null;
19
+ }
20
+ };
21
+ return {
22
+ orders,
23
+ ownedRows,
24
+ tidy,
25
+ async heldAmounts(amounts) {
26
+ return new Set(amounts.filter((a) => holder(a)));
27
+ },
28
+ async insert(order) {
29
+ if (holder(order.amount) || orders.has(order.id))
30
+ return false;
31
+ orders.set(order.id, { ...copy(order), amountLock: order.amount });
32
+ return true;
33
+ },
34
+ async order(id) {
35
+ const o = orders.get(id);
36
+ return o ? copy(o) : null;
37
+ },
38
+ async openOrder(player, lookId, until) {
39
+ const list = [...orders.values()].filter((o) => o.player === player && o.lookId === lookId && o.status === "open" && o.expiresAt > until).sort((a, b) => b.createdAt - a.createdAt);
40
+ return list[0] ? copy(list[0]) : null;
41
+ },
42
+ async countOpen(player, now) {
43
+ return [...orders.values()].filter((o) => o.player === player && o.status === "open" && o.expiresAt > now).length;
44
+ },
45
+ async owns(player, lookId) {
46
+ return ownedRows.has(ownKey(player, lookId));
47
+ },
48
+ async owned(player) {
49
+ return [...ownedRows.values()].filter((r) => r.player === player).sort((a, b) => a.grantedAt - b.grantedAt).map((r) => r.lookId);
50
+ },
51
+ async held() {
52
+ return [...orders.values()].filter((o) => o.amountLock !== null).map(copy);
53
+ },
54
+ async usedHashes(hashes) {
55
+ const all = new Set([...orders.values()].map((o) => o.hash).filter((h) => h !== null));
56
+ return new Set(hashes.filter((h) => all.has(h)));
57
+ },
58
+ async settle(order, pay, now) {
59
+ const o = orders.get(order.id);
60
+ if (!o || (o.status !== "open" && o.status !== "expired") || o.amountLock === null)
61
+ return "taken";
62
+ if ([...orders.values()].some((x) => x.hash === pay.hash))
63
+ return "used";
64
+ const status = paidStatus(o, pay.seenMs);
65
+ Object.assign(o, { status, hash: pay.hash, payer: pay.payer, paidAt: pay.seenMs, amountLock: null });
66
+ if (status === "paid" && !ownedRows.has(ownKey(o.player, o.lookId)))
67
+ ownedRows.set(ownKey(o.player, o.lookId), { player: o.player, lookId: o.lookId, orderId: o.id, grantedAt: now });
68
+ return status;
69
+ },
70
+ async claimScan(now, gapMs) {
71
+ if (scanAt !== null && scanAt > now - gapMs)
72
+ return false;
73
+ scanAt = now;
74
+ return true;
75
+ },
76
+ async readRate() {
77
+ return rate ? { ...rate, sources: [...rate.sources] } : null;
78
+ },
79
+ async saveRate(r) {
80
+ rate = { usd: r.usd, sources: [...r.sources], at: r.at };
81
+ },
82
+ async forget(player, now) {
83
+ await tidy(now);
84
+ const mine = [...orders.values()].filter((o) => o.player === player);
85
+ const waiting = mine.filter((o) => o.amountLock !== null);
86
+ if (waiting.length)
87
+ return { deleted: 0, kept: 0, waitUntil: Math.max(...waiting.map((o) => o.holdUntil)) };
88
+ let deleted = 0;
89
+ let kept = 0;
90
+ for (const [k, r] of ownedRows)
91
+ if (r.player === player)
92
+ (ownedRows.delete(k), deleted++);
93
+ for (const o of mine) {
94
+ if (o.hash === null)
95
+ (orders.delete(o.id), deleted++);
96
+ else
97
+ (Object.assign(o, { player: FORGOTTEN, visitor: null }), kept++);
98
+ }
99
+ // A paid order keeps no payer; a paid-late one keeps it: the creator sends that payment back by hand.
100
+ for (const o of orders.values())
101
+ if (o.payer === player && o.status === "paid")
102
+ o.payer = FORGOTTEN;
103
+ return { deleted, kept, waitUntil: null };
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,47 @@
1
+ /** The `fetch` the shop uses (a test gives its own). */
2
+ export type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
3
+ export type NanoNode = {
4
+ name: string;
5
+ url: string;
6
+ headers?: Record<string, string>;
7
+ };
8
+ /** The keyed provider. Its key is a secret: the game reads it from its secret store and passes it, never writes it in code. */
9
+ export declare const KEYED_NODE: {
10
+ readonly name: "Nanswap Nodes";
11
+ readonly url: "https://nodes.nanswap.com/XNO";
12
+ readonly keyHeader: "nodes-api-key";
13
+ };
14
+ /** Public nodes with no key (from the community list at publicnodes.somenano.com, read 2026-10-21). */
15
+ export declare const PUBLIC_NODES: readonly NanoNode[];
16
+ /**
17
+ * An optional setting or secret: its value without spaces around it, or undefined when it is not set. A setup page that needs a value in every field
18
+ * (Cloudflare's "Deploy" page) gets "none", "skip", "off" or "-" (in any case), which also mean "not set".
19
+ */
20
+ export declare function settingValue(v?: string | null): string | undefined;
21
+ /**
22
+ * The nodes to ask, in order. `NODE_API_KEY`: the keyed provider's key (it goes first). `NANO_NODES`: the game's own node addresses, separated by
23
+ * commas, instead of the public list: https only, and plain http only for a node on this computer (local development with a mock node). The names are
24
+ * the game starter's, so `nodesFor(env)` works as it is.
25
+ */
26
+ export declare function nodesFor(settings?: {
27
+ NODE_API_KEY?: string | null;
28
+ NANO_NODES?: string | null;
29
+ }): NanoNode[];
30
+ export declare class NodeDown extends Error {
31
+ constructor(message: string);
32
+ }
33
+ export type RpcOptions = {
34
+ fetch?: Fetch;
35
+ timeoutMs?: number;
36
+ now?: () => number;
37
+ };
38
+ /**
39
+ * Sends one RPC call to the first node that answers. Returns its answer, or null when the node says the thing does not exist ("Account not found",
40
+ * "Block not found"). Throws NodeDown when no node answers.
41
+ */
42
+ export declare function rpc(nodes: readonly NanoNode[], body: Record<string, unknown>, o?: RpcOptions): Promise<{
43
+ node: string;
44
+ answer: Record<string, any> | null;
45
+ }>;
46
+ /** For tests: forget which nodes failed. */
47
+ export declare function forgetNodeHealth(): void;
@@ -0,0 +1,106 @@
1
+ // Talking to Nano nodes, with failover (the game starter's src/worker/nodes.ts). The shop holds no key and sends nothing: it only READS the ledger, to see
2
+ // whether the creator's address received a payment.
3
+ //
4
+ // The order of the list (`nodesFor`, the starter's defaults):
5
+ // 1. Nanswap Nodes, when the game gives its API key (the starter's secret NODE_API_KEY; a free key: https://nanswap.com/nano-node).
6
+ // 2. The game's own list of node addresses (the starter's setting NANO_NODES), or else the public nodes that need no key. Those are run by volunteers,
7
+ // with no promise of uptime.
8
+ // A node that does not answer, answers with an HTTP error or answers with an error is skipped, and the next one is asked. When none answers, the check
9
+ // fails closed: nothing is credited, and the order waits.
10
+ /** The keyed provider. Its key is a secret: the game reads it from its secret store and passes it, never writes it in code. */
11
+ export const KEYED_NODE = { name: "Nanswap Nodes", url: "https://nodes.nanswap.com/XNO", keyHeader: "nodes-api-key" };
12
+ /** Public nodes with no key (from the community list at publicnodes.somenano.com, read 2026-10-21). */
13
+ export const PUBLIC_NODES = [
14
+ { name: "rpc.nano.to", url: "https://rpc.nano.to" },
15
+ { name: "Rainstorm", url: "https://rainstorm.city/api" },
16
+ { name: "NanOslo", url: "https://nanoslo.0x.no/proxy" },
17
+ ];
18
+ /**
19
+ * An optional setting or secret: its value without spaces around it, or undefined when it is not set. A setup page that needs a value in every field
20
+ * (Cloudflare's "Deploy" page) gets "none", "skip", "off" or "-" (in any case), which also mean "not set".
21
+ */
22
+ export function settingValue(v) {
23
+ const s = (v ?? "").trim();
24
+ return s && !/^(none|skip|off|-)$/i.test(s) ? s : undefined;
25
+ }
26
+ /**
27
+ * The nodes to ask, in order. `NODE_API_KEY`: the keyed provider's key (it goes first). `NANO_NODES`: the game's own node addresses, separated by
28
+ * commas, instead of the public list: https only, and plain http only for a node on this computer (local development with a mock node). The names are
29
+ * the game starter's, so `nodesFor(env)` works as it is.
30
+ */
31
+ export function nodesFor(settings = {}) {
32
+ const list = [];
33
+ const key = settingValue(settings.NODE_API_KEY);
34
+ if (key)
35
+ list.push({ name: KEYED_NODE.name, url: KEYED_NODE.url, headers: { [KEYED_NODE.keyHeader]: key } });
36
+ const own = (settings.NANO_NODES ?? "")
37
+ .split(",")
38
+ .map((s) => s.trim())
39
+ .filter((s) => /^https:\/\/[^\s]+$/.test(s) || /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?(\/[^\s]*)?$/.test(s));
40
+ list.push(...(own.length ? own.map((url) => ({ name: new URL(url).host, url })) : PUBLIC_NODES.map((n) => ({ ...n }))));
41
+ return list;
42
+ }
43
+ export class NodeDown extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = "NodeDown";
47
+ }
48
+ }
49
+ /** Nodes that failed lately are asked last for a short time (in this copy of the server only). */
50
+ const restUntil = new Map();
51
+ const REST_MS = 30_000;
52
+ /**
53
+ * Sends one RPC call to the first node that answers. Returns its answer, or null when the node says the thing does not exist ("Account not found",
54
+ * "Block not found"). Throws NodeDown when no node answers.
55
+ */
56
+ export async function rpc(nodes, body, o = {}) {
57
+ if (!nodes.length)
58
+ throw new NodeDown("no Nano node is set");
59
+ const fetchFn = o.fetch ?? ((input, init) => fetch(input, init));
60
+ const now = o.now ?? Date.now;
61
+ const resting = (n) => (restUntil.get(n.url) ?? 0) > now();
62
+ const order = [...nodes.filter((n) => !resting(n)), ...nodes.filter(resting)];
63
+ const why = [];
64
+ for (const node of order) {
65
+ try {
66
+ const res = await fetchFn(node.url, {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/json", Accept: "application/json", "User-Agent": "nano-game-starter", ...node.headers },
69
+ body: JSON.stringify(body),
70
+ redirect: "manual",
71
+ signal: AbortSignal.timeout(o.timeoutMs ?? 5000),
72
+ });
73
+ const text = await res.text();
74
+ let answer;
75
+ try {
76
+ answer = JSON.parse(text);
77
+ }
78
+ catch {
79
+ throw new Error(`HTTP ${res.status}, not JSON`);
80
+ }
81
+ if (!answer || typeof answer !== "object")
82
+ throw new Error(`HTTP ${res.status}, not an object`);
83
+ const error = answer.error;
84
+ if (error !== undefined) {
85
+ if (/not found/i.test(String(error))) {
86
+ restUntil.delete(node.url);
87
+ return { node: node.name, answer: null };
88
+ }
89
+ throw new Error(String(error).slice(0, 80));
90
+ }
91
+ if (!res.ok)
92
+ throw new Error(`HTTP ${res.status}`);
93
+ restUntil.delete(node.url);
94
+ return { node: node.name, answer: answer };
95
+ }
96
+ catch (e) {
97
+ restUntil.set(node.url, now() + REST_MS);
98
+ why.push(`${node.name}: ${e instanceof Error ? e.message : String(e)}`);
99
+ }
100
+ }
101
+ throw new NodeDown(`no Nano node answered (${why.join("; ")})`);
102
+ }
103
+ /** For tests: forget which nodes failed. */
104
+ export function forgetNodeHealth() {
105
+ restUntil.clear();
106
+ }
@@ -0,0 +1,47 @@
1
+ import type { OrderStatus, ShopOrder, ShopStore } from "./store.js";
2
+ export declare const ORDER_TTL_MS: number;
3
+ export declare const LATE_HOLD_MS: number;
4
+ export declare const MAX_STEPS = 100;
5
+ export declare const MAX_EXTRA_PERCENT = 10;
6
+ /** One player can wait on this many orders at once. */
7
+ export declare const MAX_OPEN_PER_PLAYER = 3;
8
+ /** An order's id: 24 hex characters. */
9
+ export declare const ORDER_ID_PATTERN: RegExp;
10
+ /** Every amount for this price is held by another order: ask the player to try again in a minute. */
11
+ export declare class ShopBusy extends Error {
12
+ constructor(message?: string);
13
+ }
14
+ /** How many amounts a price may use: the price and the steps above it. At least 1. */
15
+ export declare function stepsFor(price: string): number;
16
+ /** The amounts an order of this price may ask for, smallest first. */
17
+ export declare function amountsFor(price: string): string[];
18
+ /** A new order id: 12 random bytes as hex. */
19
+ export declare const newOrderId: () => string;
20
+ export type ReserveInput = {
21
+ player: string;
22
+ lookId: string;
23
+ usd: string;
24
+ rate: number;
25
+ price: string;
26
+ visitor: string | null;
27
+ now: number;
28
+ ttlMs?: number;
29
+ };
30
+ /** Makes an order with the smallest free amount at or above `p.price` (a clean amount in raw). Throws ShopBusy when every allowed amount is held. */
31
+ export declare function reserveOrder(store: ShopStore, p: ReserveInput): Promise<ShopOrder>;
32
+ /** "paid" when the send was first seen while the order was open (with the clock allowance), else "paid_late". Every store uses this one rule. */
33
+ export declare const paidStatus: (order: Pick<ShopOrder, "expiresAt">, seenMs: number) => "paid" | "paid_late";
34
+ /** The status as the player reads it now: an open order past its time is "expired". */
35
+ export declare const statusAt: (o: Pick<ShopOrder, "status" | "expiresAt">, now: number) => OrderStatus;
36
+ /** The orders' smallest amount, or null (the scan's threshold). */
37
+ export declare const smallestAmount: (orders: readonly Pick<ShopOrder, "amount">[]) => string | null;
38
+ /** The order as its player sees it (the starter's `orderView`). The pay screen adds the XNO text, the test mode, the address and the link. */
39
+ export declare function orderView(o: ShopOrder, now: number): {
40
+ id: string;
41
+ lookId: string;
42
+ usd: string;
43
+ amount: string;
44
+ status: OrderStatus;
45
+ createdAt: number;
46
+ expiresAt: number;
47
+ };
@@ -0,0 +1,82 @@
1
+ // Orders: the clean amount and its reservation (the game starter's src/worker/orders.ts; the writes are the store's, ./store.ts).
2
+ // DO NOT CHANGE THE MONEY RULES HERE without the tests that pin them (the conformance suite, test/shop-*.test.ts).
3
+ //
4
+ // The reservation (the same rule as the Nano Unlock WordPress plugin):
5
+ // - An order asks for its price, rounded up to 0.0001 XNO. While another held order has that exact amount, it asks for one step (0.0001 XNO) more, and so
6
+ // on: at most a tenth of the price more, and at most 99 steps. When every step is held, the player is asked to try again in a moment.
7
+ // - No two held orders can ever ask for the same amount (the store's unique rule), so one payment can only match one order. Two orders made at the same
8
+ // instant cannot both take an amount: the store refuses the second, and it moves on to the next step.
9
+ // - The amount stays held from the order's start to LATE_HOLD_MS after it expires, so a late payment is still recognised (as "paid late") and never counts
10
+ // for a newer order. A paid order frees its amount at once (its payment's hash is recorded, so that payment never counts again).
11
+ import { compareRaw, isClean, plusSteps, STEP_RAW } from "./amount.js";
12
+ import { CLOCK_SLACK_MS } from "./match.js";
13
+ export const ORDER_TTL_MS = 15 * 60_000;
14
+ export const LATE_HOLD_MS = 60 * 60_000;
15
+ export const MAX_STEPS = 100;
16
+ export const MAX_EXTRA_PERCENT = 10;
17
+ /** One player can wait on this many orders at once. */
18
+ export const MAX_OPEN_PER_PLAYER = 3;
19
+ /** An order's id: 24 hex characters. */
20
+ export const ORDER_ID_PATTERN = /^[0-9a-f]{24}$/;
21
+ /** Every amount for this price is held by another order: ask the player to try again in a minute. */
22
+ export class ShopBusy extends Error {
23
+ constructor(message = "every amount for this price is held") {
24
+ super(message);
25
+ this.name = "ShopBusy";
26
+ }
27
+ }
28
+ /** How many amounts a price may use: the price and the steps above it. At least 1. */
29
+ export function stepsFor(price) {
30
+ const base = BigInt(price) / STEP_RAW;
31
+ return Math.max(1, Math.min(MAX_STEPS, Number((base * BigInt(MAX_EXTRA_PERCENT)) / 100n) + 1));
32
+ }
33
+ /** The amounts an order of this price may ask for, smallest first. */
34
+ export function amountsFor(price) {
35
+ if (!isClean(price))
36
+ throw new Error("the price must be a clean amount");
37
+ return Array.from({ length: stepsFor(price) }, (_, k) => plusSteps(price, k));
38
+ }
39
+ /** A new order id: 12 random bytes as hex. */
40
+ export const newOrderId = () => Array.from(globalThis.crypto.getRandomValues(new Uint8Array(12)), (x) => x.toString(16).padStart(2, "0")).join("");
41
+ /** Makes an order with the smallest free amount at or above `p.price` (a clean amount in raw). Throws ShopBusy when every allowed amount is held. */
42
+ export async function reserveOrder(store, p) {
43
+ const amounts = amountsFor(p.price);
44
+ await store.tidy(p.now);
45
+ const held = await store.heldAmounts(amounts);
46
+ const expiresAt = p.now + (p.ttlMs ?? ORDER_TTL_MS);
47
+ for (const amount of amounts) {
48
+ if (held.has(amount))
49
+ continue;
50
+ const order = {
51
+ id: newOrderId(),
52
+ player: p.player,
53
+ lookId: p.lookId,
54
+ usd: p.usd,
55
+ rate: p.rate,
56
+ amount,
57
+ amountLock: amount,
58
+ status: "open",
59
+ createdAt: p.now,
60
+ expiresAt,
61
+ holdUntil: expiresAt + LATE_HOLD_MS,
62
+ hash: null,
63
+ payer: null,
64
+ paidAt: null,
65
+ visitor: p.visitor,
66
+ };
67
+ // False: another order took this amount since the read. Go on to the next step.
68
+ if (await store.insert(order))
69
+ return order;
70
+ }
71
+ throw new ShopBusy();
72
+ }
73
+ /** "paid" when the send was first seen while the order was open (with the clock allowance), else "paid_late". Every store uses this one rule. */
74
+ export const paidStatus = (order, seenMs) => (seenMs > order.expiresAt + CLOCK_SLACK_MS ? "paid_late" : "paid");
75
+ /** The status as the player reads it now: an open order past its time is "expired". */
76
+ export const statusAt = (o, now) => (o.status === "open" && o.expiresAt < now ? "expired" : o.status);
77
+ /** The orders' smallest amount, or null (the scan's threshold). */
78
+ export const smallestAmount = (orders) => orders.map((o) => o.amount).sort(compareRaw)[0] ?? null;
79
+ /** The order as its player sees it (the starter's `orderView`). The pay screen adds the XNO text, the test mode, the address and the link. */
80
+ export function orderView(o, now) {
81
+ return { id: o.id, lookId: o.lookId, usd: o.usd, amount: o.amount, status: statusAt(o, now), createdAt: o.createdAt, expiresAt: o.expiresAt };
82
+ }
@@ -0,0 +1,7 @@
1
+ export { mountShop } from "./mount.js";
2
+ export type { MountedShop, ShopMountOptions } from "./mount.js";
3
+ export { looksView, slotsOf } from "./looks.js";
4
+ export type { LooksViewOptions, SlotWords } from "./looks.js";
5
+ export { PAY_TIMING, isTestOrder, payPanel } from "./pay.js";
6
+ export type { PayOptions, PayPanel } from "./pay.js";
7
+ export { qrModules, qrSvg, qrVersionFor } from "./qr.js";
@@ -0,0 +1,11 @@
1
+ // "@nano133/nanoterra/shop/panel": the Looks key and the shop's sheet, drawn in the browser. Plain DOM, no framework, no Three.js, no QR library.
2
+ //
3
+ // import { mountShop } from "@nano133/nanoterra/shop/panel";
4
+ // import "@nano133/nanoterra/shop/style.css";
5
+ // const shop = mountShop(document.getElementById("shop")!, { looks, owned: () => me.owned, signedIn: () => !!me.address, signIn, worn, wear, onOwned });
6
+ //
7
+ // The server part is "@nano133/nanoterra/shop" (the handler and the stores): it must not load this one.
8
+ export { mountShop } from "./mount.js";
9
+ export { looksView, slotsOf } from "./looks.js";
10
+ export { PAY_TIMING, isTestOrder, payPanel } from "./pay.js";
11
+ export { qrModules, qrSvg, qrVersionFor } from "./qr.js";
@@ -0,0 +1,30 @@
1
+ import { type Look } from "../../looks/look.js";
2
+ /** A tab's words: its key's word, and its plain choice's name (an item slot's: "Plain shovel", "No charm"). */
3
+ export type SlotWords = {
4
+ tab: string;
5
+ plain?: string;
6
+ };
7
+ export type LooksViewOptions = {
8
+ looks: readonly Look[];
9
+ owned: ReadonlySet<string>;
10
+ signedIn: boolean;
11
+ /** Whether the player wears this look now. */
12
+ worn(look: Look): boolean;
13
+ wear(look: Look): void;
14
+ /** Takes off the item of a wear slot (back to the plain tool, or none). With it, each item tab starts with the plain choice. */
15
+ takeOff?: (slot: string) => void;
16
+ /** Whether nothing is worn in this slot (the plain choice is worn). Default: no look of the slot is worn. */
17
+ plainWorn?: (slot: string) => boolean;
18
+ buy(look: Look): void;
19
+ /** The game's own picture of a look (a canvas, an image), or null for the kit's swatch. */
20
+ picture?: (look: Look) => HTMLElement | null;
21
+ /** Words for the tabs, by wear slot ("outfit" and the game's own). Default: "Outfits", then each slot's name. */
22
+ slots?: Record<string, SlotWords>;
23
+ /** The tab that is open (a wear slot). Default: the first. */
24
+ tab?: string;
25
+ /** A tab was chosen: the sheet draws again with it. */
26
+ onTab?: (slot: string) => void;
27
+ };
28
+ /** The wear slots of a list, in the order they first show (the outfits first). */
29
+ export declare function slotsOf(looks: readonly Look[]): string[];
30
+ export declare function looksView(p: LooksViewOptions): HTMLElement;
@@ -0,0 +1,61 @@
1
+ // The looks sheet (the game starter's src/client/ui/looks.ts, with Echo Island's tabs for a game with wear slots): wear a look you own, or buy one.
2
+ // Every look is shown with its price before any step: nothing is random, and there are no loot boxes.
3
+ //
4
+ // A game that sells only outfits gets one list. A game with items of its own wear slots ("wear": "shovel") gets a tab for each slot, the outfits first;
5
+ // with `takeOff`, each item tab starts with its plain choice (free, "No charm").
6
+ import { h } from "../../admin/panel/dom.js";
7
+ import { OUTFIT, isOutfit, usdText, wearOf } from "../../looks/look.js";
8
+ const HAT_WORDS = { cap: "with a cap", beanie: "with a beanie", crown: "with a crown", tophat: "with a top hat" };
9
+ const titleCase = (s) => s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, " ");
10
+ /** The wear slots of a list, in the order they first show (the outfits first). */
11
+ export function slotsOf(looks) {
12
+ const out = [OUTFIT];
13
+ for (const l of looks)
14
+ if (!out.includes(wearOf(l)))
15
+ out.push(wearOf(l));
16
+ return looks.some((l) => wearOf(l) === OUTFIT) ? out : out.slice(1);
17
+ }
18
+ function swatchOf(look) {
19
+ const swatch = h("span", { class: "n133s-swatch", "aria-hidden": "true" });
20
+ if (isOutfit(look)) {
21
+ swatch.style.background = look.change.body;
22
+ if (look.change.hat) {
23
+ const hat = h("span", { class: `n133s-swatch-hat n133s-hat-${look.change.hat}` });
24
+ hat.style.background = look.change.hatColor ?? "#333";
25
+ swatch.append(hat);
26
+ }
27
+ }
28
+ else {
29
+ swatch.classList.add("n133s-swatch-item");
30
+ swatch.textContent = look.name.charAt(0).toUpperCase();
31
+ }
32
+ return swatch;
33
+ }
34
+ export function looksView(p) {
35
+ const slots = slotsOf(p.looks);
36
+ const tab = p.tab && slots.includes(p.tab) ? p.tab : slots[0];
37
+ const words = (slot) => p.slots?.[slot] ?? (slot === OUTFIT ? { tab: "Outfits" } : { tab: titleCase(slot), plain: `No ${slot.replace(/-/g, " ")}` });
38
+ const list = h("ul", { class: "n133s-looks" });
39
+ const row = (pic, name, line, action, worn) => h("li", { class: `n133s-look${worn ? " n133s-look-worn" : ""}` }, pic, h("span", { class: "n133s-look-text" }, h("strong", {}, name), h("small", {}, line)), action);
40
+ if (tab !== OUTFIT && p.takeOff) {
41
+ const plain = p.plainWorn ? p.plainWorn(tab) : !p.looks.some((l) => wearOf(l) === tab && p.worn(l));
42
+ const action = h("button", { class: `n133s-btn${plain ? "" : " n133s-primary"}`, type: "button", disabled: plain, onclick: () => p.takeOff(tab) }, plain ? "Wearing" : "Wear");
43
+ list.append(row(h("span", { class: "n133s-swatch n133s-swatch-plain", "aria-hidden": "true" }), words(tab).plain ?? `No ${tab}`, "free", action, plain));
44
+ }
45
+ for (const look of p.looks) {
46
+ if (wearOf(look) !== tab)
47
+ continue;
48
+ const mine = look.priceUsd === 0 || p.owned.has(look.id);
49
+ const worn = p.worn(look);
50
+ const action = mine
51
+ ? h("button", { class: `n133s-btn${worn ? "" : " n133s-primary"}`, type: "button", disabled: worn, onclick: () => p.wear(look) }, worn ? "Wearing" : "Wear")
52
+ : h("button", { class: "n133s-btn n133s-buy", type: "button", onclick: () => p.buy(look) }, `Buy ${usdText(look.priceUsd)}`);
53
+ const what = isOutfit(look) ? (look.change.hat ? HAT_WORDS[look.change.hat] : "colour only") : tab.replace(/-/g, " ");
54
+ const state = mine ? (look.priceUsd === 0 ? " · free" : " · yours") : "";
55
+ list.append(row(p.picture?.(look) ?? swatchOf(look), look.name, `${what}${state}`, action, worn));
56
+ }
57
+ const tabs = slots.length > 1
58
+ ? h("div", { class: "n133s-tabs", role: "tablist", "aria-label": "Kinds of looks" }, ...slots.map((s) => h("button", { class: "n133s-tab", type: "button", role: "tab", "aria-selected": s === tab ? "true" : "false", onclick: () => p.onTab?.(s) }, words(s).tab)))
59
+ : null;
60
+ return h("div", { class: "n133s-looks-view" }, tabs, list, h("p", { class: "n133s-fine" }, "A look changes how your character looks. It never changes a score. You pay in Nano (XNO), straight to the game's creator."), p.signedIn ? null : h("p", { class: "n133s-fine" }, "To buy a look, sign in with Nano first: your looks belong to your Nano address."));
61
+ }
@@ -0,0 +1,52 @@
1
+ import { type Look } from "../../looks/look.js";
2
+ import { type SlotWords } from "./looks.js";
3
+ export type ShopMountOptions = {
4
+ /** The game's looks (looks.json as it was read). The server decides every price; these are for the list. */
5
+ looks: unknown;
6
+ /** Where the shop's routes are. Default "/api/orders" (the starter's). */
7
+ base?: string;
8
+ /** The ids of the looks the player owns (free looks need not be listed). Read each time the sheet draws. */
9
+ owned(): readonly string[];
10
+ signedIn(): boolean;
11
+ /** The game's sign-in: true when a player is signed in after it. With none, Buy asks the player to sign in and stops. */
12
+ signIn?: () => Promise<boolean>;
13
+ /** Whether the player wears this look now. */
14
+ worn(look: Look): boolean;
15
+ /** Wear a look the player owns. */
16
+ wear(look: Look): void;
17
+ /** Take off the item of a wear slot (back to the plain tool, or none). */
18
+ takeOff?: (slot: string) => void;
19
+ /** A paid order gave this look: the game adds it to the player's looks (and wears it). */
20
+ onOwned?: (lookId: string, look: Look) => void;
21
+ /** The person check (Turnstile): draws the check in `box` and gives its token, or null when the game has none. */
22
+ personCheck?: (box: HTMLElement) => Promise<string | null>;
23
+ /** The game's own picture of a look, or null for the kit's swatch. */
24
+ picture?: (look: Look) => HTMLElement | null;
25
+ /** Words for the tabs of the wear slots. */
26
+ slots?: Record<string, SlotWords>;
27
+ /** The sheet's title. Default "Looks". */
28
+ title?: string;
29
+ /** Draw the Looks key. Default true; false: the game calls `open()` from its own key. */
30
+ key?: boolean;
31
+ /** "light", "dark", or "device" (the default): the page's `data-n133-theme` when it has one, else the device's setting. */
32
+ theme?: "light" | "dark" | "device";
33
+ onOpen?: () => void;
34
+ onClose?: () => void;
35
+ /** Where the keyboard focus goes when the sheet closes: "game" (the default: out of the sheet, to the page) or "key". */
36
+ focusAfterClose?: "game" | "key";
37
+ /** The `fetch` to use (default: the page's). */
38
+ fetch?: typeof fetch;
39
+ };
40
+ export type MountedShop = {
41
+ /** Opens the sheet on the looks. */
42
+ open(): void;
43
+ close(): void;
44
+ /** Opens the sheet on Buy for one look (the game's own "Buy" elsewhere). */
45
+ buy(lookId: string): void;
46
+ /** Draws the looks again (after the player signed in, or wore something). */
47
+ refresh(): void;
48
+ readonly isOpen: boolean;
49
+ /** Removes the key and the sheet, and stops every timer. */
50
+ destroy(): void;
51
+ };
52
+ export declare function mountShop(root: HTMLElement, options: ShopMountOptions): MountedShop;