@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,534 @@
1
+ /* The Looks key and the shop's sheet (mountShop in ./mount.ts). Every rule is under .n133-shop, so nothing leaks into the game's page.
2
+ Phone first: every key is 44 px or more (the buy and pay keys 48 px). On a phone held upright the sheet rises from the bottom; on a wide screen it
3
+ stands in the middle; on a phone held sideways the pay sheet puts the QR code beside the words, and the wallet key stays in view.
4
+ Light and dark: mountShop sets data-theme="light" | "dark" on .n133-shop (the game's word, the page's data-n133-theme, or the device's setting).
5
+ To theme it, set the --n133-* custom properties on the element that holds the key (or a parent): --n133-ink, --n133-ink-soft, --n133-paper,
6
+ --n133-ground, --n133-line, --n133-gem, --n133-gem-dark, --n133-on-gem, --n133-sun (the Buy key), --n133-warn (the test stamp), --n133-radius,
7
+ --n133-font. Those set the LIGHT theme; the dark one reads the twins with "dark-" in the name (--n133-dark-ink and so on), as the admin panel does.
8
+ The QR code stays dark on white in both themes: a wallet's camera reads it best that way. */
9
+
10
+ .n133-shop {
11
+ --font: var(--n133-font, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
12
+ --radius: var(--n133-radius, 18px);
13
+ --ink: var(--n133-ink, #16202e);
14
+ --ink-soft: var(--n133-ink-soft, #4a5568);
15
+ --paper: var(--n133-paper, #ffffff);
16
+ --ground: var(--n133-ground, #eef2f7);
17
+ --line: var(--n133-line, #dbe4ee);
18
+ --gem: var(--n133-gem, #20c9a6);
19
+ --gem-dark: var(--n133-gem-dark, #0e8a72);
20
+ --on-gem: var(--n133-on-gem, #062b23);
21
+ --sun: var(--n133-sun, #ffd23f);
22
+ --on-sun: var(--n133-on-sun, #3a2a00);
23
+ --warn: var(--n133-warn, #d6223b);
24
+ --paid: var(--n133-paid, #e6fbf5);
25
+ --shade: 10 20 35;
26
+ display: inline-block;
27
+ color: var(--ink);
28
+ color-scheme: light;
29
+ font: 16px/1.45 var(--font);
30
+ -webkit-text-size-adjust: 100%;
31
+ -webkit-tap-highlight-color: transparent;
32
+ }
33
+
34
+ .n133-shop[data-theme="dark"] {
35
+ --ink: var(--n133-dark-ink, #e7edf5);
36
+ --ink-soft: var(--n133-dark-ink-soft, #a9b5c5);
37
+ --paper: var(--n133-dark-paper, #171f2b);
38
+ --ground: var(--n133-dark-ground, #0f151e);
39
+ --line: var(--n133-dark-line, #34415a);
40
+ --gem: var(--n133-dark-gem, #20c9a6);
41
+ --gem-dark: var(--n133-dark-gem-dark, #3fd8b6);
42
+ --on-gem: var(--n133-dark-on-gem, #062b23);
43
+ --sun: var(--n133-dark-sun, #ffd23f);
44
+ --on-sun: var(--n133-dark-on-sun, #3a2a00);
45
+ --warn: var(--n133-dark-warn, #ff5d73);
46
+ --paid: var(--n133-dark-paid, #0f3a31);
47
+ --shade: 0 0 0;
48
+ color-scheme: dark;
49
+ }
50
+
51
+ .n133-shop *,
52
+ .n133-shop *::before,
53
+ .n133-shop *::after {
54
+ box-sizing: border-box;
55
+ }
56
+
57
+ .n133-shop [hidden] {
58
+ display: none !important;
59
+ }
60
+
61
+ /* ---- the key ---- */
62
+
63
+ .n133-shop .n133s-key {
64
+ display: inline-flex;
65
+ align-items: center;
66
+ gap: 8px;
67
+ min-width: 44px;
68
+ min-height: 44px;
69
+ padding: 0 14px;
70
+ border: 1px solid var(--line);
71
+ border-radius: 999px;
72
+ background: var(--paper);
73
+ color: var(--ink);
74
+ font: 650 15px/1 var(--font);
75
+ cursor: pointer;
76
+ touch-action: manipulation;
77
+ }
78
+
79
+ .n133-shop :is(.n133s-key, .n133s-btn, .n133s-close, .n133s-tab, summary):focus-visible {
80
+ outline: 3px solid var(--gem-dark);
81
+ outline-offset: 2px;
82
+ }
83
+
84
+ /* ---- the sheet ---- */
85
+
86
+ .n133-shop .n133s-sheet {
87
+ width: min(480px, 100%);
88
+ max-width: 100%;
89
+ max-height: min(92vh, 760px);
90
+ max-height: min(92dvh, 760px);
91
+ margin: auto auto 0;
92
+ padding: 0;
93
+ border: 0;
94
+ border-radius: var(--radius) var(--radius) 0 0;
95
+ background: var(--paper);
96
+ color: var(--ink);
97
+ box-shadow: 0 10px 30px rgb(var(--shade) / 0.25);
98
+ overflow: hidden;
99
+ }
100
+
101
+ .n133-shop .n133s-sheet[open] {
102
+ display: flex;
103
+ flex-direction: column;
104
+ }
105
+
106
+ .n133-shop .n133s-sheet::backdrop {
107
+ background: rgb(10 20 35 / 0.45);
108
+ }
109
+
110
+ @media (min-width: 700px) {
111
+ .n133-shop .n133s-sheet {
112
+ margin: auto;
113
+ border-radius: var(--radius);
114
+ }
115
+ }
116
+
117
+ /* A phone on its side: the sheet uses the whole height, and stands in the middle. */
118
+ @media (orientation: landscape) and (max-height: 520px) {
119
+ .n133-shop .n133s-sheet {
120
+ width: min(760px, calc(100% - 16px));
121
+ max-height: calc(100vh - 12px);
122
+ max-height: calc(100dvh - 12px);
123
+ margin: auto;
124
+ border-radius: var(--radius);
125
+ }
126
+ }
127
+
128
+ .n133-shop .n133s-head {
129
+ display: flex;
130
+ flex: none;
131
+ align-items: center;
132
+ justify-content: space-between;
133
+ gap: 8px;
134
+ padding: 8px 8px 4px 20px;
135
+ }
136
+
137
+ .n133-shop .n133s-title {
138
+ margin: 0;
139
+ outline: none;
140
+ font: 750 21px/1.2 var(--font);
141
+ }
142
+
143
+ .n133-shop .n133s-close {
144
+ display: grid;
145
+ place-items: center;
146
+ width: 44px;
147
+ height: 44px;
148
+ border: 0;
149
+ border-radius: 12px;
150
+ background: transparent;
151
+ color: var(--ink);
152
+ cursor: pointer;
153
+ }
154
+
155
+ .n133-shop .n133s-body {
156
+ flex: 1 1 auto;
157
+ min-height: 0;
158
+ padding: 4px 20px max(16px, env(safe-area-inset-bottom));
159
+ overflow: auto;
160
+ overscroll-behavior: contain;
161
+ touch-action: pan-y;
162
+ }
163
+
164
+ .n133-shop p {
165
+ margin: 8px 0;
166
+ }
167
+
168
+ .n133-shop .n133s-status {
169
+ min-height: 1.4em;
170
+ font-weight: 600;
171
+ }
172
+
173
+ .n133-shop .n133s-fine {
174
+ color: var(--ink-soft);
175
+ font-size: 13px;
176
+ }
177
+
178
+ /* ---- keys ---- */
179
+
180
+ .n133-shop .n133s-btn {
181
+ display: inline-flex;
182
+ align-items: center;
183
+ justify-content: center;
184
+ gap: 8px;
185
+ min-height: 48px;
186
+ padding: 0 18px;
187
+ border: 2px solid var(--line);
188
+ border-radius: 999px;
189
+ background: var(--paper);
190
+ color: var(--ink);
191
+ font: 700 16px/1 var(--font);
192
+ text-decoration: none;
193
+ white-space: nowrap;
194
+ cursor: pointer;
195
+ touch-action: manipulation;
196
+ }
197
+
198
+ .n133-shop .n133s-btn:disabled {
199
+ opacity: 0.55;
200
+ cursor: default;
201
+ }
202
+
203
+ /* A main key that is off (the wallet key in test mode) must never look like one that works, in either theme. */
204
+ .n133-shop .n133s-primary:disabled {
205
+ border-color: var(--line);
206
+ border-style: dashed;
207
+ background: var(--ground);
208
+ color: var(--ink-soft);
209
+ opacity: 1;
210
+ }
211
+
212
+ .n133-shop .n133s-primary {
213
+ border-color: var(--gem-dark);
214
+ background: var(--gem);
215
+ color: var(--on-gem);
216
+ }
217
+
218
+ .n133-shop .n133s-buy {
219
+ border-color: #b98a00;
220
+ background: var(--sun);
221
+ color: var(--on-sun);
222
+ }
223
+
224
+ .n133-shop .n133s-wide {
225
+ width: 100%;
226
+ margin-top: 10px;
227
+ }
228
+
229
+ .n133-shop .n133s-small {
230
+ min-height: 44px;
231
+ padding: 0 12px;
232
+ font-size: 14px;
233
+ }
234
+
235
+ /* ---- the looks ---- */
236
+
237
+ .n133-shop .n133s-tabs {
238
+ display: flex;
239
+ gap: 6px;
240
+ margin: 4px -20px 6px;
241
+ padding: 0 20px 6px;
242
+ overflow-x: auto;
243
+ scrollbar-width: none;
244
+ }
245
+
246
+ .n133-shop .n133s-tab {
247
+ flex: none;
248
+ min-height: 44px;
249
+ padding: 0 14px;
250
+ border: 1px solid var(--line);
251
+ border-radius: 999px;
252
+ background: var(--ground);
253
+ color: var(--ink);
254
+ font: 650 15px/1 var(--font);
255
+ cursor: pointer;
256
+ }
257
+
258
+ .n133-shop .n133s-tab[aria-selected="true"] {
259
+ border-color: var(--gem-dark);
260
+ background: var(--gem);
261
+ color: var(--on-gem);
262
+ }
263
+
264
+ .n133-shop .n133s-looks {
265
+ margin: 0;
266
+ padding: 0;
267
+ list-style: none;
268
+ }
269
+
270
+ .n133-shop .n133s-look {
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 12px;
274
+ padding: 10px 0;
275
+ border-bottom: 1px solid var(--line);
276
+ }
277
+
278
+ .n133-shop .n133s-look-worn .n133s-look-text strong::after {
279
+ content: " ✓";
280
+ color: var(--gem-dark);
281
+ }
282
+
283
+ .n133-shop .n133s-look-text {
284
+ display: flex;
285
+ flex: 1;
286
+ flex-direction: column;
287
+ min-width: 0;
288
+ }
289
+
290
+ .n133-shop .n133s-look-text strong {
291
+ overflow: hidden;
292
+ text-overflow: ellipsis;
293
+ white-space: nowrap;
294
+ }
295
+
296
+ .n133-shop .n133s-look-text small {
297
+ color: var(--ink-soft);
298
+ font-size: 14px;
299
+ }
300
+
301
+ .n133-shop .n133s-swatch {
302
+ position: relative;
303
+ display: grid;
304
+ flex: none;
305
+ place-items: center;
306
+ width: 44px;
307
+ height: 44px;
308
+ border: 2px solid rgb(0 0 0 / 0.12);
309
+ border-radius: 50%;
310
+ }
311
+
312
+ .n133-shop .n133s-swatch-item {
313
+ border-radius: 12px;
314
+ background: var(--ground);
315
+ color: var(--ink-soft);
316
+ font: 800 18px/1 var(--font);
317
+ }
318
+
319
+ .n133-shop .n133s-swatch-plain {
320
+ border: 2px dashed var(--line);
321
+ background: transparent;
322
+ }
323
+
324
+ .n133-shop .n133s-swatch-hat {
325
+ position: absolute;
326
+ left: 9px;
327
+ top: -6px;
328
+ width: 22px;
329
+ height: 12px;
330
+ border: 2px solid rgb(0 0 0 / 0.15);
331
+ border-radius: 12px 12px 3px 3px;
332
+ }
333
+
334
+ .n133-shop .n133s-hat-tophat {
335
+ top: -12px;
336
+ height: 18px;
337
+ border-radius: 3px;
338
+ }
339
+
340
+ .n133-shop .n133s-hat-crown {
341
+ border-radius: 2px;
342
+ }
343
+
344
+ /* ---- pay ---- */
345
+
346
+ .n133-shop .n133s-pay-what {
347
+ margin-top: 0;
348
+ color: var(--ink-soft);
349
+ }
350
+
351
+ .n133-shop .n133s-amount {
352
+ display: flex;
353
+ flex-wrap: wrap;
354
+ align-items: baseline;
355
+ gap: 6px;
356
+ margin: 6px 0 12px;
357
+ }
358
+
359
+ .n133-shop .n133s-amount strong {
360
+ font-size: 30px;
361
+ font-variant-numeric: tabular-nums;
362
+ }
363
+
364
+ .n133-shop .n133s-amount span {
365
+ color: var(--ink-soft);
366
+ font-weight: 700;
367
+ }
368
+
369
+ .n133-shop .n133s-qr {
370
+ display: grid;
371
+ place-items: center;
372
+ width: min(248px, 70vw);
373
+ aspect-ratio: 1;
374
+ margin: 0 auto;
375
+ padding: 8px;
376
+ border: 2px solid var(--line);
377
+ border-radius: 16px;
378
+ background: #ffffff;
379
+ }
380
+
381
+ .n133-shop .n133s-qr-code {
382
+ display: block;
383
+ width: 100%;
384
+ height: 100%;
385
+ }
386
+
387
+ .n133-shop .n133s-test-pay {
388
+ position: relative;
389
+ }
390
+
391
+ .n133-shop .n133s-qr-blocked {
392
+ overflow: hidden;
393
+ }
394
+
395
+ .n133-shop .n133s-qr-fake {
396
+ width: 100%;
397
+ height: 100%;
398
+ border-radius: 8px;
399
+ background: repeating-conic-gradient(#c8ced6 0 25%, #eef1f4 0 50%) 0 0 / 22px 22px;
400
+ filter: blur(3px);
401
+ opacity: 0.7;
402
+ }
403
+
404
+ /* TEST MODE: DO NOT PAY. Large, red, over the place of the QR code. Never hide it or make it small. */
405
+ .n133-shop .n133s-stamp {
406
+ position: absolute;
407
+ left: 50%;
408
+ top: min(124px, 35vw);
409
+ width: max-content;
410
+ max-width: 96%;
411
+ padding: 10px 16px;
412
+ border: 4px solid var(--warn);
413
+ border-radius: 10px;
414
+ background: rgb(255 255 255 / 0.94);
415
+ color: #d6223b;
416
+ font: 900 clamp(20px, 6.4vw, 28px)/1.1 var(--font);
417
+ letter-spacing: 0.02em;
418
+ text-align: center;
419
+ transform: translate(-50%, -50%) rotate(-8deg);
420
+ box-shadow: 0 6px 18px rgb(214 34 59 / 0.25);
421
+ }
422
+
423
+ .n133-shop .n133s-stamp span {
424
+ display: block;
425
+ }
426
+
427
+ .n133-shop .n133s-by-hand {
428
+ margin-top: 12px;
429
+ }
430
+
431
+ .n133-shop .n133s-by-hand summary {
432
+ min-height: 44px;
433
+ line-height: 44px;
434
+ font-weight: 700;
435
+ cursor: pointer;
436
+ }
437
+
438
+ .n133-shop .n133s-row {
439
+ display: flex;
440
+ align-items: center;
441
+ gap: 8px;
442
+ margin: 6px 0;
443
+ }
444
+
445
+ .n133-shop .n133s-label {
446
+ width: 60px;
447
+ flex: none;
448
+ color: var(--ink-soft);
449
+ font-size: 14px;
450
+ }
451
+
452
+ .n133-shop .n133s-row code {
453
+ flex: 1;
454
+ min-width: 0;
455
+ word-break: break-all;
456
+ font-size: 13px;
457
+ }
458
+
459
+ .n133-shop .n133s-paid {
460
+ padding: 18px;
461
+ border-radius: 16px;
462
+ background: var(--paid);
463
+ text-align: center;
464
+ }
465
+
466
+ .n133-shop .n133s-paid strong {
467
+ font-size: 22px;
468
+ color: var(--gem-dark);
469
+ }
470
+
471
+ /* The wallet key stays in view at the foot of the sheet. */
472
+ .n133-shop .n133s-foot {
473
+ position: sticky;
474
+ bottom: calc(-1 * max(16px, env(safe-area-inset-bottom)));
475
+ margin: 8px -20px 0;
476
+ padding: 0 20px max(12px, env(safe-area-inset-bottom));
477
+ background: linear-gradient(to bottom, transparent, var(--paper) 14px);
478
+ }
479
+
480
+ .n133-shop .n133s-foot .n133s-wide {
481
+ margin-top: 6px;
482
+ }
483
+
484
+ /* A phone on its side: the QR code (or the stamp) alone on the left, as big as the height allows; the words, the amount and the wallet key on the right. */
485
+ @media (orientation: landscape) and (max-height: 520px) {
486
+ .n133-shop .n133s-pay {
487
+ display: grid;
488
+ grid-template-columns: auto minmax(0, 1fr);
489
+ grid-template-areas: "qr what" "qr amount" "qr side";
490
+ grid-template-rows: auto auto 1fr;
491
+ gap: 0 20px;
492
+ align-items: start;
493
+ }
494
+
495
+ .n133-shop .n133s-pay-main {
496
+ display: contents;
497
+ }
498
+
499
+ .n133-shop .n133s-pay-what {
500
+ grid-area: what;
501
+ }
502
+
503
+ .n133-shop .n133s-amount {
504
+ grid-area: amount;
505
+ margin: 0 0 4px;
506
+ }
507
+
508
+ .n133-shop :is(.n133s-live-pay, .n133s-test-pay) {
509
+ grid-area: qr;
510
+ width: min(250px, calc(100vh - 96px));
511
+ width: min(250px, calc(100dvh - 96px));
512
+ }
513
+
514
+ .n133-shop .n133s-pay-side {
515
+ grid-area: side;
516
+ }
517
+
518
+ .n133-shop .n133s-qr {
519
+ width: 100%;
520
+ }
521
+
522
+ .n133-shop .n133s-stamp {
523
+ top: 50%;
524
+ font-size: 22px;
525
+ }
526
+
527
+ .n133-shop .n133s-amount strong {
528
+ font-size: 24px;
529
+ }
530
+
531
+ .n133-shop .n133s-pay-side .n133s-fine:first-child {
532
+ margin-top: 0;
533
+ }
534
+ }
@@ -0,0 +1,52 @@
1
+ // Dollars per XNO (the game starter's src/worker/price.ts): the median of public price feeds that need no key, so one wrong feed cannot move the price.
2
+ // The answer is kept 5 minutes (in the store: `readRate`, `saveRate`). When every feed fails, the last good price is used for up to 6 hours; after that,
3
+ // new orders wait ("Prices are not available") and nothing is sold at a guessed price.
4
+ //
5
+ // CoinGecko's API terms ask for the words "Powered by CoinGecko": the pay screen shows them.
6
+
7
+ import { MAX_RATE, MIN_RATE } from "./amount.js";
8
+ import type { Fetch } from "./nodes.js";
9
+ import type { ShopStore } from "./store.js";
10
+
11
+ export type PriceFeed = { name: string; url: string; read: (j: any) => unknown };
12
+
13
+ export const PRICE_FEEDS: readonly PriceFeed[] = [
14
+ { name: "CoinGecko", url: "https://api.coingecko.com/api/v3/simple/price?ids=nano&vs_currencies=usd", read: (j) => j?.nano?.usd },
15
+ { name: "Kraken", url: "https://api.kraken.com/0/public/Ticker?pair=NANOUSD", read: (j) => (Object.values(j?.result ?? {})[0] as any)?.c?.[0] },
16
+ { name: "KuCoin", url: "https://api.kucoin.com/api/v1/market/orderbook/level1?symbol=XNO-USDT", read: (j) => j?.data?.price },
17
+ ];
18
+
19
+ export const RATE_FRESH_MS = 5 * 60_000;
20
+ export const RATE_LAST_GOOD_MS = 6 * 60 * 60_000;
21
+
22
+ export type Rate = { usd: number; at: number; sources: string[]; stale: boolean };
23
+
24
+ /** Asks every feed at once and returns the median of the believable answers, or null. */
25
+ export async function readFeeds(fetchFn: Fetch, feeds: readonly PriceFeed[] = PRICE_FEEDS, timeoutMs = 4000): Promise<{ usd: number; sources: string[] } | null> {
26
+ const answers = await Promise.allSettled(
27
+ feeds.map(async (f) => {
28
+ const res = await fetchFn(f.url, { headers: { Accept: "application/json", "User-Agent": "nano-game-starter" }, signal: AbortSignal.timeout(timeoutMs) });
29
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
30
+ return Number(f.read(await res.json()));
31
+ }),
32
+ );
33
+ const good = answers
34
+ .map((a, i) => ({ name: feeds[i].name, v: a.status === "fulfilled" ? a.value : NaN }))
35
+ .filter((x) => Number.isFinite(x.v) && x.v >= MIN_RATE && x.v <= MAX_RATE)
36
+ .sort((a, b) => a.v - b.v);
37
+ if (!good.length) return null;
38
+ return { usd: good[Math.floor(good.length / 2)].v, sources: good.map((g) => g.name) };
39
+ }
40
+
41
+ /** Dollars per XNO now, or null when no believable price is known. */
42
+ export async function usdPerXno(store: Pick<ShopStore, "readRate" | "saveRate">, fetchFn: Fetch, now = Date.now(), feeds: readonly PriceFeed[] = PRICE_FEEDS): Promise<Rate | null> {
43
+ const last = await store.readRate();
44
+ if (last && now - last.at < RATE_FRESH_MS) return { usd: last.usd, sources: last.sources, at: last.at, stale: false };
45
+ const fresh = await readFeeds(fetchFn, feeds);
46
+ if (fresh) {
47
+ await store.saveRate({ ...fresh, at: now });
48
+ return { ...fresh, at: now, stale: false };
49
+ }
50
+ if (last && now - last.at < RATE_LAST_GOOD_MS) return { usd: last.usd, sources: last.sources, at: last.at, stale: true };
51
+ return null;
52
+ }
@@ -0,0 +1,90 @@
1
+ // The payment scan (the game starter's src/worker/sweep.ts): one look at the creator's address settles every held order at once. It READS the ledger and
2
+ // writes only the store: no key, no send. (The starter calls it "sweep"; it moves no money, unlike @nano133/checkout's sweep.)
3
+ //
4
+ // A player's pay screen asks for it every 5 seconds, the admin page on "Check payments now", and a cron every 10 minutes (for a player who paid and closed
5
+ // the page). Node load stays flat however many players pay: the whole game makes at most one scan every SCAN_GAP_MS, and none when no order is held.
6
+
7
+ import { candidates, checkSend } from "./match.js";
8
+ import { rpc, type NanoNode, type RpcOptions } from "./nodes.js";
9
+ import { smallestAmount } from "./orders.js";
10
+ import type { ShopOrder, ShopStore } from "./store.js";
11
+
12
+ export const SCAN_GAP_MS = 4000;
13
+ /** The most entries one RPC read asks for. */
14
+ const COUNT = "50";
15
+
16
+ export type ScanResult = { scanned: boolean; settled: { id: string; status: string }[]; error?: string };
17
+
18
+ /** A confirmed send never changes: its answer is kept for an hour (in this copy of the server). */
19
+ const blockCache = new Map<string, { info: Record<string, any>; until: number }>();
20
+
21
+ async function blockInfo(nodes: readonly NanoNode[], hash: string, o: RpcOptions, now: number) {
22
+ const hit = blockCache.get(hash);
23
+ if (hit && hit.until > now) return hit.info;
24
+ const { answer } = await rpc(nodes, { action: "block_info", hash, json_block: "true" }, o);
25
+ if (answer && (answer.confirmed === "true" || answer.confirmed === true)) {
26
+ if (blockCache.size > 500) blockCache.clear();
27
+ blockCache.set(hash, { info: answer, until: now + 3_600_000 });
28
+ }
29
+ return answer;
30
+ }
31
+
32
+ export type ScanOptions = {
33
+ /** The creator's address (a checked one: `payeeOf`). Null: no scan. */
34
+ address: string | null;
35
+ /** The nodes to ask (`nodesFor`). */
36
+ nodes: readonly NanoNode[];
37
+ /** The time now in ms (default Date.now()). */
38
+ at?: number;
39
+ rpc?: RpcOptions;
40
+ };
41
+
42
+ /** Scans the creator's address once and settles the held orders it pays. Fails closed: when the nodes cannot be read, nothing is credited. */
43
+ export async function scanPayments(store: ShopStore, opts: ScanOptions): Promise<ScanResult> {
44
+ const now = opts.at ?? Date.now();
45
+ const o: RpcOptions = opts.rpc ?? {};
46
+ await store.tidy(now);
47
+ const address = opts.address;
48
+ if (!address) return { scanned: false, settled: [] };
49
+ const held = await store.held();
50
+ if (!held.length) return { scanned: false, settled: [] };
51
+ if (!(await store.claimScan(now, SCAN_GAP_MS))) return { scanned: false, settled: [] };
52
+
53
+ const settled: ScanResult["settled"] = [];
54
+ try {
55
+ // Only amounts a held order asks for: small sends ("dust") cannot push a payment out of the list.
56
+ const threshold = smallestAmount(held)!;
57
+ const receivable = await rpc(opts.nodes, { action: "receivable", account: address, count: COUNT, threshold, source: "true" }, o);
58
+ // A new address has no history yet: the node answers "Account not found" (null here).
59
+ const history = await rpc(opts.nodes, { action: "account_history", account: address, count: COUNT, raw: "true" }, o);
60
+ const byAmount = new Map<string, ShopOrder>(held.map((h) => [h.amount, h]));
61
+ const found = candidates(receivable.answer, history.answer, new Set(byAmount.keys()));
62
+ if (!found.size) return { scanned: true, settled };
63
+
64
+ // A payment that already paid (or was recorded for) an order never counts again.
65
+ const used = await store.usedHashes([...found.keys()]);
66
+ for (const [hash, amount] of found) {
67
+ if (used.has(hash)) continue;
68
+ const order = byAmount.get(amount);
69
+ if (!order) continue;
70
+ const info = await blockInfo(opts.nodes, hash, o, now);
71
+ if (!info) continue;
72
+ const verdict = checkSend(info, address, order.amount, order.createdAt);
73
+ if (verdict.kind !== "paid") continue;
74
+ const result = await store.settle(order, { hash, payer: verdict.payer, seenMs: verdict.seenMs }, now);
75
+ if (result === "paid" || result === "paid_late") {
76
+ settled.push({ id: order.id, status: result });
77
+ byAmount.delete(amount);
78
+ }
79
+ }
80
+ return { scanned: true, settled };
81
+ } catch (e) {
82
+ // Fail closed: nothing is credited when the nodes cannot be read. The next scan tries again.
83
+ return { scanned: false, settled, error: e instanceof Error ? e.message : String(e) };
84
+ }
85
+ }
86
+
87
+ /** For tests: forget the kept send blocks. */
88
+ export function forgetBlocks(): void {
89
+ blockCache.clear();
90
+ }