@nano133/nanoterra 0.9.0 → 0.10.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 (58) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +62 -12
  3. package/ai/SPEC.md +7 -1
  4. package/dist/admin/amount.d.ts +4 -0
  5. package/dist/admin/amount.js +4 -0
  6. package/dist/admin/panel/dom.d.ts +1 -1
  7. package/dist/admin/panel/dom.js +3 -2
  8. package/dist/admin/panel/shelf.js +1 -1
  9. package/dist/admin/panel/shelfData.d.ts +1 -1
  10. package/dist/admin/panel/shelfData.js +6 -6
  11. package/dist/admin/summary.d.ts +1 -1
  12. package/dist/admin/summary.js +8 -8
  13. package/dist/looks/index.d.ts +1 -1
  14. package/dist/looks/index.js +1 -1
  15. package/dist/looks/ladder.d.ts +1 -1
  16. package/dist/looks/ladder.js +7 -7
  17. package/dist/looks/look.d.ts +7 -2
  18. package/dist/looks/look.js +10 -3
  19. package/dist/player-signin/panel/index.d.ts +2 -2
  20. package/dist/player-signin/panel/index.js +3 -2
  21. package/dist/player-signin/panel/mount.d.ts +1 -1
  22. package/dist/player-signin/panel/mount.js +5 -3
  23. package/dist/player-signin/panel/view.d.ts +3 -0
  24. package/dist/player-signin/panel/view.js +49 -46
  25. package/dist/player-signin/panel/words.d.ts +96 -1
  26. package/dist/player-signin/panel/words.js +109 -24
  27. package/dist/shop/handler.js +2 -2
  28. package/dist/shop/panel/index.d.ts +2 -0
  29. package/dist/shop/panel/index.js +3 -0
  30. package/dist/shop/panel/looks.d.ts +3 -0
  31. package/dist/shop/panel/looks.js +11 -11
  32. package/dist/shop/panel/mount.d.ts +18 -2
  33. package/dist/shop/panel/mount.js +48 -21
  34. package/dist/shop/panel/pay.d.ts +3 -0
  35. package/dist/shop/panel/pay.js +38 -25
  36. package/dist/shop/panel/words.d.ts +89 -0
  37. package/dist/shop/panel/words.js +79 -0
  38. package/package.json +1 -1
  39. package/src/admin/amount.ts +6 -0
  40. package/src/admin/panel/dom.ts +4 -2
  41. package/src/admin/panel/shelf.ts +1 -1
  42. package/src/admin/panel/shelfData.ts +6 -6
  43. package/src/admin/summary.ts +8 -8
  44. package/src/looks/index.ts +1 -1
  45. package/src/looks/ladder.ts +7 -6
  46. package/src/looks/look.ts +12 -3
  47. package/src/player-signin/panel/index.ts +4 -3
  48. package/src/player-signin/panel/mount.ts +6 -4
  49. package/src/player-signin/panel/style.css +22 -17
  50. package/src/player-signin/panel/view.ts +60 -55
  51. package/src/player-signin/panel/words.ts +202 -24
  52. package/src/shop/handler.ts +2 -2
  53. package/src/shop/panel/index.ts +4 -0
  54. package/src/shop/panel/looks.ts +14 -14
  55. package/src/shop/panel/mount.ts +60 -21
  56. package/src/shop/panel/pay.ts +51 -30
  57. package/src/shop/panel/style.css +24 -20
  58. package/src/shop/panel/words.ts +172 -0
@@ -3,12 +3,13 @@
3
3
  //
4
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
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.
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
7
 
8
- import { copy, h } from "../../admin/panel/dom.js";
8
+ import { h } from "../../admin/panel/dom.js";
9
9
  import { usdText } from "../../looks/look.js";
10
10
  import type { PayView } from "../handler.js";
11
11
  import { qrSvg } from "./qr.js";
12
+ import { priceLine, shopWords, type ShopWords } from "./words.js";
12
13
 
13
14
  export const PAY_TIMING = { pollMs: 5000, checkForMs: 2 * 60_000 } as const;
14
15
 
@@ -23,6 +24,8 @@ export type PayOptions = {
23
24
  errorText?(e: unknown): string | null;
24
25
  /** The time now in ms (a test gives its own). */
25
26
  now?: () => number;
27
+ /** The game's words (./words.ts): the keys it leaves out keep the defaults. */
28
+ words?: Partial<ShopWords>;
26
29
  };
27
30
 
28
31
  export type PayPanel = { node: HTMLElement; stop(): void };
@@ -37,44 +40,62 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
37
40
  let timer: ReturnType<typeof setTimeout> | undefined;
38
41
  let clockTimer: ReturnType<typeof setInterval> | undefined;
39
42
  let checkingUntil = 0;
43
+ const w = shopWords(options.words);
44
+ const test = isTestOrder(order);
40
45
 
41
- const status = h("p", { class: "n133s-status", role: "status", "aria-live": "polite" });
42
- const again = h("button", { class: "n133s-btn n133s-wide", type: "button", hidden: true }, "Check again");
43
- const left = h("span", { class: "n133s-time-left" });
44
- const amount = h("div", { class: "n133s-amount" }, h("strong", {}, `${order.xno} XNO`), h("span", {}, ` = ${usdText(Number(order.usd))}`));
46
+ /** The order's state. In test mode nothing is checked, so it has nothing to say and takes no room. */
47
+ const status = h("p", { class: "n133s-status", role: "status", "aria-live": "polite", hidden: test });
48
+ const again = h("button", { class: "n133s-btn n133s-wide", type: "button", hidden: true }, w.checkAgain);
49
+ /** The time left, live only: in test mode nothing is paid, so nothing waits for the end. */
50
+ const ends = h("p", { class: "n133s-fine n133s-time-left", hidden: test });
51
+ const amount = h(
52
+ "div",
53
+ { class: "n133s-amount" },
54
+ test ? null : h("span", { class: "n133s-exact" }, w.sendExactly),
55
+ h("strong", {}, w.amount(order.xno)),
56
+ h("span", {}, w.usd(usdText(Number(order.usd)))),
57
+ );
58
+ const copyKey = (value: string) => {
59
+ const key = h("button", { class: "n133s-btn n133s-small", type: "button" }, w.copy);
60
+ key.addEventListener("click", async () => {
61
+ try {
62
+ await navigator.clipboard.writeText(value);
63
+ key.textContent = w.copied;
64
+ } catch {
65
+ key.textContent = w.copyFailed;
66
+ }
67
+ setTimeout(() => (key.textContent = w.copy), 1500);
68
+ });
69
+ return key;
70
+ };
45
71
 
46
72
  let payArea: HTMLElement;
47
73
  /** 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. */
48
74
  let wallet: HTMLElement;
49
- const test = isTestOrder(order);
50
75
  if (test) {
51
76
  payArea = h(
52
77
  "div",
53
78
  { class: "n133s-test-pay" },
54
79
  h("div", { class: "n133s-qr n133s-qr-blocked", "aria-hidden": "true" }, h("div", { class: "n133s-qr-fake" })),
55
80
  h("div", { class: "n133s-stamp", role: "alert" }, h("span", {}, "TEST MODE:"), " ", h("span", {}, "DO NOT PAY")),
56
- 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."),
81
+ h("p", { class: "n133s-fine" }, w.testNote),
57
82
  );
58
- wallet = h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", disabled: true }, "Open wallet (off in test mode)");
83
+ wallet = h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", disabled: true }, w.walletOff);
59
84
  } else {
60
- const copyAmount = h("button", { class: "n133s-btn n133s-small", type: "button" }, "Copy");
61
- copyAmount.addEventListener("click", () => copy(order.xno, copyAmount));
62
- const copyAddress = h("button", { class: "n133s-btn n133s-small", type: "button" }, "Copy");
63
- copyAddress.addEventListener("click", () => copy(order.address!, copyAddress));
64
85
  payArea = h(
65
86
  "div",
66
87
  { class: "n133s-live-pay" },
67
- h("div", { class: "n133s-qr" }, qrSvg(order.uri!, `QR code to pay ${order.xno} XNO`)),
88
+ h("div", { class: "n133s-qr" }, qrSvg(order.uri!, w.qrLabel(order.xno))),
68
89
  h(
69
90
  "details",
70
91
  { class: "n133s-by-hand" },
71
- h("summary", {}, "Enter it by hand"),
72
- h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, "Amount"), h("code", {}, `${order.xno} XNO`), copyAmount),
73
- h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, "To"), h("code", { class: "n133s-address" }, order.address!), copyAddress),
74
- h("p", { class: "n133s-fine" }, "Send exactly this amount, with every digit. Another amount does not match this order."),
92
+ h("summary", {}, w.byHand),
93
+ h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, w.amountLabel), h("code", {}, w.amount(order.xno)), copyKey(order.xno)),
94
+ h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, w.toLabel), h("code", { class: "n133s-address" }, order.address!), copyKey(order.address!)),
95
+ h("p", { class: "n133s-fine" }, w.byHandNote),
75
96
  ),
76
97
  );
77
- wallet = h("a", { class: "n133s-btn n133s-primary n133s-wide", href: order.uri! }, "Open wallet");
98
+ wallet = h("a", { class: "n133s-btn n133s-primary n133s-wide", href: order.uri! }, w.wallet);
78
99
  }
79
100
 
80
101
  const node = h(
@@ -83,17 +104,17 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
83
104
  h(
84
105
  "div",
85
106
  { class: "n133s-pay-main" },
86
- h("p", { class: "n133s-pay-what" }, `${options.lookName}: a look for your character. It changes how you look, not how you play.`),
107
+ h("p", { class: "n133s-pay-what" }, w.lookLine(options.lookName)),
87
108
  amount,
88
109
  payArea,
89
110
  ),
90
111
  h(
91
112
  "div",
92
113
  { class: "n133s-pay-side" },
93
- h("p", { class: "n133s-fine" }, "This order ends in ", left, "."),
114
+ ends,
94
115
  status,
95
116
  again,
96
- h("p", { class: "n133s-fine n133s-prices" }, `Price in XNO from ${order.prices.join(", ")}. Powered by CoinGecko.`),
117
+ h("p", { class: "n133s-fine n133s-prices" }, priceLine(w, order.prices)),
97
118
  h("div", { class: "n133s-foot" }, wallet),
98
119
  ),
99
120
  );
@@ -109,7 +130,7 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
109
130
  const ms = Math.max(0, order.expiresAt - now());
110
131
  const m = Math.floor(ms / 60_000);
111
132
  const s = Math.floor((ms % 60_000) / 1000);
112
- left.textContent = `${m}:${String(s).padStart(2, "0")}`;
133
+ ends.textContent = w.endsIn(`${m}:${String(s).padStart(2, "0")}`);
113
134
  };
114
135
  tick();
115
136
  clockTimer = setInterval(tick, 1000);
@@ -118,7 +139,7 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
118
139
  order = o;
119
140
  if (o.status === "paid") {
120
141
  stop();
121
- payArea.replaceChildren(h("div", { class: "n133s-paid" }, h("strong", {}, "Paid. Thank you!"), h("p", {}, `${options.lookName} is yours now.`)));
142
+ payArea.replaceChildren(h("div", { class: "n133s-paid" }, h("strong", {}, w.paid), h("p", {}, w.paidLine(options.lookName))));
122
143
  wallet.hidden = true;
123
144
  status.textContent = "";
124
145
  again.hidden = true;
@@ -127,13 +148,13 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
127
148
  }
128
149
  if (o.status === "paid_late") {
129
150
  stop();
130
- status.textContent = "This payment came after the order ended. The look was not given. The creator sees it and can send it back.";
151
+ status.textContent = w.paidLate;
131
152
  again.hidden = true;
132
153
  return true;
133
154
  }
134
155
  if (o.status === "expired") {
135
156
  stop();
136
- status.textContent = "This order ended. If you already paid, wait here: a payment sent in time still counts.";
157
+ status.textContent = w.expired;
137
158
  again.hidden = false;
138
159
  return false;
139
160
  }
@@ -145,13 +166,13 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
145
166
  try {
146
167
  const o = await options.check(order.id);
147
168
  if (show(o)) return;
148
- if (o.status === "open") status.textContent = test ? "Test mode: the game does not wait for a payment." : "Waiting for your payment…";
169
+ if (o.status === "open") status.textContent = test ? "" : w.waiting;
149
170
  } catch (e) {
150
- status.textContent = options.errorText?.(e) ?? "No connection. Trying again…";
171
+ status.textContent = options.errorText?.(e) ?? w.noConnection;
151
172
  }
152
173
  if (now() < checkingUntil) timer = setTimeout(poll, PAY_TIMING.pollMs);
153
174
  else if (!stopped) {
154
- status.textContent = "Still waiting. Already paid? Press Check again.";
175
+ status.textContent = w.stillWaiting;
155
176
  again.hidden = false;
156
177
  }
157
178
  }
@@ -164,8 +185,8 @@ export function payPanel(start: PayView, options: PayOptions): PayPanel {
164
185
  void poll();
165
186
  }
166
187
  again.addEventListener("click", startChecking);
188
+ // Test mode makes no checks: nothing can be paid, so nothing is waited for.
167
189
  if (!test) startChecking();
168
- else status.textContent = "Test mode: the game does not wait for a payment.";
169
190
 
170
191
  return { node, stop };
171
192
  }
@@ -1,5 +1,5 @@
1
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
2
+ Phone first: text is 16 px (the stamp and the amount bigger), and every key, tab, close key and fold is 48 px or more each way. On a phone held upright the sheet rises from the bottom; on a wide screen it
3
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
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
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,
@@ -64,14 +64,14 @@
64
64
  display: inline-flex;
65
65
  align-items: center;
66
66
  gap: 8px;
67
- min-width: 44px;
68
- min-height: 44px;
69
- padding: 0 14px;
67
+ min-width: 48px;
68
+ min-height: 48px;
69
+ padding: 0 16px;
70
70
  border: 1px solid var(--line);
71
71
  border-radius: 999px;
72
72
  background: var(--paper);
73
73
  color: var(--ink);
74
- font: 650 15px/1 var(--font);
74
+ font: 650 16px/1 var(--font);
75
75
  cursor: pointer;
76
76
  touch-action: manipulation;
77
77
  }
@@ -143,8 +143,8 @@
143
143
  .n133-shop .n133s-close {
144
144
  display: grid;
145
145
  place-items: center;
146
- width: 44px;
147
- height: 44px;
146
+ width: 48px;
147
+ height: 48px;
148
148
  border: 0;
149
149
  border-radius: 12px;
150
150
  background: transparent;
@@ -172,7 +172,7 @@
172
172
 
173
173
  .n133-shop .n133s-fine {
174
174
  color: var(--ink-soft);
175
- font-size: 13px;
175
+ font-size: 16px;
176
176
  }
177
177
 
178
178
  /* ---- keys ---- */
@@ -182,6 +182,7 @@
182
182
  align-items: center;
183
183
  justify-content: center;
184
184
  gap: 8px;
185
+ min-width: 48px;
185
186
  min-height: 48px;
186
187
  padding: 0 18px;
187
188
  border: 2px solid var(--line);
@@ -227,9 +228,7 @@
227
228
  }
228
229
 
229
230
  .n133-shop .n133s-small {
230
- min-height: 44px;
231
- padding: 0 12px;
232
- font-size: 14px;
231
+ padding: 0 14px;
233
232
  }
234
233
 
235
234
  /* ---- the looks ---- */
@@ -245,13 +244,14 @@
245
244
 
246
245
  .n133-shop .n133s-tab {
247
246
  flex: none;
248
- min-height: 44px;
249
- padding: 0 14px;
247
+ min-width: 48px;
248
+ min-height: 48px;
249
+ padding: 0 16px;
250
250
  border: 1px solid var(--line);
251
251
  border-radius: 999px;
252
252
  background: var(--ground);
253
253
  color: var(--ink);
254
- font: 650 15px/1 var(--font);
254
+ font: 650 16px/1 var(--font);
255
255
  cursor: pointer;
256
256
  }
257
257
 
@@ -295,7 +295,7 @@
295
295
 
296
296
  .n133-shop .n133s-look-text small {
297
297
  color: var(--ink-soft);
298
- font-size: 14px;
298
+ font-size: 16px;
299
299
  }
300
300
 
301
301
  .n133-shop .n133s-swatch {
@@ -366,6 +366,11 @@
366
366
  font-weight: 700;
367
367
  }
368
368
 
369
+ /* The time left: its digits keep their width while it counts down. */
370
+ .n133-shop .n133s-time-left {
371
+ font-variant-numeric: tabular-nums;
372
+ }
373
+
369
374
  .n133-shop .n133s-qr {
370
375
  display: grid;
371
376
  place-items: center;
@@ -429,8 +434,8 @@
429
434
  }
430
435
 
431
436
  .n133-shop .n133s-by-hand summary {
432
- min-height: 44px;
433
- line-height: 44px;
437
+ min-height: 48px;
438
+ line-height: 48px;
434
439
  font-weight: 700;
435
440
  cursor: pointer;
436
441
  }
@@ -443,17 +448,16 @@
443
448
  }
444
449
 
445
450
  .n133-shop .n133s-label {
446
- width: 60px;
451
+ width: 64px;
447
452
  flex: none;
448
453
  color: var(--ink-soft);
449
- font-size: 14px;
450
454
  }
451
455
 
452
456
  .n133-shop .n133s-row code {
453
457
  flex: 1;
454
458
  min-width: 0;
455
459
  word-break: break-all;
456
- font-size: 13px;
460
+ font-size: 16px;
457
461
  }
458
462
 
459
463
  .n133-shop .n133s-paid {
@@ -0,0 +1,172 @@
1
+ // Every word that the shop's sheets show: the looks sheet, Buy and the pay sheet. A game gives `words` to `mountShop` (or to `looksView` and `payPanel`)
2
+ // with the keys it wants to change, in its own words or language; the rest stay these. A text with a value in it is a function of that value.
3
+ //
4
+ // Two things a game cannot change, because a player's money depends on them: the stamp "TEST MODE: DO NOT PAY" (it is not a key), and the words
5
+ // "Powered by CoinGecko" while CoinGecko is one of the price feeds (CoinGecko's API terms ask for them: `priceLine` adds them when `prices` leaves
6
+ // them out).
7
+ //
8
+ // The defaults are short on purpose (Wes, 2026-11-01: fewer words): the pay sheet shows at most 30 words, and no sentence has more than 10.
9
+
10
+ import type { Hat } from "../../looks/look.js";
11
+
12
+ export type ShopWords = {
13
+ // ---- the looks sheet ----
14
+ /** The sheet's title, the Looks key's word and the sheet's name for a screen reader. The option `title` wins over it. */
15
+ title: string;
16
+ /** The close key's name for a screen reader. */
17
+ close: string;
18
+ /** The tabs' name for a screen reader. */
19
+ kinds: string;
20
+ /** The outfits' tab. */
21
+ outfits: string;
22
+ /** The tab of a wear slot of the game's own ("shovel"). The option `slots` wins over it. */
23
+ slotTab: (slot: string) => string;
24
+ /** The plain choice of a wear slot ("No shovel"). The option `slots` wins over it. */
25
+ slotPlain: (slot: string) => string;
26
+ /** The line under an outfit: what it changes (its hat, or the colour only). */
27
+ outfitLine: (hat: Hat | null) => string;
28
+ /** The line under an item of a wear slot. */
29
+ itemLine: (slot: string) => string;
30
+ /** Added to the line of a free look, and the plain choice's line. */
31
+ free: string;
32
+ /** Added to the line of a look the player owns. */
33
+ yours: string;
34
+ wear: string;
35
+ wearing: string;
36
+ /** The Buy key, with the look's price ("$0.25"). */
37
+ buy: (price: string) => string;
38
+ /** Under the list. */
39
+ looksNote: string;
40
+ /** Under the list and at Buy, while nobody is signed in. */
41
+ signInFirst: string;
42
+
43
+ // ---- Buy ----
44
+ /** The title of Buy and of the pay sheet. */
45
+ buyTitle: (look: string) => string;
46
+ making: string;
47
+ /** An order that did not start, with no words from the server. */
48
+ orderFailed: string;
49
+ /** The words of a refusal: the server's own (`text`, in English) and its HTTP status (0: no answer). Default: the server's words. */
50
+ refusal: (text: string, status: number) => string;
51
+ /** The key back to the looks. */
52
+ back: string;
53
+ /** The key that signs in again after "Sign in first." (401). */
54
+ signIn: string;
55
+
56
+ // ---- the pay sheet ----
57
+ /** The line under the title. */
58
+ lookLine: (look: string) => string;
59
+ /** The amount in XNO, large ("0.25 XNO"). */
60
+ amount: (xno: string) => string;
61
+ /** The amount in dollars, beside it ("= $0.25"). */
62
+ usd: (usd: string) => string;
63
+ /** Above the amount, live only: the player must send this exact amount. */
64
+ sendExactly: string;
65
+ /** Under the stamp in test mode: nothing can be paid here. */
66
+ testNote: string;
67
+ /** The wallet key in test mode, off, with its reason. */
68
+ walletOff: string;
69
+ /** The wallet key (live). */
70
+ wallet: string;
71
+ /** The QR code's name for a screen reader. */
72
+ qrLabel: (xno: string) => string;
73
+ /** The fold with the amount and the address to type by hand. */
74
+ byHand: string;
75
+ amountLabel: string;
76
+ /** The address's label in the fold. */
77
+ toLabel: string;
78
+ /** In the fold: another amount does not match the order. */
79
+ byHandNote: string;
80
+ copy: string;
81
+ copied: string;
82
+ copyFailed: string;
83
+ /** The time left (live only): the order ends then. */
84
+ endsIn: (time: string) => string;
85
+ waiting: string;
86
+ noConnection: string;
87
+ stillWaiting: string;
88
+ checkAgain: string;
89
+ paid: string;
90
+ paidLine: (look: string) => string;
91
+ /** A payment after the order ended: it gives no look, and the creator sends it back. */
92
+ paidLate: string;
93
+ /** The order ended: a payment sent in time still counts. */
94
+ expired: string;
95
+ /** The price's sources ("CoinGecko", "Kraken", "KuCoin"). "Powered by CoinGecko" is added when CoinGecko is one and the words leave it out. */
96
+ prices: (sources: readonly string[]) => string;
97
+ };
98
+
99
+ const HATS: Record<Hat, string> = { cap: "with a cap", beanie: "with a beanie", crown: "with a crown", tophat: "with a top hat" };
100
+ const spaced = (slot: string) => slot.replace(/-/g, " ");
101
+
102
+ /** CoinGecko's words: its API terms ask for them wherever its price shows. */
103
+ export const COINGECKO_CREDIT = "Powered by CoinGecko";
104
+
105
+ export const SHOP_WORDS: ShopWords = {
106
+ title: "Looks",
107
+ close: "Close",
108
+ kinds: "Kinds of looks",
109
+ outfits: "Outfits",
110
+ slotTab: (slot) => spaced(slot.charAt(0).toUpperCase() + slot.slice(1)),
111
+ slotPlain: (slot) => `No ${spaced(slot)}`,
112
+ outfitLine: (hat) => (hat ? HATS[hat] : "colour only"),
113
+ itemLine: (slot) => spaced(slot),
114
+ free: "free",
115
+ yours: "yours",
116
+ wear: "Wear",
117
+ wearing: "Wearing",
118
+ buy: (price) => `Buy ${price}`,
119
+ looksNote: "Looks never change play. Pay in Nano, straight to the creator.",
120
+ signInFirst: "Sign in first to buy a look.",
121
+
122
+ buyTitle: (look) => `Buy ${look}`,
123
+ making: "Making your order…",
124
+ orderFailed: "The order did not start. Try again.",
125
+ refusal: (text) => text,
126
+ back: "Back",
127
+ signIn: "Sign in",
128
+
129
+ lookLine: () => "It never changes play.",
130
+ amount: (xno) => `${xno} XNO`,
131
+ usd: (usd) => `= ${usd}`,
132
+ sendExactly: "Send exactly",
133
+ testNote: "Nothing can be paid here.",
134
+ walletOff: "Wallet off: test mode",
135
+ wallet: "Open wallet",
136
+ qrLabel: (xno) => `QR code to pay ${xno} XNO`,
137
+ byHand: "Enter by hand",
138
+ amountLabel: "Amount",
139
+ toLabel: "To",
140
+ byHandNote: "Another amount does not match.",
141
+ copy: "Copy",
142
+ copied: "Copied",
143
+ copyFailed: "Copy failed",
144
+ endsIn: (time) => `Ends in ${time}.`,
145
+ waiting: "Waiting for payment…",
146
+ noConnection: "No connection. Trying again…",
147
+ stillWaiting: "Still waiting. Already paid? Press Check again.",
148
+ checkAgain: "Check again",
149
+ paid: "Paid. Thank you!",
150
+ paidLine: (look) => `${look} is yours.`,
151
+ paidLate: "Paid after the order ended, so no look. The creator sends it back.",
152
+ expired: "This order ended. Paid in time? Wait here: it still counts.",
153
+ prices: (sources) => {
154
+ const others = sources.filter((s) => s !== "CoinGecko");
155
+ const credit = sources.includes("CoinGecko") ? ` ${COINGECKO_CREDIT}.` : "";
156
+ return others.length ? `Price: ${others.join(", ")}.${credit}` : credit.trim();
157
+ },
158
+ };
159
+
160
+ /** The game's words over the defaults. A key the game leaves out, or gives as undefined, keeps its default. */
161
+ export function shopWords(over?: Partial<ShopWords>): ShopWords {
162
+ const out = { ...SHOP_WORDS };
163
+ for (const [k, v] of Object.entries(over ?? {})) if (v !== undefined && k in SHOP_WORDS) (out as Record<string, unknown>)[k] = v;
164
+ return out;
165
+ }
166
+
167
+ /** The price's line: the game's words, with "Powered by CoinGecko" added when CoinGecko is a source and the words leave it out. */
168
+ export function priceLine(words: ShopWords, sources: readonly string[]): string {
169
+ const line = words.prices(sources).trim();
170
+ if (!sources.includes("CoinGecko") || line.includes(COINGECKO_CREDIT)) return line;
171
+ return `${line}${line ? " " : ""}${COINGECKO_CREDIT}.`;
172
+ }