@nano133/nanoterra 0.4.0 → 0.6.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 (87) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +161 -9
  3. package/ai/SPEC.md +27 -4
  4. package/dist/admin/adapters/d1.d.ts +1 -0
  5. package/dist/admin/adapters/d1.js +6 -4
  6. package/dist/admin/adapters/firestore.d.ts +1 -0
  7. package/dist/admin/adapters/firestore.js +7 -5
  8. package/dist/admin/conformance.d.ts +1 -1
  9. package/dist/admin/conformance.js +13 -5
  10. package/dist/admin/handler.d.ts +1 -1
  11. package/dist/admin/handler.js +3 -3
  12. package/dist/admin/index.d.ts +2 -2
  13. package/dist/admin/index.js +2 -2
  14. package/dist/admin/memory.js +3 -1
  15. package/dist/admin/panel/gameCard.d.ts +1 -1
  16. package/dist/admin/panel/gameCard.js +56 -6
  17. package/dist/admin/panel/mount.js +3 -3
  18. package/dist/admin/panel/overview.d.ts +1 -1
  19. package/dist/admin/panel/overview.js +4 -2
  20. package/dist/admin/panel/shelf.d.ts +4 -1
  21. package/dist/admin/panel/shelf.js +84 -29
  22. package/dist/admin/panel/shelfData.js +1 -1
  23. package/dist/admin/store.d.ts +2 -1
  24. package/dist/admin/switches.d.ts +23 -1
  25. package/dist/admin/switches.js +26 -5
  26. package/dist/friends/adapters/d1.d.ts +4 -0
  27. package/dist/friends/adapters/d1.js +131 -0
  28. package/dist/friends/adapters/firestore.d.ts +49 -0
  29. package/dist/friends/adapters/firestore.js +163 -0
  30. package/dist/friends/conformance.d.ts +11 -0
  31. package/dist/friends/conformance.js +229 -0
  32. package/dist/friends/handler.d.ts +31 -0
  33. package/dist/friends/handler.js +139 -0
  34. package/dist/friends/index.d.ts +8 -0
  35. package/dist/friends/index.js +12 -0
  36. package/dist/friends/memory.d.ts +9 -0
  37. package/dist/friends/memory.js +79 -0
  38. package/dist/friends/panel/index.d.ts +4 -0
  39. package/dist/friends/panel/index.js +10 -0
  40. package/dist/friends/panel/mount.d.ts +38 -0
  41. package/dist/friends/panel/mount.js +321 -0
  42. package/dist/friends/panel/window.d.ts +52 -0
  43. package/dist/friends/panel/window.js +72 -0
  44. package/dist/friends/rules.d.ts +92 -0
  45. package/dist/friends/rules.js +180 -0
  46. package/dist/friends/types.d.ts +58 -0
  47. package/dist/friends/types.js +24 -0
  48. package/dist/looks/change.d.ts +5 -5
  49. package/dist/looks/change.js +12 -7
  50. package/dist/looks/index.d.ts +2 -2
  51. package/dist/looks/index.js +1 -1
  52. package/dist/looks/json.d.ts +3 -2
  53. package/dist/looks/json.js +16 -9
  54. package/dist/looks/look.d.ts +26 -6
  55. package/dist/looks/look.js +37 -12
  56. package/package.json +11 -4
  57. package/src/admin/adapters/d1.ts +5 -4
  58. package/src/admin/adapters/firestore.ts +6 -5
  59. package/src/admin/conformance.ts +15 -6
  60. package/src/admin/handler.ts +3 -3
  61. package/src/admin/index.ts +3 -3
  62. package/src/admin/memory.ts +2 -1
  63. package/src/admin/panel/gameCard.ts +52 -6
  64. package/src/admin/panel/mount.ts +3 -3
  65. package/src/admin/panel/overview.ts +4 -3
  66. package/src/admin/panel/shelf.ts +135 -65
  67. package/src/admin/panel/shelfData.ts +1 -1
  68. package/src/admin/panel/style.css +10 -1
  69. package/src/admin/store.ts +2 -2
  70. package/src/admin/switches.ts +35 -9
  71. package/src/friends/adapters/d1.sql +33 -0
  72. package/src/friends/adapters/d1.ts +146 -0
  73. package/src/friends/adapters/firestore.ts +197 -0
  74. package/src/friends/conformance.ts +260 -0
  75. package/src/friends/handler.ts +144 -0
  76. package/src/friends/index.ts +17 -0
  77. package/src/friends/memory.ts +83 -0
  78. package/src/friends/panel/index.ts +13 -0
  79. package/src/friends/panel/mount.ts +346 -0
  80. package/src/friends/panel/style.css +303 -0
  81. package/src/friends/panel/window.ts +79 -0
  82. package/src/friends/rules.ts +196 -0
  83. package/src/friends/types.ts +57 -0
  84. package/src/looks/change.ts +12 -9
  85. package/src/looks/index.ts +2 -2
  86. package/src/looks/json.ts +12 -7
  87. package/src/looks/look.ts +47 -9
@@ -1,12 +1,13 @@
1
- // The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra". A press saves at once
2
- // (POST {api}/game, behind the same admin guard as every admin call) and the game reads the setting at run time, so the creator pushes and deploys
3
- // nothing. The words are in ../switches.ts. The card changes ONLY these two settings: never a look, a price or an order.
4
- import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf } from "../switches.js";
1
+ // The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra", and one three-way
2
+ // choice, "Friends list: Nanoterra / The game's own / Off". A press saves at once (POST {api}/game, behind the same admin guard as every admin call) and
3
+ // the game reads the setting at run time, so the creator pushes and deploys nothing. The words are in ../switches.ts. The card changes ONLY these three
4
+ // settings: never a look, a price or an order.
5
+ import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf } from "../switches.js";
5
6
  import { ApiError } from "./api.js";
6
7
  import { h } from "./dom.js";
7
8
  /**
8
9
  * The card for the settings `initial`. It keeps its own state: a press shows "Saving…", then "Saved." or the server's words. `defaultsFile` is where the game's defaults are
9
- * written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the two switches too).
10
+ * written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the switches too).
10
11
  */
11
12
  export function gameCard(api, initial, defaultsFile = "the game's own settings", onChange) {
12
13
  let state = initial;
@@ -14,7 +15,11 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
14
15
  /** Under the character switch: it works only through sign-in, so it says so, and says what a player gets while sign-in is Off. */
15
16
  const needs = h("p", { class: "fine switch-needs" });
16
17
  const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("div", { class: "card-head" }, h("h2", { id: "game-title" }, "Game")));
17
- card.append(h("p", { class: "fine card-intro" }, `Two choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
18
+ card.append(h("p", { class: "fine card-intro" }, `Three choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
19
+ /** The friends choice: a radio group of three keys (arrow keys move between them, as a radio group does). */
20
+ const friendsKeys = new Map();
21
+ const friendsNote = h("p", { class: "switch-note", id: "switch-friends-note" });
22
+ const friendsStatus = h("span", { class: "fine", role: "status", "aria-live": "polite" });
18
23
  const draw = () => {
19
24
  for (const [id, row] of rows) {
20
25
  const on = stateOf(id, state);
@@ -25,6 +30,33 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
25
30
  }
26
31
  needs.hidden = !stateOf("character", state);
27
32
  needs.textContent = signinOn(state.signin) ? CHARACTER_NEEDS_SIGNIN : CHARACTER_SIGNIN_OFF;
33
+ for (const [id, key] of friendsKeys) {
34
+ const on = state.friends === id;
35
+ key.setAttribute("aria-checked", on ? "true" : "false");
36
+ key.tabIndex = on ? 0 : -1;
37
+ key.classList.toggle("is-on", on);
38
+ }
39
+ friendsNote.textContent = FRIENDS_CHOICES[state.friends]?.note ?? "";
40
+ };
41
+ const choose = async (id) => {
42
+ if (state.friends === id)
43
+ return;
44
+ for (const key of friendsKeys.values())
45
+ key.disabled = true;
46
+ friendsStatus.textContent = "Saving…";
47
+ try {
48
+ state = await api.post("/game", { friends: id });
49
+ friendsStatus.textContent = "Saved.";
50
+ draw();
51
+ onChange?.(state);
52
+ }
53
+ catch (e) {
54
+ friendsStatus.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Nothing was changed.";
55
+ }
56
+ finally {
57
+ for (const key of friendsKeys.values())
58
+ key.disabled = false;
59
+ }
28
60
  };
29
61
  const press = async (id) => {
30
62
  const row = rows.get(id);
@@ -55,6 +87,24 @@ export function gameCard(api, initial, defaultsFile = "the game's own settings",
55
87
  if (id === "signin")
56
88
  card.append(h("p", { class: "fine switch-review" }, REVIEW_NOTE, " ", h("a", { href: REVIEW_LINK, target: "_blank", rel: "noopener noreferrer" }, "Open the launch page")));
57
89
  }
90
+ const order = Object.keys(FRIENDS_CHOICES);
91
+ const group = h("div", { class: "seg friends-choice", role: "radiogroup", "aria-labelledby": "switch-friends-label", "aria-describedby": "switch-friends-note" });
92
+ for (const id of order) {
93
+ const key = h("button", { class: "seg-btn", type: "button", role: "radio", "aria-checked": "false", "data-friends": id }, FRIENDS_CHOICES[id].name);
94
+ key.addEventListener("click", () => void choose(id));
95
+ key.addEventListener("keydown", (e) => {
96
+ const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
97
+ if (!step)
98
+ return;
99
+ e.preventDefault();
100
+ const next = order[(order.indexOf(id) + step + order.length) % order.length];
101
+ friendsKeys.get(next).focus();
102
+ void choose(next);
103
+ });
104
+ friendsKeys.set(id, key);
105
+ group.append(key);
106
+ }
107
+ card.append(h("div", { class: "switch-row friends-row" }, h("div", { class: "switch-head" }, h("span", { class: "switch-title", id: "switch-friends-label" }, FRIENDS_TITLE), group), friendsNote, friendsStatus));
58
108
  draw();
59
109
  return card;
60
110
  }
@@ -28,7 +28,7 @@ export function mountAdmin(root, options) {
28
28
  let game = null;
29
29
  let gameState = null;
30
30
  let dash = null;
31
- /** The card, made once. A fault in reading the two switches must not hide the orders: the card is left out and the rest still shows. */
31
+ /** The card, made once. A fault in reading the game switches must not hide the orders: the card is left out and the rest still shows. */
32
32
  async function gameSection() {
33
33
  if (game)
34
34
  return game;
@@ -110,8 +110,8 @@ export function mountAdmin(root, options) {
110
110
  shelf = shelfView(d, options.looks, options.lookPicture, options.repoHelp);
111
111
  return shelf.el;
112
112
  }
113
- /** A look's body colour, while the game still has the look (for the dots in the lists). */
114
- const colourOf = (id) => options.looks.find((l) => l.id === id)?.change.body ?? null;
113
+ /** An outfit's body colour, while the game still has the look (for the dots in the lists); none for an item of another wear slot. */
114
+ const colourOf = (id) => options.looks.find((l) => l.id === id)?.change?.body ?? null;
115
115
  function build() {
116
116
  const tabs = tabBar("overview");
117
117
  const go = (tab, filter) => {
@@ -16,7 +16,7 @@ export declare function bestSellers(s: Pick<AdminSummary, "orders" | "players">,
16
16
  export type OverviewView = {
17
17
  el: HTMLElement;
18
18
  update(s: AdminSummary): void;
19
- /** The two switches as they are now (null: they could not be read). */
19
+ /** The game switches as they are now (null: they could not be read). */
20
20
  setGame(state: GameState | null): void;
21
21
  };
22
22
  export declare function overviewView(looks: readonly Look[], colourOf: (lookId: string) => string | null, go: (tab: TabId, filter?: OrderFilter) => void): OverviewView;
@@ -1,7 +1,7 @@
1
1
  // The Overview tab of /admin: the game at a glance. The best-selling looks with their share of the money, the last five orders, the two game switches as On or
2
2
  // Off, and the shelf in numbers; each part has a key to its own tab. A game with no sale yet gets three first steps instead of empty lists.
3
3
  import { priceText } from "../../looks/ladder.js";
4
- import { SWITCHES, stateOf } from "../switches.js";
4
+ import { FRIENDS_CHOICES, FRIENDS_TITLE, SWITCHES, stateOf } from "../switches.js";
5
5
  import { dollars, h, plural } from "./dom.js";
6
6
  import { icon } from "./icons.js";
7
7
  import { orderRow } from "./orders.js";
@@ -35,7 +35,9 @@ export function overviewView(looks, colourOf, go) {
35
35
  const on = game ? stateOf(id, game) : null;
36
36
  return h("li", {}, h("span", {}, SWITCHES[id].title), on === null ? h("span", { class: "pill" }, "Unknown") : h("span", { class: `pill ${on ? "pill-on" : "pill-off"}` }, on ? "On" : "Off"));
37
37
  });
38
- gameBox.replaceChildren(h("div", { class: "card-head" }, h("h2", { id: "n133-glance-game" }, "Game switches"), linkKey("Change", () => go("settings"))), game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The two switches could not be read. Reload the page to try again."));
38
+ if (game)
39
+ rows.push(h("li", {}, h("span", {}, FRIENDS_TITLE), h("span", { class: `pill ${game.friends === "off" ? "pill-off" : "pill-on"}` }, FRIENDS_CHOICES[game.friends]?.name ?? "Unknown")));
40
+ gameBox.replaceChildren(h("div", { class: "card-head" }, h("h2", { id: "n133-glance-game" }, "Game switches"), linkKey("Change", () => go("settings"))), game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The game switches could not be read. Reload the page to try again."));
39
41
  }
40
42
  function drawShelf() {
41
43
  const sale = looks.filter((l) => l.priceUsd > 0);
@@ -5,7 +5,10 @@ export type ShelfData = {
5
5
  players: ShelfPlayer[];
6
6
  repo: string | null;
7
7
  };
8
- /** A small flat picture of a look: the body in its colour and the hat in its colour. (The game draws the real character; a game can give its own picture.) */
8
+ /**
9
+ * A small flat picture of a look: an outfit's body in its colour and its hat in its colour; an item of another wear slot as a tag with the slot's name (the game
10
+ * knows what the item is). (The game draws the real thing; a game can give its own picture.)
11
+ */
9
12
  export declare function lookPicture(l: Look): Element;
10
13
  /**
11
14
  * A Shelf picture that shows the look on the real character. `load` gives the drawing function (a look to a PNG address, or null), and is
@@ -9,7 +9,7 @@ import { diffLooks, removalProblems } from "../../looks/change.js";
9
9
  import { SLOTS_MAX } from "../../looks/look.js";
10
10
  import { looksJson, slugId } from "../../looks/json.js";
11
11
  import { PRICE_LADDER, priceText, rungOf, rungUsd } from "../../looks/ladder.js";
12
- import { COLOR_PATTERN, HATS, lookProblems, usdText } from "../../looks/look.js";
12
+ import { COLOR_PATTERN, HATS, isOutfit, lookProblems, OUTFIT, SLOT_NAME_PATTERN, usdText, wearOf } from "../../looks/look.js";
13
13
  import { copy, dollars, h, plural } from "./dom.js";
14
14
  import { icon } from "./icons.js";
15
15
  import { aiPrompt, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
@@ -33,8 +33,17 @@ function hatShapes(hat, fill) {
33
33
  };
34
34
  return hats[hat]();
35
35
  }
36
- /** A small flat picture of a look: the body in its colour and the hat in its colour. (The game draws the real character; a game can give its own picture.) */
36
+ /**
37
+ * A small flat picture of a look: an outfit's body in its colour and its hat in its colour; an item of another wear slot as a tag with the slot's name (the game
38
+ * knows what the item is). (The game draws the real thing; a game can give its own picture.)
39
+ */
37
40
  export function lookPicture(l) {
41
+ if (!isOutfit(l)) {
42
+ const wear = wearOf(l);
43
+ const label = svg("text", { x: 33, y: 40, "font-size": 9, "font-family": "system-ui, sans-serif", "text-anchor": "middle", fill: INK });
44
+ label.textContent = wear.slice(0, 8);
45
+ return svg("svg", { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: worn in the ${wear} slot` }, shape("path", { d: "M14 20 L36 20 L52 36 L36 52 L14 52 Z", fill: "#f6e7c1" }), shape("circle", { cx: 22, cy: 36, r: 3.5, fill: "#ffffff" }), label);
46
+ }
38
47
  const hat = l.change.hat;
39
48
  return svg("svg", { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: ${l.change.hat ?? "no hat"}` }, shape("rect", { x: 23, y: 61, width: 7, height: 9, rx: 2, fill: "#3a3f5c" }), shape("rect", { x: 34, y: 61, width: 7, height: 9, rx: 2, fill: "#3a3f5c" }), shape("rect", { x: 16, y: 37, width: 32, height: 28, rx: 12, fill: l.change.body }), shape("circle", { cx: 32, cy: 26, r: 13, fill: "#f2c9a0" }), svg("circle", { cx: 27, cy: 27, r: 1.7, fill: INK }), svg("circle", { cx: 37, cy: 27, r: 1.7, fill: INK }), ...(hat ? hatShapes(hat, l.change.hatColor ?? "#444444") : []));
40
49
  }
@@ -55,7 +64,7 @@ export function previewPicture(load, fallback = lookPicture, size = 64) {
55
64
  return (l) => {
56
65
  const box = h("span", { class: "shelf-preview" }, fallback(l));
57
66
  ready ??= load().catch(() => null);
58
- const look = { ...l, change: { ...l.change }, ...(l.slots ? { slots: { ...l.slots } } : {}) };
67
+ const look = copyLook(l);
59
68
  void ready
60
69
  .then((render) => (render ? render(look) : null))
61
70
  .then((src) => {
@@ -68,7 +77,9 @@ export function previewPicture(load, fallback = lookPicture, size = 64) {
68
77
  return box;
69
78
  };
70
79
  }
71
- const clone = (ls) => ls.map((l) => ({ id: l.id, name: l.name, priceUsd: l.priceUsd, change: { ...l.change }, ...(l.slots ? { slots: { ...l.slots } } : {}) }));
80
+ /** A copy of a look that shares nothing with it: its wear slot, an outfit's change, its slots. */
81
+ const copyLook = (l) => ({ id: l.id, name: l.name, priceUsd: l.priceUsd, ...(l.wear !== undefined ? { wear: l.wear } : {}), ...(l.change ? { change: { ...l.change } } : {}), ...(l.slots ? { slots: { ...l.slots } } : {}) });
82
+ const clone = (ls) => ls.map(copyLook);
72
83
  /** The names of the slots that differ between two looks (added, removed or another value). */
73
84
  function slotNamesChanged(before, after) {
74
85
  const names = new Set([...Object.keys(before.slots ?? {}), ...Object.keys(after.slots ?? {})]);
@@ -83,7 +94,9 @@ function fieldWords(d) {
83
94
  if (f === "body")
84
95
  return "body colour";
85
96
  if (f === "hat")
86
- return `hat ${d.before.change.hat ?? "none"} to ${d.after.change.hat ?? "none"}`;
97
+ return `hat ${d.before.change?.hat ?? "none"} to ${d.after.change?.hat ?? "none"}`;
98
+ if (f === "wear")
99
+ return `wear slot ${wearOf(d.before)} to ${wearOf(d.after)}`;
87
100
  if (f === "slots")
88
101
  return `slots changed (${slotNamesChanged(d.before, d.after)})`;
89
102
  return "hat colour";
@@ -166,7 +179,12 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
166
179
  const stats = () => shelfStats(data.orders, data.players);
167
180
  const owned = () => ownedIds(data.players);
168
181
  const isOriginal = (id) => LOOKS.some((l) => l.id === id);
169
- const freeCount = () => draft.filter((l) => l.priceUsd === 0).length;
182
+ /** Free outfits in the draft: one must stay (every player starts with it). */
183
+ const freeCount = () => draft.filter((l) => l.priceUsd === 0 && wearOf(l) === OUTFIT).length;
184
+ /** The wear slots that the draft uses besides the outfit, for the wear slot field's suggestions. */
185
+ const wearSlots = () => [...new Set(draft.map(wearOf).filter((w) => w !== OUTFIT))].sort();
186
+ /** What a card says under the price: who has it (a free outfit), what it sold, or that it is new. */
187
+ const fineWords = (l, s) => (l.priceUsd === 0 && wearOf(l) === OUTFIT ? "Every player starts with it" : soldText(s));
170
188
  /** Whether the draft differs from the game's looks.json in any way, the order of the looks too (the file's own text is compared). */
171
189
  const changedFromGame = () => looksJson(draft) !== looksJson(LOOKS);
172
190
  /**
@@ -176,7 +194,7 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
176
194
  const pictures = new Map();
177
195
  let used = new Set();
178
196
  function pictureOf(l) {
179
- const key = JSON.stringify([l.id, l.name, l.priceUsd, l.change, l.slots ?? null]);
197
+ const key = JSON.stringify([l.id, l.name, l.priceUsd, l.wear ?? null, l.change ?? null, l.slots ?? null]);
180
198
  const kept = pictures.get(key);
181
199
  if (kept && !used.has(key)) {
182
200
  used.add(key);
@@ -270,10 +288,15 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
270
288
  }
271
289
  function stage(l, ...extra) {
272
290
  const box = h("div", { class: "shelf-stage" }, h("div", { class: "shelf-pic" }, pictureOf(l)), ...extra);
273
- if (COLOR_PATTERN.test(l.change.body))
274
- box.style.setProperty("--tint", l.change.body);
291
+ const body = l.change?.body;
292
+ if (body && COLOR_PATTERN.test(body))
293
+ box.style.setProperty("--tint", body);
275
294
  return box;
276
295
  }
296
+ /** An item's wear slot, under its name ("Worn in the shovel slot"); nothing for an outfit. */
297
+ function wearLine(l) {
298
+ return wearOf(l) === OUTFIT ? null : h("span", { class: "fine shelf-wear" }, `Worn in the ${wearOf(l)} slot`);
299
+ }
277
300
  function slotList(l) {
278
301
  const entries = Object.entries(l.slots ?? {});
279
302
  if (!entries.length)
@@ -284,7 +307,7 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
284
307
  const share = s && total > 0 && s.paidCents > 0 ? Math.round((s.paidCents / total) * 100) : 0;
285
308
  const bar = h("div", { class: "share-bar" });
286
309
  bar.style.setProperty("--share", `${share}%`);
287
- return h("article", { class: `shelf-card${l.priceUsd === 0 ? " is-free" : ""}` }, stage(l, h("span", { class: `price-pill${l.priceUsd === 0 ? " is-free" : ""}` }, priceWords(l.priceUsd))), h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name || "(no name)"), h("span", { class: "fine" }, l.priceUsd === 0 ? "Every player starts with it" : soldText(s)), share ? h("div", { class: "share", title: `${share}% of the money from sales` }, bar, h("span", { class: "fine" }, `${share}% of sales`)) : null, slotList(l)));
310
+ return h("article", { class: `shelf-card${l.priceUsd === 0 ? " is-free" : ""}` }, stage(l, h("span", { class: `price-pill${l.priceUsd === 0 ? " is-free" : ""}` }, priceWords(l.priceUsd))), h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name || "(no name)"), wearLine(l), h("span", { class: "fine" }, fineWords(l, s)), share ? h("div", { class: "share", title: `${share}% of the money from sales` }, bar, h("span", { class: "fine" }, `${share}% of sales`)) : null, slotList(l)));
288
311
  }
289
312
  // ---- To edit ----
290
313
  function editGrid(st, own) {
@@ -314,7 +337,7 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
314
337
  ];
315
338
  }
316
339
  function compactCard(l, st, own, mine, mark) {
317
- return h("article", { class: `shelf-card is-compact${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}` }, stage(l), h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name || "(no name)"), h("span", { class: "shelf-price" }, priceWords(l.priceUsd)), h("span", { class: "fine" }, isOriginal(l.id) ? (l.priceUsd === 0 ? "Every player starts with it" : soldText(st.get(l.id))) : "New: not in your game yet"), marks(l, own, mine, mark)), h("button", { class: "btn btn-small card-open", type: "button", "aria-expanded": "false", "aria-label": `Edit ${l.name || "this look"}`, onclick: () => openCard(l) }, icon("pencil", 16), "Edit"));
340
+ return h("article", { class: `shelf-card is-compact${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}` }, stage(l), h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name || "(no name)"), h("span", { class: "shelf-price" }, priceWords(l.priceUsd)), wearLine(l), h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"), marks(l, own, mine, mark)), h("button", { class: "btn btn-small card-open", type: "button", "aria-expanded": "false", "aria-label": `Edit ${l.name || "this look"}`, onclick: () => openCard(l) }, icon("pencil", 16), "Edit"));
318
341
  }
319
342
  function ghostCard(l) {
320
343
  return h("article", { class: "shelf-ghost" }, h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name), h("span", { class: "fine" }, "Removed from the draft")), h("button", {
@@ -341,7 +364,7 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
341
364
  const stageBox = h("div", { class: "shelf-stage is-big" }, pic);
342
365
  const nameLine = h("strong", { class: "shelf-name" }, l.name || "(no name)");
343
366
  const priceLine = h("span", { class: "shelf-price" }, priceWords(l.priceUsd));
344
- const tint = () => (COLOR_PATTERN.test(l.change.body) ? stageBox.style.setProperty("--tint", l.change.body) : stageBox.style.removeProperty("--tint"));
367
+ const tint = () => (l.change && COLOR_PATTERN.test(l.change.body) ? stageBox.style.setProperty("--tint", l.change.body) : stageBox.style.removeProperty("--tint"));
345
368
  tint();
346
369
  // The real picture is drawn a moment after the last change: a colour dragged across the picker draws once, not at every step.
347
370
  let later = null;
@@ -353,8 +376,10 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
353
376
  clearTimeout(later);
354
377
  later = setTimeout(() => pic.replaceChildren(pictureOf(l)), 150);
355
378
  ladder.querySelectorAll("button").forEach((c) => c.setAttribute("aria-pressed", Number(c.dataset.usd) === l.priceUsd ? "true" : "false"));
356
- swatches.querySelectorAll("button").forEach((c) => c.setAttribute("aria-pressed", c.dataset.colour === l.change.body.toLowerCase() ? "true" : "false"));
357
- hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
379
+ if (outfit) {
380
+ lookParts?.swatches.querySelectorAll("button").forEach((c) => c.setAttribute("aria-pressed", c.dataset.colour === outfit.change.body.toLowerCase() ? "true" : "false"));
381
+ lookParts?.hatChoices.style.setProperty("color", outfit.change.hatColor ?? "#9aa4b2");
382
+ }
358
383
  refreshFoot();
359
384
  };
360
385
  const name = h("input", { class: "input", type: "text", maxlength: 40, value: l.name, "aria-label": `Name of look ${i + 1}`, oninput: (e) => {
@@ -378,6 +403,48 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
378
403
  ladder.append(step(0, "Free", "everyone", "Every player has it"));
379
404
  for (const r of PRICE_LADDER)
380
405
  ladder.append(step(rungUsd(r), priceText(rungUsd(r)), r.name, `${r.name}: ${r.for}`));
406
+ // An outfit's body colour and hat (an item of another wear slot has neither).
407
+ const outfit = isOutfit(l) ? l : null;
408
+ const lookParts = outfit ? outfitFields(outfit, i, sync) : null;
409
+ const lastFree = l.priceUsd === 0 && wearOf(l) === OUTFIT && freeCount() === 1;
410
+ const keep = isOriginal(l.id) && own.has(l.id);
411
+ const remove = h("button", { class: "btn btn-danger", type: "button", disabled: keep || lastFree, title: keep ? "A player owns this look. Keep it in the file." : lastFree ? "Keep one free outfit: every player starts with it." : "", onclick: () => {
412
+ draft = draft.filter((x) => x !== l);
413
+ open = null;
414
+ redraw();
415
+ } }, icon("trash", 18), "Remove");
416
+ const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, icon("dup", 18), "Duplicate");
417
+ const openMarks = marks(l, own, mine, mark);
418
+ const openProblems = h("ul", { class: "card-problems" }, ...mine.map((p) => h("li", {}, p.message)));
419
+ openProblems.hidden = !mine.length;
420
+ openParts = { marks: openMarks, problems: openProblems };
421
+ const group = (title, ...kids) => h("div", { class: "edit-group" }, h("h3", {}, title), ...kids);
422
+ return h("article", { class: `shelf-card shelf-editing${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}`, tabindex: -1, "aria-label": `Editing ${l.name || "a look"}` }, h("div", { class: "edit-top" }, stageBox, h("div", { class: "shelf-body" }, nameLine, priceLine, wearLine(l), h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"), openMarks), h("button", { class: "btn btn-small card-done", type: "button", "aria-expanded": "true", onclick: () => openCard(null) }, icon("check", 16), "Done")), openProblems, h("div", { class: "edit-groups" }, group("Name", h("label", { class: "field" }, h("span", {}, "What players read"), name)), group("Price", h("label", { class: "field" }, h("span", {}, "Price in US dollars"), priceInput), ladder), wearEditor(l, keep || lastFree, i), lookParts?.group ?? null, slotsEditor(l, isOriginal(l.id) && own.has(l.id))), keep ? h("p", { class: "fine owned-note" }, icon("lock", 14), "A player owns this look: its id stays, and it cannot be removed.") : null, h("div", { class: "shelf-actions" }, dup, remove));
423
+ }
424
+ /**
425
+ * The wear slot of a look: "outfit" (the character's own look) or one of the game's own slots ("shovel"). Typed, with the slots the draft already uses as
426
+ * suggestions. Locked for a look that a player owns (it stays in its slot) and for the last free outfit. An outfit that becomes an item loses its colours; an
427
+ * item that becomes an outfit gets the free look's blue body.
428
+ */
429
+ function wearEditor(l, locked, i) {
430
+ const list = `n133-wears-${i}`;
431
+ const input = h("input", { class: "input", type: "text", maxlength: 24, value: wearOf(l), list, autocapitalize: "none", spellcheck: false, disabled: locked, "aria-label": `Wear slot of ${l.name}`, onchange: (e) => {
432
+ const v = e.target.value.trim();
433
+ if (v === "" || v === OUTFIT) {
434
+ delete l.wear;
435
+ l.change ??= { body: "#4aa8ff" };
436
+ }
437
+ else {
438
+ l.wear = v;
439
+ if (SLOT_NAME_PATTERN.test(v))
440
+ delete l.change;
441
+ }
442
+ redraw();
443
+ } });
444
+ return h("div", { class: "edit-group" }, h("h3", {}, "Wear slot"), h("label", { class: "field" }, h("span", {}, "outfit (the character's colour and hat), or a slot of your game for an item (shovel, charm)"), input), h("datalist", { id: list }, ...[OUTFIT, ...wearSlots()].map((w) => h("option", { value: w }))));
445
+ }
446
+ /** An outfit's fields: the body colour (swatches and a picker), the hat (pictures) and the hat colour, as the editor's "Look" group. */
447
+ function outfitFields(l, i, sync) {
381
448
  const body = h("input", { class: "colour", type: "color", value: l.change.body, "aria-label": `Body colour of ${l.name}`, oninput: (e) => {
382
449
  l.change.body = e.target.value;
383
450
  sync();
@@ -416,20 +483,8 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
416
483
  hatChoices.append(h("label", { class: `hat-choice${(l.change.hat ?? null) === x ? " is-on" : ""}` }, input, hatChoicePicture(x), h("span", {}, x ? HAT_WORDS[x] : "No hat")));
417
484
  }
418
485
  hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
419
- const lastFree = l.priceUsd === 0 && freeCount() === 1;
420
- const keep = isOriginal(l.id) && own.has(l.id);
421
- const remove = h("button", { class: "btn btn-danger", type: "button", disabled: keep || lastFree, title: keep ? "A player owns this look. Keep it in the file." : lastFree ? "Keep one free look: every player starts with it." : "", onclick: () => {
422
- draft = draft.filter((x) => x !== l);
423
- open = null;
424
- redraw();
425
- } }, icon("trash", 18), "Remove");
426
- const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, icon("dup", 18), "Duplicate");
427
- const openMarks = marks(l, own, mine, mark);
428
- const openProblems = h("ul", { class: "card-problems" }, ...mine.map((p) => h("li", {}, p.message)));
429
- openProblems.hidden = !mine.length;
430
- openParts = { marks: openMarks, problems: openProblems };
431
- const group = (title, ...kids) => h("div", { class: "edit-group" }, h("h3", {}, title), ...kids);
432
- return h("article", { class: `shelf-card shelf-editing${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}`, tabindex: -1, "aria-label": `Editing ${l.name || "a look"}` }, h("div", { class: "edit-top" }, stageBox, h("div", { class: "shelf-body" }, nameLine, priceLine, h("span", { class: "fine" }, isOriginal(l.id) ? (l.priceUsd === 0 ? "Every player starts with it" : soldText(st.get(l.id))) : "New: not in your game yet"), openMarks), h("button", { class: "btn btn-small card-done", type: "button", "aria-expanded": "true", onclick: () => openCard(null) }, icon("check", 16), "Done")), openProblems, h("div", { class: "edit-groups" }, group("Name", h("label", { class: "field" }, h("span", {}, "What players read"), name)), group("Price", h("label", { class: "field" }, h("span", {}, "Price in US dollars"), priceInput), ladder), h("div", { class: "edit-group group-look" }, h("h3", {}, "Look"), h("div", { class: "look-fields" }, h("div", { class: "field" }, h("span", {}, "Body colour"), h("div", { class: "colour-row" }, swatches, body)), h("div", { class: "field" }, h("span", {}, "Hat"), hatChoices), h("label", { class: "field" }, h("span", {}, "Hat colour"), hatColor))), slotsEditor(l, isOriginal(l.id) && own.has(l.id))), keep ? h("p", { class: "fine owned-note" }, icon("lock", 14), "A player owns this look: its id stays, and it cannot be removed.") : null, h("div", { class: "shelf-actions" }, dup, remove));
486
+ const group = h("div", { class: "edit-group group-look" }, h("h3", {}, "Look"), h("div", { class: "look-fields" }, h("div", { class: "field" }, h("span", {}, "Body colour"), h("div", { class: "colour-row" }, swatches, body)), h("div", { class: "field" }, h("span", {}, "Hat"), hatChoices), h("label", { class: "field" }, h("span", {}, "Hat colour"), hatColor)));
487
+ return { group, swatches, hatChoices };
433
488
  }
434
489
  /** The slots of a look: names and values (words or #rrggbb colours) that the game reads. Rows to type in, at most SLOTS_MAX. */
435
490
  function slotsEditor(l, owned) {
@@ -456,7 +511,7 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
456
511
  }
457
512
  function addLook(from) {
458
513
  const name = from ? `${from.name} copy`.slice(0, 40) : "New look";
459
- const base = from ? { id: "", name, priceUsd: from.priceUsd, change: { ...from.change }, ...(from.slots ? { slots: { ...from.slots } } : {}) } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
514
+ const base = from ? { ...copyLook(from), id: "", name } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
460
515
  // An id that a player owns is never given to a new look (even when that look is not in looks.json now): the new look would be what the owners own.
461
516
  base.id = slugId(name, [...draft.map((x) => x.id), ...owned()]);
462
517
  const at = from ? draft.indexOf(from) + 1 : draft.length;
@@ -57,7 +57,7 @@ export function aiPrompt(text, diff, owned, before) {
57
57
  diff.changed.length ? `Changed: ${names(diff.changed.map((c) => c.after))}.` : "",
58
58
  "",
59
59
  `Looks that players own and that must stay in the file with the same id: ${ownedOk.length ? quote(ownedOk) : "none yet"}${odd ? ` (and ${odd} more with an id that is not a valid look id: do not remove any look)` : ""}.`,
60
- `Rules: keep one free look (priceUsd 0), prices in US dollars with at most 2 decimals, never change or remove the id of a look that someone owns, never change the slots of a look that someone owns (slots are words and #rrggbb colours that the game reads, at most 8 on a look), and a look never changes a score or a rule. (The file had ${before.length} looks before.)`,
60
+ `Rules: keep one free outfit (priceUsd 0, no "wear"), prices in US dollars with at most 2 decimals, never change or remove the id of a look that someone owns, never change the wear slot or the slots of a look that someone owns (a look with "wear" is an item of that slot, such as a shovel, and has no "change"; slots are words and #rrggbb colours that the game reads, at most 8 on a look), and a look never changes a score or a rule. (The file had ${before.length} looks before.)`,
61
61
  "",
62
62
  "looks.json:",
63
63
  "```json",
@@ -71,7 +71,7 @@ export type AdminLogin = {
71
71
  /** How many sign-ins the panel lists. */
72
72
  export declare const ADMIN_LOGINS_SHOWN = 10;
73
73
  /**
74
- * The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the two settings and, when the game turns it on, the deletion of the admin sessions
74
+ * The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the three settings and, when the game turns it on, the deletion of the admin sessions
75
75
  * (`signOutEverywhere`). Nothing else.
76
76
  * (There is no call that writes a look, a price or an order: the Shelf gives text to copy, and a change of looks is a change of the repository.)
77
77
  */
@@ -80,6 +80,7 @@ export interface AdminStore {
80
80
  readSettings(): Promise<{
81
81
  character?: unknown;
82
82
  signin?: unknown;
83
+ friends?: unknown;
83
84
  }>;
84
85
  /** Saves a checked patch: all of it or none of it. */
85
86
  saveSettings(patch: GamePatch, now: number): Promise<void>;
@@ -2,25 +2,40 @@
2
2
  export type Character = "nanoterra" | "own";
3
3
  /** How the game signs a Nanoterra player in: the code exchange, a signed ticket (a game with no server), or not at all. */
4
4
  export type Signin = "exchange" | "ticket" | "none";
5
+ /**
6
+ * Which friends list the game shows: Nanoterra's (the nano133 window, "@nano133/nanoterra/friends/panel"), the game's own (its own players and store), or none.
7
+ * A three-way choice, not an On/Off switch.
8
+ */
9
+ export type Friends = "nanoterra" | "own" | "off";
10
+ /** The game's own words for the switches. `friends` may be left out: it is "nanoterra" then (on by default). */
5
11
  export type GameSettings = {
6
12
  character: Character;
7
13
  signin: Signin;
14
+ friends?: Friends;
8
15
  };
9
16
  /** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
10
17
  export type GamePatch = {
11
18
  character?: Character;
12
19
  signin?: "exchange" | "none";
20
+ friends?: Friends;
13
21
  };
14
22
  /** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
15
- export type GameState = GameSettings & {
23
+ export type GameState = {
24
+ character: Character;
25
+ signin: Signin;
26
+ friends: Friends;
16
27
  fromAdmin: {
17
28
  character: boolean;
18
29
  signin: boolean;
30
+ friends: boolean;
19
31
  };
20
32
  };
21
33
  export declare const isCharacter: (v: unknown) => v is Character;
22
34
  export declare const isSigninChoice: (v: unknown) => v is "exchange" | "none";
23
35
  export declare const isSignin: (v: unknown) => v is Signin;
36
+ export declare const isFriends: (v: unknown) => v is Friends;
37
+ /** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
38
+ export declare const DEFAULT_FRIENDS: Friends;
24
39
  /** The sign-in switch is on for any way but "none". */
25
40
  export declare const signinOn: (s: Signin) => boolean;
26
41
  /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
@@ -35,7 +50,14 @@ export declare function parsePatch(body: unknown): {
35
50
  export declare function mergeSettings(stored: {
36
51
  character?: unknown;
37
52
  signin?: unknown;
53
+ friends?: unknown;
38
54
  }, defaults: GameSettings): GameState;
55
+ /** The friends choice of the card: its title, and for each choice its short name (the card's three keys) and one plain sentence. Nanoterra's is the default. */
56
+ export declare const FRIENDS_TITLE = "Friends list";
57
+ export declare const FRIENDS_CHOICES: Record<Friends, {
58
+ name: string;
59
+ note: string;
60
+ }>;
39
61
  /** One switch of the card: its title, and one plain sentence for each state. */
40
62
  export type SwitchId = "character" | "signin";
41
63
  export declare const SWITCHES: Record<SwitchId, {
@@ -1,20 +1,27 @@
1
- // The two switches of the "Game" card in /admin, and the words that say what each does. The handler checks a press with `parsePatch`; the panel
2
- // draws the card from `SWITCHES`. A switch changes ONLY these two settings; never a look, a price or an order.
1
+ // The switches of the "Game" card in /admin, and the words that say what each does. The handler checks a press with `parsePatch`; the panel
2
+ // draws the card from `SWITCHES` and `FRIENDS_CHOICES`. A switch changes ONLY these three settings; never a look, a price or an order.
3
3
  export const isCharacter = (v) => v === "nanoterra" || v === "own";
4
4
  export const isSigninChoice = (v) => v === "exchange" || v === "none";
5
5
  export const isSignin = (v) => v === "exchange" || v === "ticket" || v === "none";
6
+ export const isFriends = (v) => v === "nanoterra" || v === "own" || v === "off";
7
+ /** The friends list a game shows while neither its own words nor /admin say: Nanoterra's. */
8
+ export const DEFAULT_FRIENDS = "nanoterra";
6
9
  /** The sign-in switch is on for any way but "none". */
7
10
  export const signinOn = (s) => s !== "none";
8
11
  /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
9
12
  export function parsePatch(body) {
10
13
  if (!body || typeof body !== "object" || Array.isArray(body))
11
- return { ok: false, error: 'Send an object with "character" or "signin".' };
14
+ return { ok: false, error: 'Send an object with "character", "signin" or "friends".' };
12
15
  const patch = {};
13
16
  for (const [key, value] of Object.entries(body)) {
14
17
  if (key === "character" && isCharacter(value))
15
18
  patch.character = value;
16
19
  else if (key === "signin" && isSigninChoice(value))
17
20
  patch.signin = value;
21
+ else if (key === "friends" && isFriends(value))
22
+ patch.friends = value;
23
+ else if (key === "friends")
24
+ return { ok: false, error: '"friends" must be "nanoterra", "own" or "off".' };
18
25
  else if (key === "character")
19
26
  return { ok: false, error: '"character" must be "nanoterra" or "own".' };
20
27
  else if (key === "signin")
@@ -23,7 +30,7 @@ export function parsePatch(body) {
23
30
  return { ok: false, error: `"${key}" is not a game switch.` };
24
31
  }
25
32
  if (!Object.keys(patch).length)
26
- return { ok: false, error: 'Send "character" or "signin".' };
33
+ return { ok: false, error: 'Send "character", "signin" or "friends".' };
27
34
  return { ok: true, patch };
28
35
  }
29
36
  /** The settings from what the store kept and the game's defaults. A stored value that is not one of the known words is ignored. */
@@ -31,9 +38,23 @@ export function mergeSettings(stored, defaults) {
31
38
  return {
32
39
  character: isCharacter(stored.character) ? stored.character : defaults.character,
33
40
  signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
34
- fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin) },
41
+ friends: isFriends(stored.friends) ? stored.friends : isFriends(defaults.friends) ? defaults.friends : DEFAULT_FRIENDS,
42
+ fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin), friends: isFriends(stored.friends) },
35
43
  };
36
44
  }
45
+ /** The friends choice of the card: its title, and for each choice its short name (the card's three keys) and one plain sentence. Nanoterra's is the default. */
46
+ export const FRIENDS_TITLE = "Friends list";
47
+ export const FRIENDS_CHOICES = {
48
+ nanoterra: {
49
+ name: "Nanoterra",
50
+ note: "Nanoterra: a player signed in on nano133 sees their Nanoterra friends in a small nano133 frame in your game, and their friends see that they are playing it. The friends stay on nano133: your game gets none of them.",
51
+ },
52
+ own: {
53
+ name: "The game's own",
54
+ note: "The game's own: a friends list of this game's players, kept in this game's own database. Nanoterra friends do not show.",
55
+ },
56
+ off: { name: "Off", note: "Off: the game shows no friends list." },
57
+ };
37
58
  export const SWITCHES = {
38
59
  character: {
39
60
  title: "Players can play as their Nanoterra character",
@@ -0,0 +1,4 @@
1
+ import type { D1Like } from "../../admin/adapters/d1.js";
2
+ import { type FriendsStore } from "../types.js";
3
+ export type { D1Like, D1StatementLike } from "../../admin/adapters/d1.js";
4
+ export declare function createD1Friends(db: D1Like): FriendsStore;