@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,194 @@
1
+ // The Looks key and the shop's sheet: `mountShop(element, options)`. Plain DOM, no framework, no Three.js. The sheet holds the looks (wear or buy) and,
2
+ // after Buy, the pay sheet of the order. It talks to the shop's routes ("@nano133/nanoterra/shop", `base`), and to the game only through its hooks:
3
+ //
4
+ // owned() the looks the player owns (the game's GET /api/me gives them) signedIn() whether a player is signed in
5
+ // signIn() the game's own sign-in (the kit's sign-in path, or the game's): true when the player is signed in after it. The shop draws no sign-in.
6
+ // worn/wear what the player wears; `onOwned(lookId)` when a paid order gave a look (wear it)
7
+ //
8
+ // `key: false` draws no key: the game opens the sheet from its own key with `open()`, as the options panel allows. The sheet is a <dialog>: it rises from
9
+ // the bottom on a phone held upright and stands in the middle on a wide screen; on a phone held sideways the pay sheet puts the QR code beside the words.
10
+ import { ApiError, createApi } from "../../admin/panel/api.js";
11
+ import { h, releaseFocus } from "../../admin/panel/dom.js";
12
+ import { icon } from "../../admin/panel/icons.js";
13
+ import { checkLooks, wearOf } from "../../looks/look.js";
14
+ import { looksView, slotsOf } from "./looks.js";
15
+ import { payPanel } from "./pay.js";
16
+ export function mountShop(root, options) {
17
+ const looks = checkLooks(options.looks);
18
+ const api = createApi(options.base ?? "/api/orders", options.fetch);
19
+ const bought = new Set();
20
+ let tab;
21
+ let stopPay = null;
22
+ let destroyed = false;
23
+ const cleanup = [];
24
+ const title = h("h2", { class: "n133s-title", tabindex: "-1" });
25
+ const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": "Close" }, icon("close", 20));
26
+ const body = h("div", { class: "n133s-body" });
27
+ const dialog = h("dialog", { class: "n133s-sheet", "aria-label": options.title ?? "Looks" }, h("header", { class: "n133s-head" }, title, closeKey), body);
28
+ const key = options.key === false ? null : h("button", { class: "n133s-key", type: "button", "aria-haspopup": "dialog" }, icon("shirt", 20), h("span", { class: "n133s-key-word" }, options.title ?? "Looks"));
29
+ const wrap = h("div", { class: "n133-shop" }, key, dialog);
30
+ root.append(wrap);
31
+ // The theme: the game's word, or the page's (data-n133-theme), or the device's setting.
32
+ let media = null;
33
+ try {
34
+ media = typeof globalThis.matchMedia === "function" ? globalThis.matchMedia("(prefers-color-scheme: dark)") : null;
35
+ }
36
+ catch {
37
+ media = null;
38
+ }
39
+ const drawTheme = () => {
40
+ const want = options.theme ?? "device";
41
+ const page = globalThis.document?.documentElement?.getAttribute("data-n133-theme");
42
+ const dark = want === "device" ? (page === "dark" || page === "light" ? page === "dark" : media?.matches === true) : want === "dark";
43
+ wrap.setAttribute("data-theme", dark ? "dark" : "light");
44
+ };
45
+ drawTheme();
46
+ media?.addEventListener?.("change", drawTheme);
47
+ cleanup.push(() => media?.removeEventListener?.("change", drawTheme));
48
+ const isOpen = () => dialog.open || dialog.hasAttribute("open");
49
+ const stopTimers = () => {
50
+ stopPay?.();
51
+ stopPay = null;
52
+ };
53
+ const show = (heading, content) => {
54
+ stopTimers();
55
+ title.textContent = heading;
56
+ body.replaceChildren(content);
57
+ body.scrollTop = 0;
58
+ if (!isOpen()) {
59
+ if (typeof dialog.showModal === "function")
60
+ dialog.showModal();
61
+ else
62
+ dialog.setAttribute("open", "");
63
+ // The first focus is on the title (a screen reader reads it), not on the close key: a ring on Close at every open draws the eye from the looks.
64
+ title.focus();
65
+ key?.setAttribute("aria-expanded", "true");
66
+ options.onOpen?.();
67
+ }
68
+ };
69
+ const closed = () => {
70
+ stopTimers();
71
+ key?.setAttribute("aria-expanded", "false");
72
+ options.onClose?.();
73
+ if (options.focusAfterClose === "key" && key)
74
+ key.focus();
75
+ else
76
+ releaseFocus(wrap);
77
+ };
78
+ const close = () => {
79
+ if (!isOpen())
80
+ return;
81
+ if (typeof dialog.close === "function" && dialog.open)
82
+ dialog.close();
83
+ else {
84
+ dialog.removeAttribute("open");
85
+ closed();
86
+ }
87
+ };
88
+ dialog.addEventListener("close", closed);
89
+ dialog.addEventListener("click", (e) => {
90
+ if (e.target === dialog)
91
+ close();
92
+ });
93
+ closeKey.addEventListener("click", close);
94
+ key?.addEventListener("click", () => (isOpen() ? close() : showLooks()));
95
+ const ownedNow = () => new Set([...options.owned(), ...bought]);
96
+ function showLooks() {
97
+ if (destroyed)
98
+ return;
99
+ show(options.title ?? "Looks", looksView({
100
+ looks,
101
+ owned: ownedNow(),
102
+ signedIn: options.signedIn(),
103
+ worn: options.worn,
104
+ wear: (l) => {
105
+ options.wear(l);
106
+ showLooks();
107
+ },
108
+ takeOff: options.takeOff
109
+ ? (slot) => {
110
+ options.takeOff(slot);
111
+ showLooks();
112
+ }
113
+ : undefined,
114
+ buy: (l) => void buy(l),
115
+ picture: options.picture,
116
+ slots: options.slots,
117
+ tab,
118
+ onTab: (s) => {
119
+ tab = s;
120
+ showLooks();
121
+ body.querySelector('.n133s-tab[aria-selected="true"]')?.focus();
122
+ },
123
+ }));
124
+ }
125
+ const back = (message, extra) => h("div", { class: "n133s-message" }, h("p", { class: "n133s-status" }, message), extra ?? null, h("button", { class: "n133s-btn n133s-wide", type: "button", onclick: () => showLooks() }, "Back to looks"));
126
+ async function buy(look) {
127
+ if (destroyed)
128
+ return;
129
+ tab = slotsOf(looks).length > 1 ? wearOf(look) : undefined;
130
+ if (!options.signedIn()) {
131
+ if (!options.signIn)
132
+ return show(`Buy ${look.name}`, back("To buy a look, sign in with Nano first: your looks belong to your Nano address."));
133
+ let ok = false;
134
+ try {
135
+ ok = await options.signIn();
136
+ }
137
+ catch {
138
+ ok = false;
139
+ }
140
+ if (!ok || !options.signedIn())
141
+ return showLooks();
142
+ }
143
+ const box = h("div", { class: "n133s-turnstile", hidden: true });
144
+ show(`Buy ${look.name}`, h("div", {}, h("p", { class: "n133s-status" }, "Making your order…"), box));
145
+ try {
146
+ const turnstile = options.personCheck ? await options.personCheck(box) : null;
147
+ const order = await api.post("", { lookId: look.id, turnstile });
148
+ if (destroyed)
149
+ return;
150
+ const pay = payPanel(order, {
151
+ lookName: look.name,
152
+ check: (id) => api.post(`/${id}/check`),
153
+ errorText: (e) => (e instanceof ApiError ? e.message : null),
154
+ onPaid: () => {
155
+ bought.add(look.id);
156
+ options.onOwned?.(look.id, look);
157
+ },
158
+ });
159
+ show(`Buy ${look.name}`, pay.node);
160
+ stopPay = pay.stop;
161
+ }
162
+ catch (e) {
163
+ const message = e instanceof ApiError ? e.message : e instanceof Error && e.message ? e.message : "The order did not start. Try again.";
164
+ const again = e instanceof ApiError && e.status === 401 && options.signIn ? h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", onclick: () => void buy(look) }, "Sign in") : null;
165
+ show(`Buy ${look.name}`, back(message, again));
166
+ }
167
+ }
168
+ return {
169
+ open: showLooks,
170
+ close,
171
+ buy(lookId) {
172
+ const look = looks.find((l) => l.id === lookId);
173
+ if (look)
174
+ void buy(look);
175
+ },
176
+ refresh() {
177
+ if (isOpen() && body.querySelector(".n133s-looks-view"))
178
+ showLooks();
179
+ },
180
+ get isOpen() {
181
+ return isOpen();
182
+ },
183
+ destroy() {
184
+ if (destroyed)
185
+ return;
186
+ close();
187
+ destroyed = true;
188
+ stopTimers();
189
+ for (const fn of cleanup.splice(0))
190
+ fn();
191
+ wrap.remove();
192
+ },
193
+ };
194
+ }
@@ -0,0 +1,24 @@
1
+ import type { PayView } from "../handler.js";
2
+ export declare const PAY_TIMING: {
3
+ readonly pollMs: 5000;
4
+ readonly checkForMs: number;
5
+ };
6
+ export type PayOptions = {
7
+ /** The look's name, for the line at the top. */
8
+ lookName: string;
9
+ /** POST {base}/{id}/check: the order again, after a payment scan. */
10
+ check(id: string): Promise<PayView>;
11
+ /** The order is paid: the look is the player's now. */
12
+ onPaid(order: PayView): void;
13
+ /** Words of a failed check (an ApiError's message), or null for "No connection". */
14
+ errorText?(e: unknown): string | null;
15
+ /** The time now in ms (a test gives its own). */
16
+ now?: () => number;
17
+ };
18
+ export type PayPanel = {
19
+ node: HTMLElement;
20
+ stop(): void;
21
+ };
22
+ /** True when this order must show the test stamp: the server said test mode, or it sent no address or no link. */
23
+ export declare const isTestOrder: (o: Pick<PayView, "test" | "uri" | "address">) => boolean;
24
+ export declare function payPanel(start: PayView, options: PayOptions): PayPanel;
@@ -0,0 +1,115 @@
1
+ // The pay sheet for one order (the game starter's src/client/ui/pay.ts). It shows the exact amount, a QR code and a "nano:" link for the wallet, and asks
2
+ // the game every 5 seconds whether the payment arrived, for 2 minutes per check.
3
+ //
4
+ // TEST MODE: the server sends no address and no link, and this sheet shows a large "TEST MODE: DO NOT PAY" stamp where the QR code would be, with the
5
+ // wallet key off. Never change that: a payment to an address that nobody holds the key for is lost for good. The sheet decides it from the ORDER (its
6
+ // `test`, a missing `uri` or a missing `address`), never from a setting of the page.
7
+ import { copy, h } from "../../admin/panel/dom.js";
8
+ import { usdText } from "../../looks/look.js";
9
+ import { qrSvg } from "./qr.js";
10
+ export const PAY_TIMING = { pollMs: 5000, checkForMs: 2 * 60_000 };
11
+ /** True when this order must show the test stamp: the server said test mode, or it sent no address or no link. */
12
+ export const isTestOrder = (o) => o.test || !o.uri || !o.address;
13
+ export function payPanel(start, options) {
14
+ let order = start;
15
+ const now = options.now ?? (() => Date.now());
16
+ let stopped = false;
17
+ let timer;
18
+ let clockTimer;
19
+ let checkingUntil = 0;
20
+ const status = h("p", { class: "n133s-status", role: "status", "aria-live": "polite" });
21
+ const again = h("button", { class: "n133s-btn n133s-wide", type: "button", hidden: true }, "Check again");
22
+ const left = h("span", { class: "n133s-time-left" });
23
+ const amount = h("div", { class: "n133s-amount" }, h("strong", {}, `${order.xno} XNO`), h("span", {}, ` = ${usdText(Number(order.usd))}`));
24
+ let payArea;
25
+ /** The main key (the wallet link; off in test mode): it stands in a foot that stays in view, so a phone on its side never hides it. */
26
+ let wallet;
27
+ const test = isTestOrder(order);
28
+ if (test) {
29
+ payArea = h("div", { class: "n133s-test-pay" }, h("div", { class: "n133s-qr n133s-qr-blocked", "aria-hidden": "true" }, h("div", { class: "n133s-qr-fake" })), h("div", { class: "n133s-stamp", role: "alert" }, h("span", {}, "TEST MODE:"), " ", h("span", {}, "DO NOT PAY")), h("p", { class: "n133s-fine" }, "This game is in test mode, so nothing can be paid here. To sell for real, the creator sets their own receive address and turns test mode off."));
30
+ wallet = h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", disabled: true }, "Open wallet (off in test mode)");
31
+ }
32
+ else {
33
+ const copyAmount = h("button", { class: "n133s-btn n133s-small", type: "button" }, "Copy");
34
+ copyAmount.addEventListener("click", () => copy(order.xno, copyAmount));
35
+ const copyAddress = h("button", { class: "n133s-btn n133s-small", type: "button" }, "Copy");
36
+ copyAddress.addEventListener("click", () => copy(order.address, copyAddress));
37
+ payArea = h("div", { class: "n133s-live-pay" }, h("div", { class: "n133s-qr" }, qrSvg(order.uri, `QR code to pay ${order.xno} XNO`)), h("details", { class: "n133s-by-hand" }, h("summary", {}, "Enter it by hand"), h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, "Amount"), h("code", {}, `${order.xno} XNO`), copyAmount), h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, "To"), h("code", { class: "n133s-address" }, order.address), copyAddress), h("p", { class: "n133s-fine" }, "Send exactly this amount, with every digit. Another amount does not match this order.")));
38
+ wallet = h("a", { class: "n133s-btn n133s-primary n133s-wide", href: order.uri }, "Open wallet");
39
+ }
40
+ const node = h("div", { class: `n133s-pay${test ? " n133s-is-test" : ""}` }, h("div", { class: "n133s-pay-main" }, h("p", { class: "n133s-pay-what" }, `${options.lookName}: a look for your character. It changes how you look, not how you play.`), amount, payArea), h("div", { class: "n133s-pay-side" }, h("p", { class: "n133s-fine" }, "This order ends in ", left, "."), status, again, h("p", { class: "n133s-fine n133s-prices" }, `Price in XNO from ${order.prices.join(", ")}. Powered by CoinGecko.`), h("div", { class: "n133s-foot" }, wallet)));
41
+ const stop = () => {
42
+ stopped = true;
43
+ if (timer)
44
+ clearTimeout(timer);
45
+ if (clockTimer)
46
+ clearInterval(clockTimer);
47
+ clockTimer = undefined;
48
+ };
49
+ const tick = () => {
50
+ const ms = Math.max(0, order.expiresAt - now());
51
+ const m = Math.floor(ms / 60_000);
52
+ const s = Math.floor((ms % 60_000) / 1000);
53
+ left.textContent = `${m}:${String(s).padStart(2, "0")}`;
54
+ };
55
+ tick();
56
+ clockTimer = setInterval(tick, 1000);
57
+ function show(o) {
58
+ order = o;
59
+ if (o.status === "paid") {
60
+ stop();
61
+ payArea.replaceChildren(h("div", { class: "n133s-paid" }, h("strong", {}, "Paid. Thank you!"), h("p", {}, `${options.lookName} is yours now.`)));
62
+ wallet.hidden = true;
63
+ status.textContent = "";
64
+ again.hidden = true;
65
+ options.onPaid(o);
66
+ return true;
67
+ }
68
+ if (o.status === "paid_late") {
69
+ stop();
70
+ status.textContent = "This payment came after the order ended. The look was not given. The creator sees it and can send it back.";
71
+ again.hidden = true;
72
+ return true;
73
+ }
74
+ if (o.status === "expired") {
75
+ stop();
76
+ status.textContent = "This order ended. If you already paid, wait here: a payment sent in time still counts.";
77
+ again.hidden = false;
78
+ return false;
79
+ }
80
+ return false;
81
+ }
82
+ async function poll() {
83
+ if (stopped)
84
+ return;
85
+ try {
86
+ const o = await options.check(order.id);
87
+ if (show(o))
88
+ return;
89
+ if (o.status === "open")
90
+ status.textContent = test ? "Test mode: the game does not wait for a payment." : "Waiting for your payment…";
91
+ }
92
+ catch (e) {
93
+ status.textContent = options.errorText?.(e) ?? "No connection. Trying again…";
94
+ }
95
+ if (now() < checkingUntil)
96
+ timer = setTimeout(poll, PAY_TIMING.pollMs);
97
+ else if (!stopped) {
98
+ status.textContent = "Still waiting. Already paid? Press Check again.";
99
+ again.hidden = false;
100
+ }
101
+ }
102
+ function startChecking() {
103
+ stopped = false;
104
+ again.hidden = true;
105
+ checkingUntil = now() + PAY_TIMING.checkForMs;
106
+ clockTimer ??= setInterval(tick, 1000);
107
+ void poll();
108
+ }
109
+ again.addEventListener("click", startChecking);
110
+ if (!test)
111
+ startChecking();
112
+ else
113
+ status.textContent = "Test mode: the game does not wait for a payment.";
114
+ return { node, stop };
115
+ }
@@ -0,0 +1,9 @@
1
+ /** The smallest version that holds `length` bytes at level M, or 0. */
2
+ export declare const qrVersionFor: (length: number) => number;
3
+ /**
4
+ * The modules of the QR code of `text` (UTF-8): `true` is dark, `[row][column]`, without the quiet zone. `mask` forces a mask (0 to 7); by default the
5
+ * one with the lowest penalty. Throws when the text is longer than version 10 holds (213 bytes).
6
+ */
7
+ export declare function qrModules(text: string, mask?: number): boolean[][];
8
+ /** The QR code of `text` as an SVG element: dark modules on white, with a quiet zone of 4 modules. `label` names it for a screen reader. */
9
+ export declare function qrSvg(text: string, label: string): SVGSVGElement;
@@ -0,0 +1,281 @@
1
+ // A QR code for the pay sheet, made in the browser: no outside service sees the payment request, and the package needs no QR library. Byte mode, error
2
+ // correction level M (as the game starter's qrcode-generator used), versions 1 to 10: up to 213 bytes, and a nano: link with a raw amount is at most 117.
3
+ // The steps are ISO/IEC 18004's (and Project Nayuki's reference code): the data and its Reed-Solomon codes, the fixed patterns, the zig-zag placement, and
4
+ // the mask with the lowest penalty. `qrModules` has no DOM; `qrSvg` draws it as one SVG path (crisp at any size, fine under a strict CSP).
5
+ /** For each version 1 to 10 at level M: the EC codewords per block, and the data codewords of each block. */
6
+ const BLOCKS = [
7
+ [10, [16]],
8
+ [16, [28]],
9
+ [26, [44]],
10
+ [18, [32, 32]],
11
+ [24, [43, 43]],
12
+ [16, [27, 27, 27, 27]],
13
+ [18, [31, 31, 31, 31]],
14
+ [22, [38, 38, 39, 39]],
15
+ [22, [36, 36, 36, 37, 37]],
16
+ [26, [43, 43, 43, 43, 44]],
17
+ ];
18
+ const ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50]];
19
+ const gfMul = (x, y) => {
20
+ let z = 0;
21
+ for (let i = 7; i >= 0; i--) {
22
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
23
+ z ^= ((y >>> i) & 1) * x;
24
+ }
25
+ return z & 0xff;
26
+ };
27
+ function rsDivisor(degree) {
28
+ const out = new Array(degree).fill(0);
29
+ out[degree - 1] = 1;
30
+ let root = 1;
31
+ for (let i = 0; i < degree; i++) {
32
+ for (let j = 0; j < degree; j++) {
33
+ out[j] = gfMul(out[j], root);
34
+ if (j + 1 < degree)
35
+ out[j] ^= out[j + 1];
36
+ }
37
+ root = gfMul(root, 0x02);
38
+ }
39
+ return out;
40
+ }
41
+ function rsRemainder(data, divisor) {
42
+ const out = new Array(divisor.length).fill(0);
43
+ for (const b of data) {
44
+ const factor = b ^ out.shift();
45
+ out.push(0);
46
+ divisor.forEach((c, i) => (out[i] ^= gfMul(c, factor)));
47
+ }
48
+ return out;
49
+ }
50
+ /** The codewords (data and EC, interleaved) of `bytes` in `version`. */
51
+ function codewords(bytes, version) {
52
+ const [ecLen, blocks] = BLOCKS[version - 1];
53
+ const capacity = blocks.reduce((a, b) => a + b, 0);
54
+ const bits = [];
55
+ const put = (value, n) => {
56
+ for (let i = n - 1; i >= 0; i--)
57
+ bits.push((value >>> i) & 1);
58
+ };
59
+ put(0b0100, 4);
60
+ put(bytes.length, version < 10 ? 8 : 16);
61
+ for (const b of bytes)
62
+ put(b, 8);
63
+ put(0, Math.min(4, capacity * 8 - bits.length));
64
+ while (bits.length % 8)
65
+ bits.push(0);
66
+ const data = [];
67
+ for (let i = 0; i < bits.length; i += 8)
68
+ data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
69
+ for (let pad = 0xec; data.length < capacity; pad ^= 0xec ^ 0x11)
70
+ data.push(pad);
71
+ const divisor = rsDivisor(ecLen);
72
+ const parts = [];
73
+ let at = 0;
74
+ for (const n of blocks) {
75
+ parts.push(data.slice(at, at + n));
76
+ at += n;
77
+ }
78
+ const ecs = parts.map((p) => rsRemainder(p, divisor));
79
+ const out = [];
80
+ const longest = Math.max(...blocks);
81
+ for (let i = 0; i < longest; i++)
82
+ for (const p of parts)
83
+ if (i < p.length)
84
+ out.push(p[i]);
85
+ for (let i = 0; i < ecLen; i++)
86
+ for (const e of ecs)
87
+ out.push(e[i]);
88
+ return out;
89
+ }
90
+ const MASKS = [
91
+ (x, y) => (x + y) % 2 === 0,
92
+ (_x, y) => y % 2 === 0,
93
+ (x) => x % 3 === 0,
94
+ (x, y) => (x + y) % 3 === 0,
95
+ (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
96
+ (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
97
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
98
+ (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
99
+ ];
100
+ /** The penalty of a finished matrix (the four rules of the standard). */
101
+ function penalty(m) {
102
+ const n = m.length;
103
+ let score = 0;
104
+ const lines = [];
105
+ for (let i = 0; i < n; i++) {
106
+ lines.push(m[i]);
107
+ lines.push(m.map((row) => row[i]));
108
+ }
109
+ const finder = [true, false, true, true, true, false, true];
110
+ for (const line of lines) {
111
+ let run = 1;
112
+ for (let i = 1; i <= n; i++) {
113
+ if (i < n && line[i] === line[i - 1])
114
+ run++;
115
+ else {
116
+ if (run >= 5)
117
+ score += 3 + (run - 5);
118
+ run = 1;
119
+ }
120
+ }
121
+ for (let i = 0; i + 7 <= n; i++) {
122
+ if (!finder.every((v, k) => line[i + k] === v))
123
+ continue;
124
+ const lightBefore = i >= 4 && [1, 2, 3, 4].every((k) => !line[i - k]);
125
+ const lightAfter = i + 11 <= n && [7, 8, 9, 10].every((k) => !line[i + k]);
126
+ if (lightBefore)
127
+ score += 40;
128
+ if (lightAfter)
129
+ score += 40;
130
+ }
131
+ }
132
+ for (let y = 0; y + 1 < n; y++)
133
+ for (let x = 0; x + 1 < n; x++)
134
+ if (m[y][x] === m[y][x + 1] && m[y][x] === m[y + 1][x] && m[y][x] === m[y + 1][x + 1])
135
+ score += 3;
136
+ const dark = m.reduce((a, row) => a + row.filter(Boolean).length, 0);
137
+ score += Math.floor(Math.abs((dark * 100) / (n * n) - 50) / 5) * 10;
138
+ return score;
139
+ }
140
+ /** The smallest version that holds `length` bytes at level M, or 0. */
141
+ export const qrVersionFor = (length) => BLOCKS.findIndex(([, b], i) => 4 + (i + 1 < 10 ? 8 : 16) + 8 * length <= 8 * b.reduce((x, y) => x + y, 0)) + 1;
142
+ /**
143
+ * The modules of the QR code of `text` (UTF-8): `true` is dark, `[row][column]`, without the quiet zone. `mask` forces a mask (0 to 7); by default the
144
+ * one with the lowest penalty. Throws when the text is longer than version 10 holds (213 bytes).
145
+ */
146
+ export function qrModules(text, mask) {
147
+ const bytes = new TextEncoder().encode(text);
148
+ const version = qrVersionFor(bytes.length);
149
+ if (version < 1)
150
+ throw new Error("qrModules: the text is longer than this QR code holds (213 bytes)");
151
+ const size = version * 4 + 17;
152
+ const m = Array.from({ length: size }, () => new Array(size).fill(false));
153
+ const fixed = Array.from({ length: size }, () => new Array(size).fill(false));
154
+ const set = (x, y, dark) => {
155
+ m[y][x] = dark;
156
+ fixed[y][x] = true;
157
+ };
158
+ for (let i = 0; i < size; i++) {
159
+ set(6, i, i % 2 === 0);
160
+ set(i, 6, i % 2 === 0);
161
+ }
162
+ for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
163
+ for (let dy = -4; dy <= 4; dy++)
164
+ for (let dx = -4; dx <= 4; dx++) {
165
+ const x = cx + dx;
166
+ const y = cy + dy;
167
+ const d = Math.max(Math.abs(dx), Math.abs(dy));
168
+ if (x >= 0 && x < size && y >= 0 && y < size)
169
+ set(x, y, d !== 2 && d !== 4);
170
+ }
171
+ }
172
+ const pos = ALIGN[version - 1];
173
+ for (let i = 0; i < pos.length; i++)
174
+ for (let j = 0; j < pos.length; j++) {
175
+ const corner = (i === 0 && j === 0) || (i === 0 && j === pos.length - 1) || (i === pos.length - 1 && j === 0);
176
+ if (corner)
177
+ continue;
178
+ for (let dy = -2; dy <= 2; dy++)
179
+ for (let dx = -2; dx <= 2; dx++)
180
+ set(pos[i] + dx, pos[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
181
+ }
182
+ const drawFormat = (maskNo) => {
183
+ const data = (0 << 3) | maskNo; // level M is 00
184
+ let rem = data;
185
+ for (let i = 0; i < 10; i++)
186
+ rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
187
+ const bits = ((data << 10) | rem) ^ 0x5412;
188
+ const bit = (i) => ((bits >>> i) & 1) === 1;
189
+ for (let i = 0; i <= 5; i++)
190
+ set(8, i, bit(i));
191
+ set(8, 7, bit(6));
192
+ set(8, 8, bit(7));
193
+ set(7, 8, bit(8));
194
+ for (let i = 9; i < 15; i++)
195
+ set(14 - i, 8, bit(i));
196
+ for (let i = 0; i < 8; i++)
197
+ set(size - 1 - i, 8, bit(i));
198
+ for (let i = 8; i < 15; i++)
199
+ set(8, size - 15 + i, bit(i));
200
+ set(8, size - 8, true);
201
+ };
202
+ drawFormat(0);
203
+ if (version >= 7) {
204
+ let rem = version;
205
+ for (let i = 0; i < 12; i++)
206
+ rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
207
+ const bits = (version << 12) | rem;
208
+ for (let i = 0; i < 18; i++) {
209
+ const dark = ((bits >>> i) & 1) === 1;
210
+ const a = size - 11 + (i % 3);
211
+ const b = Math.floor(i / 3);
212
+ set(a, b, dark);
213
+ set(b, a, dark);
214
+ }
215
+ }
216
+ const words = codewords(bytes, version);
217
+ let i = 0;
218
+ for (let right = size - 1; right >= 1; right -= 2) {
219
+ if (right === 6)
220
+ right = 5;
221
+ for (let vert = 0; vert < size; vert++)
222
+ for (let j = 0; j < 2; j++) {
223
+ const x = right - j;
224
+ const upward = ((right + 1) & 2) === 0;
225
+ const y = upward ? size - 1 - vert : vert;
226
+ if (!fixed[y][x] && i < words.length * 8) {
227
+ m[y][x] = ((words[i >>> 3] >>> (7 - (i & 7))) & 1) === 1;
228
+ i++;
229
+ }
230
+ }
231
+ }
232
+ const masked = (k) => {
233
+ const out = m.map((row) => row.slice());
234
+ for (let y = 0; y < size; y++)
235
+ for (let x = 0; x < size; x++)
236
+ if (!fixed[y][x] && MASKS[k](x, y))
237
+ out[y][x] = !out[y][x];
238
+ return out;
239
+ };
240
+ const pick = (k) => {
241
+ drawFormat(k);
242
+ return masked(k);
243
+ };
244
+ if (mask !== undefined) {
245
+ if (!Number.isInteger(mask) || mask < 0 || mask > 7)
246
+ throw new Error("qrModules: a mask is 0 to 7");
247
+ return pick(mask);
248
+ }
249
+ let best = 0;
250
+ let bestScore = Infinity;
251
+ for (let k = 0; k < 8; k++) {
252
+ const s = penalty(pick(k));
253
+ if (s < bestScore)
254
+ (best = k), (bestScore = s);
255
+ }
256
+ return pick(best);
257
+ }
258
+ const SVG = "http://www.w3.org/2000/svg";
259
+ /** The QR code of `text` as an SVG element: dark modules on white, with a quiet zone of 4 modules. `label` names it for a screen reader. */
260
+ export function qrSvg(text, label) {
261
+ const m = qrModules(text);
262
+ const quiet = 4;
263
+ const n = m.length + quiet * 2;
264
+ const svg = document.createElementNS(SVG, "svg");
265
+ svg.setAttribute("viewBox", `0 0 ${n} ${n}`);
266
+ svg.setAttribute("class", "n133s-qr-code");
267
+ svg.setAttribute("role", "img");
268
+ svg.setAttribute("aria-label", label);
269
+ svg.setAttribute("shape-rendering", "crispEdges");
270
+ const back = document.createElementNS(SVG, "rect");
271
+ back.setAttribute("width", String(n));
272
+ back.setAttribute("height", String(n));
273
+ back.setAttribute("fill", "#ffffff");
274
+ let d = "";
275
+ m.forEach((row, y) => row.forEach((dark, x) => (d += dark ? `M${x + quiet} ${y + quiet}h1v1h-1z` : "")));
276
+ const path = document.createElementNS(SVG, "path");
277
+ path.setAttribute("d", d);
278
+ path.setAttribute("fill", "#111318");
279
+ svg.append(back, path);
280
+ return svg;
281
+ }
@@ -0,0 +1,23 @@
1
+ import type { Fetch } from "./nodes.js";
2
+ import type { ShopStore } from "./store.js";
3
+ export type PriceFeed = {
4
+ name: string;
5
+ url: string;
6
+ read: (j: any) => unknown;
7
+ };
8
+ export declare const PRICE_FEEDS: readonly PriceFeed[];
9
+ export declare const RATE_FRESH_MS: number;
10
+ export declare const RATE_LAST_GOOD_MS: number;
11
+ export type Rate = {
12
+ usd: number;
13
+ at: number;
14
+ sources: string[];
15
+ stale: boolean;
16
+ };
17
+ /** Asks every feed at once and returns the median of the believable answers, or null. */
18
+ export declare function readFeeds(fetchFn: Fetch, feeds?: readonly PriceFeed[], timeoutMs?: number): Promise<{
19
+ usd: number;
20
+ sources: string[];
21
+ } | null>;
22
+ /** Dollars per XNO now, or null when no believable price is known. */
23
+ export declare function usdPerXno(store: Pick<ShopStore, "readRate" | "saveRate">, fetchFn: Fetch, now?: number, feeds?: readonly PriceFeed[]): Promise<Rate | null>;