@nano133/nanoterra 0.8.1 → 0.9.3

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 (122) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +390 -6
  3. package/ai/SPEC.md +71 -3
  4. package/dist/admin/panel/mount.d.ts +3 -0
  5. package/dist/admin/panel/mount.js +4 -2
  6. package/dist/player-signin/adapters/d1.d.ts +10 -0
  7. package/dist/player-signin/adapters/d1.js +108 -0
  8. package/dist/player-signin/adapters/firestore.d.ts +45 -0
  9. package/dist/player-signin/adapters/firestore.js +168 -0
  10. package/dist/player-signin/conformance.d.ts +11 -0
  11. package/dist/player-signin/conformance.js +160 -0
  12. package/dist/player-signin/handler.d.ts +64 -0
  13. package/dist/player-signin/handler.js +364 -0
  14. package/dist/player-signin/host.d.ts +4 -0
  15. package/dist/player-signin/host.js +10 -0
  16. package/dist/player-signin/index.d.ts +9 -0
  17. package/dist/player-signin/index.js +15 -0
  18. package/dist/player-signin/memory.d.ts +15 -0
  19. package/dist/player-signin/memory.js +101 -0
  20. package/dist/player-signin/panel/index.d.ts +9 -0
  21. package/dist/player-signin/panel/index.js +8 -0
  22. package/dist/player-signin/panel/memo.d.ts +12 -0
  23. package/dist/player-signin/panel/memo.js +41 -0
  24. package/dist/player-signin/panel/mount.d.ts +21 -0
  25. package/dist/player-signin/panel/mount.js +107 -0
  26. package/dist/player-signin/panel/view.d.ts +48 -0
  27. package/dist/player-signin/panel/view.js +363 -0
  28. package/dist/player-signin/panel/words.d.ts +103 -0
  29. package/dist/player-signin/panel/words.js +111 -0
  30. package/dist/player-signin/scheme-check.d.ts +3 -0
  31. package/dist/player-signin/scheme-check.js +14 -0
  32. package/dist/player-signin/scheme.d.ts +13 -0
  33. package/dist/player-signin/scheme.js +28 -0
  34. package/dist/player-signin/types.d.ts +116 -0
  35. package/dist/player-signin/types.js +33 -0
  36. package/dist/shop/adapters/d1.d.ts +25 -0
  37. package/dist/shop/adapters/d1.js +200 -0
  38. package/dist/shop/adapters/firestore.d.ts +53 -0
  39. package/dist/shop/adapters/firestore.js +245 -0
  40. package/dist/shop/address.d.ts +14 -0
  41. package/dist/shop/address.js +104 -0
  42. package/dist/shop/amount.d.ts +26 -0
  43. package/dist/shop/amount.js +59 -0
  44. package/dist/shop/conformance.d.ts +13 -0
  45. package/dist/shop/conformance.js +203 -0
  46. package/dist/shop/handler.d.ts +76 -0
  47. package/dist/shop/handler.js +156 -0
  48. package/dist/shop/index.d.ts +19 -0
  49. package/dist/shop/index.js +21 -0
  50. package/dist/shop/match.d.ts +23 -0
  51. package/dist/shop/match.js +58 -0
  52. package/dist/shop/memory.d.ts +13 -0
  53. package/dist/shop/memory.js +106 -0
  54. package/dist/shop/nodes.d.ts +47 -0
  55. package/dist/shop/nodes.js +106 -0
  56. package/dist/shop/orders.d.ts +47 -0
  57. package/dist/shop/orders.js +82 -0
  58. package/dist/shop/panel/index.d.ts +9 -0
  59. package/dist/shop/panel/index.js +14 -0
  60. package/dist/shop/panel/looks.d.ts +33 -0
  61. package/dist/shop/panel/looks.js +61 -0
  62. package/dist/shop/panel/mount.d.ts +68 -0
  63. package/dist/shop/panel/mount.js +221 -0
  64. package/dist/shop/panel/pay.d.ts +27 -0
  65. package/dist/shop/panel/pay.js +128 -0
  66. package/dist/shop/panel/qr.d.ts +9 -0
  67. package/dist/shop/panel/qr.js +281 -0
  68. package/dist/shop/panel/words.d.ts +89 -0
  69. package/dist/shop/panel/words.js +79 -0
  70. package/dist/shop/price.d.ts +23 -0
  71. package/dist/shop/price.js +43 -0
  72. package/dist/shop/scan.d.ts +24 -0
  73. package/dist/shop/scan.js +80 -0
  74. package/dist/shop/security.d.ts +10 -0
  75. package/dist/shop/security.js +56 -0
  76. package/dist/shop/store.d.ts +101 -0
  77. package/dist/shop/store.js +4 -0
  78. package/dist/shop/worker.d.ts +43 -0
  79. package/dist/shop/worker.js +76 -0
  80. package/package.json +21 -6
  81. package/src/admin/panel/mount.ts +7 -2
  82. package/src/player-signin/adapters/d1.sql +45 -0
  83. package/src/player-signin/adapters/d1.ts +120 -0
  84. package/src/player-signin/adapters/firestore.ts +194 -0
  85. package/src/player-signin/conformance.ts +184 -0
  86. package/src/player-signin/handler.ts +404 -0
  87. package/src/player-signin/host.ts +12 -0
  88. package/src/player-signin/index.ts +20 -0
  89. package/src/player-signin/memory.ts +94 -0
  90. package/src/player-signin/panel/index.ts +13 -0
  91. package/src/player-signin/panel/memo.ts +49 -0
  92. package/src/player-signin/panel/mount.ts +129 -0
  93. package/src/player-signin/panel/style.css +478 -0
  94. package/src/player-signin/panel/view.ts +433 -0
  95. package/src/player-signin/panel/words.ts +210 -0
  96. package/src/player-signin/scheme-check.ts +14 -0
  97. package/src/player-signin/scheme.ts +32 -0
  98. package/src/player-signin/types.ts +103 -0
  99. package/src/shop/adapters/d1.sql +52 -0
  100. package/src/shop/adapters/d1.ts +245 -0
  101. package/src/shop/adapters/firestore.ts +281 -0
  102. package/src/shop/address.ts +102 -0
  103. package/src/shop/amount.ts +66 -0
  104. package/src/shop/conformance.ts +232 -0
  105. package/src/shop/handler.ts +202 -0
  106. package/src/shop/index.ts +30 -0
  107. package/src/shop/match.ts +63 -0
  108. package/src/shop/memory.ts +102 -0
  109. package/src/shop/nodes.ts +115 -0
  110. package/src/shop/orders.ts +93 -0
  111. package/src/shop/panel/index.ts +19 -0
  112. package/src/shop/panel/looks.ts +106 -0
  113. package/src/shop/panel/mount.ts +280 -0
  114. package/src/shop/panel/pay.ts +192 -0
  115. package/src/shop/panel/qr.ts +256 -0
  116. package/src/shop/panel/style.css +538 -0
  117. package/src/shop/panel/words.ts +172 -0
  118. package/src/shop/price.ts +52 -0
  119. package/src/shop/scan.ts +90 -0
  120. package/src/shop/security.ts +50 -0
  121. package/src/shop/store.ts +99 -0
  122. package/src/shop/worker.ts +107 -0
@@ -0,0 +1,221 @@
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
+ // onSignInCancel(lookId) the game's own Buy (`buy(lookId)`) asked for a sign-in and the player closed it: the game shows its own sheet again
8
+ //
9
+ // `key: false` draws no key: the game opens the sheet from its own key with `open()`, as the options panel allows. `buy(lookId)` starts on the next frame:
10
+ // a game's own <dialog> that closed just before has had its `close` event by then (Chromium fires it at the frame), so its handler cannot take the focus.
11
+ // The sheet is a <dialog>: it rises from the bottom on a phone held upright and stands in the middle on a wide screen; on a phone held sideways the pay
12
+ // sheet puts the QR code beside the words. Phone first: text 16 px, every key 48 px or more each way.
13
+ //
14
+ // `words` changes any word of the two sheets (./words.ts: SHOP_WORDS are the defaults), for a game's own words or language.
15
+ import { ApiError, createApi } from "../../admin/panel/api.js";
16
+ import { h, releaseFocus } from "../../admin/panel/dom.js";
17
+ import { icon } from "../../admin/panel/icons.js";
18
+ import { checkLooks, wearOf } from "../../looks/look.js";
19
+ import { looksView, slotsOf } from "./looks.js";
20
+ import { payPanel } from "./pay.js";
21
+ import { shopWords } from "./words.js";
22
+ export function mountShop(root, options) {
23
+ const looks = checkLooks(options.looks);
24
+ const api = createApi(options.base ?? "/api/orders", options.fetch);
25
+ const bought = new Set();
26
+ let tab;
27
+ let stopPay = null;
28
+ let destroyed = false;
29
+ const cleanup = [];
30
+ const words = shopWords(options.words);
31
+ const sheetTitle = options.title ?? words.title;
32
+ const title = h("h2", { class: "n133s-title", tabindex: "-1" });
33
+ const closeKey = h("button", { class: "n133s-close", type: "button", "aria-label": words.close }, icon("close", 20));
34
+ const body = h("div", { class: "n133s-body" });
35
+ const dialog = h("dialog", { class: "n133s-sheet", "aria-label": sheetTitle }, h("header", { class: "n133s-head" }, title, closeKey), body);
36
+ 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" }, sheetTitle));
37
+ const wrap = h("div", { class: "n133-shop" }, key, dialog);
38
+ root.append(wrap);
39
+ // The theme: the game's word, or the page's (data-n133-theme), or the device's setting.
40
+ let media = null;
41
+ try {
42
+ media = typeof globalThis.matchMedia === "function" ? globalThis.matchMedia("(prefers-color-scheme: dark)") : null;
43
+ }
44
+ catch {
45
+ media = null;
46
+ }
47
+ const drawTheme = () => {
48
+ const want = options.theme ?? "device";
49
+ const page = globalThis.document?.documentElement?.getAttribute("data-n133-theme");
50
+ const dark = want === "device" ? (page === "dark" || page === "light" ? page === "dark" : media?.matches === true) : want === "dark";
51
+ wrap.setAttribute("data-theme", dark ? "dark" : "light");
52
+ };
53
+ drawTheme();
54
+ media?.addEventListener?.("change", drawTheme);
55
+ cleanup.push(() => media?.removeEventListener?.("change", drawTheme));
56
+ const isOpen = () => dialog.open || dialog.hasAttribute("open");
57
+ const stopTimers = () => {
58
+ stopPay?.();
59
+ stopPay = null;
60
+ };
61
+ const show = (heading, content) => {
62
+ stopTimers();
63
+ title.textContent = heading;
64
+ body.replaceChildren(content);
65
+ body.scrollTop = 0;
66
+ if (!isOpen()) {
67
+ if (typeof dialog.showModal === "function")
68
+ dialog.showModal();
69
+ else
70
+ dialog.setAttribute("open", "");
71
+ // 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.
72
+ title.focus();
73
+ key?.setAttribute("aria-expanded", "true");
74
+ options.onOpen?.();
75
+ }
76
+ };
77
+ const closed = () => {
78
+ stopTimers();
79
+ key?.setAttribute("aria-expanded", "false");
80
+ options.onClose?.();
81
+ if (options.focusAfterClose === "key" && key)
82
+ key.focus();
83
+ else
84
+ releaseFocus(wrap);
85
+ };
86
+ const close = () => {
87
+ if (!isOpen())
88
+ return;
89
+ if (typeof dialog.close === "function" && dialog.open)
90
+ dialog.close();
91
+ else {
92
+ dialog.removeAttribute("open");
93
+ closed();
94
+ }
95
+ };
96
+ dialog.addEventListener("close", closed);
97
+ dialog.addEventListener("click", (e) => {
98
+ if (e.target === dialog)
99
+ close();
100
+ });
101
+ closeKey.addEventListener("click", close);
102
+ key?.addEventListener("click", () => (isOpen() ? close() : showLooks()));
103
+ const ownedNow = () => new Set([...options.owned(), ...bought]);
104
+ function showLooks() {
105
+ if (destroyed)
106
+ return;
107
+ show(sheetTitle, looksView({
108
+ looks,
109
+ owned: ownedNow(),
110
+ signedIn: options.signedIn(),
111
+ worn: options.worn,
112
+ wear: (l) => {
113
+ options.wear(l);
114
+ showLooks();
115
+ },
116
+ takeOff: options.takeOff
117
+ ? (slot) => {
118
+ options.takeOff(slot);
119
+ showLooks();
120
+ }
121
+ : undefined,
122
+ buy: (l) => void buy(l, false),
123
+ picture: options.picture,
124
+ slots: options.slots,
125
+ words,
126
+ tab,
127
+ onTab: (s) => {
128
+ tab = s;
129
+ showLooks();
130
+ body.querySelector('.n133s-tab[aria-selected="true"]')?.focus();
131
+ },
132
+ }));
133
+ }
134
+ 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() }, words.back));
135
+ /**
136
+ * `game`: the game's own Buy started it (`buy(lookId)`), not the kit's list: a closed sign-in goes back to the game, not to the list.
137
+ * `ended`: the server said the session ended (401): sign in again whatever `signedIn()` says.
138
+ */
139
+ async function buy(look, game, ended = false) {
140
+ if (destroyed)
141
+ return;
142
+ tab = slotsOf(looks).length > 1 ? wearOf(look) : undefined;
143
+ if (ended || !options.signedIn()) {
144
+ if (!options.signIn)
145
+ return show(words.buyTitle(look.name), back(words.signInFirst));
146
+ let ok = false;
147
+ try {
148
+ ok = await options.signIn();
149
+ }
150
+ catch {
151
+ ok = false;
152
+ }
153
+ if (!ok || !options.signedIn()) {
154
+ if (!game)
155
+ return showLooks();
156
+ close();
157
+ return options.onSignInCancel?.(look.id, look);
158
+ }
159
+ }
160
+ const box = h("div", { class: "n133s-turnstile", hidden: true });
161
+ show(words.buyTitle(look.name), h("div", {}, h("p", { class: "n133s-status" }, words.making), box));
162
+ try {
163
+ const turnstile = options.personCheck ? await options.personCheck(box) : null;
164
+ const order = await api.post("", { lookId: look.id, turnstile });
165
+ if (destroyed)
166
+ return;
167
+ const pay = payPanel(order, {
168
+ lookName: look.name,
169
+ check: (id) => api.post(`/${id}/check`),
170
+ errorText: (e) => (e instanceof ApiError ? words.refusal(e.message, e.status) : null),
171
+ words,
172
+ onPaid: () => {
173
+ bought.add(look.id);
174
+ options.onOwned?.(look.id, look);
175
+ },
176
+ });
177
+ show(words.buyTitle(look.name), pay.node);
178
+ stopPay = pay.stop;
179
+ }
180
+ catch (e) {
181
+ const message = e instanceof ApiError ? words.refusal(e.message, e.status) : e instanceof Error && e.message ? words.refusal(e.message, 0) : words.orderFailed;
182
+ const gone = e instanceof ApiError && e.status === 401;
183
+ if (gone)
184
+ options.onSessionEnd?.();
185
+ const again = gone && options.signIn ? h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", onclick: () => void buy(look, game, true) }, words.signIn) : null;
186
+ show(words.buyTitle(look.name), back(message, again));
187
+ }
188
+ }
189
+ return {
190
+ open: showLooks,
191
+ close,
192
+ buy(lookId) {
193
+ const look = looks.find((l) => l.id === lookId);
194
+ // On the next frame (rAF; a hidden page has none): a <dialog> of the game's that closed just before has had its `close` event by then.
195
+ if (!look)
196
+ return;
197
+ const go = () => void buy(look, true);
198
+ if (typeof requestAnimationFrame === "function" && !globalThis.document?.hidden)
199
+ requestAnimationFrame(go);
200
+ else
201
+ setTimeout(go, 0);
202
+ },
203
+ refresh() {
204
+ if (isOpen() && body.querySelector(".n133s-looks-view"))
205
+ showLooks();
206
+ },
207
+ get isOpen() {
208
+ return isOpen();
209
+ },
210
+ destroy() {
211
+ if (destroyed)
212
+ return;
213
+ close();
214
+ destroyed = true;
215
+ stopTimers();
216
+ for (const fn of cleanup.splice(0))
217
+ fn();
218
+ wrap.remove();
219
+ },
220
+ };
221
+ }
@@ -0,0 +1,27 @@
1
+ import type { PayView } from "../handler.js";
2
+ import { type ShopWords } from "./words.js";
3
+ export declare const PAY_TIMING: {
4
+ readonly pollMs: 5000;
5
+ readonly checkForMs: number;
6
+ };
7
+ export type PayOptions = {
8
+ /** The look's name, for the line at the top. */
9
+ lookName: string;
10
+ /** POST {base}/{id}/check: the order again, after a payment scan. */
11
+ check(id: string): Promise<PayView>;
12
+ /** The order is paid: the look is the player's now. */
13
+ onPaid(order: PayView): void;
14
+ /** Words of a failed check (an ApiError's message), or null for "No connection". */
15
+ errorText?(e: unknown): string | null;
16
+ /** The time now in ms (a test gives its own). */
17
+ now?: () => number;
18
+ /** The game's words (./words.ts): the keys it leaves out keep the defaults. */
19
+ words?: Partial<ShopWords>;
20
+ };
21
+ export type PayPanel = {
22
+ node: HTMLElement;
23
+ stop(): void;
24
+ };
25
+ /** True when this order must show the test stamp: the server said test mode, or it sent no address or no link. */
26
+ export declare const isTestOrder: (o: Pick<PayView, "test" | "uri" | "address">) => boolean;
27
+ export declare function payPanel(start: PayView, options: PayOptions): PayPanel;
@@ -0,0 +1,128 @@
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. The stamp's words are not among the game's words (./words.ts).
7
+ import { h } from "../../admin/panel/dom.js";
8
+ import { usdText } from "../../looks/look.js";
9
+ import { qrSvg } from "./qr.js";
10
+ import { priceLine, shopWords } from "./words.js";
11
+ export const PAY_TIMING = { pollMs: 5000, checkForMs: 2 * 60_000 };
12
+ /** True when this order must show the test stamp: the server said test mode, or it sent no address or no link. */
13
+ export const isTestOrder = (o) => o.test || !o.uri || !o.address;
14
+ export function payPanel(start, options) {
15
+ let order = start;
16
+ const now = options.now ?? (() => Date.now());
17
+ let stopped = false;
18
+ let timer;
19
+ let clockTimer;
20
+ let checkingUntil = 0;
21
+ const w = shopWords(options.words);
22
+ const test = isTestOrder(order);
23
+ /** The order's state. In test mode nothing is checked, so it has nothing to say and takes no room. */
24
+ const status = h("p", { class: "n133s-status", role: "status", "aria-live": "polite", hidden: test });
25
+ const again = h("button", { class: "n133s-btn n133s-wide", type: "button", hidden: true }, w.checkAgain);
26
+ /** The time left, live only: in test mode nothing is paid, so nothing waits for the end. */
27
+ const ends = h("p", { class: "n133s-fine n133s-time-left", hidden: test });
28
+ const amount = h("div", { class: "n133s-amount" }, test ? null : h("span", { class: "n133s-exact" }, w.sendExactly), h("strong", {}, w.amount(order.xno)), h("span", {}, w.usd(usdText(Number(order.usd)))));
29
+ const copyKey = (value) => {
30
+ const key = h("button", { class: "n133s-btn n133s-small", type: "button" }, w.copy);
31
+ key.addEventListener("click", async () => {
32
+ try {
33
+ await navigator.clipboard.writeText(value);
34
+ key.textContent = w.copied;
35
+ }
36
+ catch {
37
+ key.textContent = w.copyFailed;
38
+ }
39
+ setTimeout(() => (key.textContent = w.copy), 1500);
40
+ });
41
+ return key;
42
+ };
43
+ let payArea;
44
+ /** 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. */
45
+ let wallet;
46
+ if (test) {
47
+ 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" }, w.testNote));
48
+ wallet = h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", disabled: true }, w.walletOff);
49
+ }
50
+ else {
51
+ payArea = h("div", { class: "n133s-live-pay" }, h("div", { class: "n133s-qr" }, qrSvg(order.uri, w.qrLabel(order.xno))), h("details", { class: "n133s-by-hand" }, h("summary", {}, w.byHand), h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, w.amountLabel), h("code", {}, w.amount(order.xno)), copyKey(order.xno)), h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, w.toLabel), h("code", { class: "n133s-address" }, order.address), copyKey(order.address)), h("p", { class: "n133s-fine" }, w.byHandNote)));
52
+ wallet = h("a", { class: "n133s-btn n133s-primary n133s-wide", href: order.uri }, w.wallet);
53
+ }
54
+ const node = h("div", { class: `n133s-pay${test ? " n133s-is-test" : ""}` }, h("div", { class: "n133s-pay-main" }, h("p", { class: "n133s-pay-what" }, w.lookLine(options.lookName)), amount, payArea), h("div", { class: "n133s-pay-side" }, ends, status, again, h("p", { class: "n133s-fine n133s-prices" }, priceLine(w, order.prices)), h("div", { class: "n133s-foot" }, wallet)));
55
+ const stop = () => {
56
+ stopped = true;
57
+ if (timer)
58
+ clearTimeout(timer);
59
+ if (clockTimer)
60
+ clearInterval(clockTimer);
61
+ clockTimer = undefined;
62
+ };
63
+ const tick = () => {
64
+ const ms = Math.max(0, order.expiresAt - now());
65
+ const m = Math.floor(ms / 60_000);
66
+ const s = Math.floor((ms % 60_000) / 1000);
67
+ ends.textContent = w.endsIn(`${m}:${String(s).padStart(2, "0")}`);
68
+ };
69
+ tick();
70
+ clockTimer = setInterval(tick, 1000);
71
+ function show(o) {
72
+ order = o;
73
+ if (o.status === "paid") {
74
+ stop();
75
+ payArea.replaceChildren(h("div", { class: "n133s-paid" }, h("strong", {}, w.paid), h("p", {}, w.paidLine(options.lookName))));
76
+ wallet.hidden = true;
77
+ status.textContent = "";
78
+ again.hidden = true;
79
+ options.onPaid(o);
80
+ return true;
81
+ }
82
+ if (o.status === "paid_late") {
83
+ stop();
84
+ status.textContent = w.paidLate;
85
+ again.hidden = true;
86
+ return true;
87
+ }
88
+ if (o.status === "expired") {
89
+ stop();
90
+ status.textContent = w.expired;
91
+ again.hidden = false;
92
+ return false;
93
+ }
94
+ return false;
95
+ }
96
+ async function poll() {
97
+ if (stopped)
98
+ return;
99
+ try {
100
+ const o = await options.check(order.id);
101
+ if (show(o))
102
+ return;
103
+ if (o.status === "open")
104
+ status.textContent = test ? "" : w.waiting;
105
+ }
106
+ catch (e) {
107
+ status.textContent = options.errorText?.(e) ?? w.noConnection;
108
+ }
109
+ if (now() < checkingUntil)
110
+ timer = setTimeout(poll, PAY_TIMING.pollMs);
111
+ else if (!stopped) {
112
+ status.textContent = w.stillWaiting;
113
+ again.hidden = false;
114
+ }
115
+ }
116
+ function startChecking() {
117
+ stopped = false;
118
+ again.hidden = true;
119
+ checkingUntil = now() + PAY_TIMING.checkForMs;
120
+ clockTimer ??= setInterval(tick, 1000);
121
+ void poll();
122
+ }
123
+ again.addEventListener("click", startChecking);
124
+ // Test mode makes no checks: nothing can be paid, so nothing is waited for.
125
+ if (!test)
126
+ startChecking();
127
+ return { node, stop };
128
+ }
@@ -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
+ }