@nano133/nanoterra 0.4.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 (2026-10-30)
4
+
5
+ - **Wear slots** (`@nano133/nanoterra/looks`, and the Shelf of `/admin/panel`): a look fills one wear slot, and a player wears one look in each slot at once. An outfit (no
6
+ `wear`, or `"outfit"`: `OUTFIT`) is the character's own look, with its `change` (the body colour and the hat), as before. Any other `wear` is the game's own slot (a tool's
7
+ skin, a charm: `"wear": "shovel"`): an item there has no `change`. `lookProblems` refuses a bad slot name (field `wear`) and a `change` on an item (field `change`), and
8
+ now asks for one free OUTFIT (a list of outfits only keeps the starter's words); `checkLooks` leaves `wear` out for an outfit and `change` out for an item; `looksJson`
9
+ writes `wear` after `priceUsd`; `removalProblems` refuses a changed wear slot on an owned look (field `wear`); `diffLooks` lists `"wear"`. New: `OUTFIT`, `wearOf`,
10
+ `isOutfit`, the type `Change`. **`Look.change` is optional in the type now** (read an outfit's colours after `isOutfit`): a game that reads `look.change.body` directly
11
+ changes that line. The Shelf shows an item's slot and a tag picture, edits its wear slot (locked for an owned look and the last free outfit) in place of the body and hat,
12
+ and its message for an AI assistant names the wear rule. A file of outfits only reads and writes exactly as before. Echo Island sells shovel and Pinger skins and charms
13
+ with it (Wes, 2026-10-30).
14
+
3
15
  ## 0.4.0 (2026-10-30)
4
16
 
5
17
  - **The admin panel (`@nano133/nanoterra/admin/panel`) has a new layout: the game at a glance, in tabs.** A top bar (who is signed in, Live or Test mode, the bot
package/README.md CHANGED
@@ -380,11 +380,38 @@ type Look = {
380
380
  id: string; // 1 to 40 lowercase letters, digits or dashes: "mint-cap"
381
381
  name: string; // 1 to 40 characters
382
382
  priceUsd: number; // US dollars, at most 2 decimals; 0 is the free look
383
- change: { body: string; hat?: "cap" | "beanie" | "crown" | "tophat"; hatColor?: string }; // colours are "#rrggbb"
383
+ wear?: string; // the wear slot it fills: none (an outfit), or your game's own slot ("shovel"): see "Wear slots" below
384
+ change?: { body: string; hat?: "cap" | "beanie" | "crown" | "tophat"; hatColor?: string }; // an outfit's colours ("#rrggbb"); an item of another slot has none
384
385
  slots?: Record<string, string>; // what YOUR game defines for itself: see "Slots" below. Optional.
385
386
  };
386
387
  ```
387
388
 
389
+ ### Wear slots: an outfit, and the items beside it
390
+
391
+ A look fills one **wear slot**, and a player wears one look in each slot at once. An **outfit** (no `wear`, or `"wear": "outfit"`) is the character's own look: its body colour and
392
+ its hat (`change`). Every other wear slot is **your game's own**: a tool's skin, a charm, a trail. An item there has **no `change`** (it does not change the body); your game knows
393
+ what it is from its id or its `slots`:
394
+
395
+ ```json
396
+ { "id": "classic", "name": "Classic", "priceUsd": 0, "change": { "body": "#4aa8ff" } },
397
+ { "id": "toy-spade", "name": "Toy Spade", "priceUsd": 0.1, "wear": "shovel" },
398
+ { "id": "echo-bat", "name": "Echo Bat", "priceUsd": 1, "wear": "pinger", "slots": { "ring": "ripples" } }
399
+ ```
400
+
401
+ - A wear slot's name is like a slot's: 1 to 24 lowercase letters, digits or dashes, starting with a letter (`SLOT_NAME_PATTERN`); `outfit` (`OUTFIT`) is the outfit's.
402
+ - **Keep one free OUTFIT:** every player starts with it. A free item is fine beside it, but it does not count.
403
+ - `"change"` on an item is a mistake (`lookProblems`, field `change`): an item never changes the body. An outfit still needs its `change.body`.
404
+ - `checkLooks` leaves `wear` out for an outfit (no `wear` and `"wear": "outfit"` are the same look) and `change` out for an item; `looksJson` writes `wear` after `priceUsd`, on the
405
+ look's own line. `wearOf(look)` gives the slot (`OUTFIT` for an outfit); `isOutfit(look)` tells an outfit (with its `change`) from an item.
406
+ - **Players own a look by its id, so the wear slot of a look that a player owns must not change:** a shovel that a player owns stays a shovel. `removalProblems` reports it with the
407
+ field `"wear"`; `diffLooks` lists it as `"wear"`.
408
+ - The Shelf shows an item's slot on its card ("Worn in the shovel slot") and a tag in place of the character picture (give the Shelf your own `picture` to draw the real item), and
409
+ its editor has a **Wear slot** field (locked for an owned look and the last free outfit) in place of the body and hat of an outfit.
410
+ - What a player wears in each slot is your game's to keep (the starter keeps the outfit in the browser); ownership stays by id, as before.
411
+
412
+ A file of outfits only reads and writes exactly as it did. `change` is optional in the type now: read an outfit's colours after `isOutfit(look)` (or `look.change?.body`). This is a
413
+ minor change (0.5.0); a game on 0.4 or before drops `wear`, so a game with items must move to the new version in the game, the server and the Shelf.
414
+
388
415
  ### Slots: what your game defines on a look
389
416
 
390
417
  A game with its own parts (a pair of wings, a trail colour) needs a place for them on a look. That place is `slots`, an optional object of names and values:
package/ai/SPEC.md CHANGED
@@ -100,9 +100,10 @@ export const LOOKS = checkLooks(rawLooksJson); // throws with the first mistake
100
100
  - One free look (`priceUsd: 0`) at least. Never change or remove the id of a look that someone owns. Prices in US dollars with at most 2 decimals; the server reads the price from the file.
101
101
  - A look only changes how the character looks. No random look, no loot box, no look that changes a score.
102
102
  - `slots` (optional, at most 8: a name and a word or a `#rrggbb` colour) is where the game keeps its own parts of a look. Read `look.slots` from `checkLooks`. Never change the slots of a look that someone owns: add a new look.
103
+ - `wear` (optional) is the wear slot a look fills: none for an outfit (the character's colours and hat, `change`), or the game's own slot (`"shovel"`, `"charm"`) for an item, which has no `change`. A player wears one look in each slot. Keep one free outfit. Never change the wear slot of a look that someone owns. Read outfits with `isOutfit(look)`.
103
104
  - The game is the seller. Never send looks, prices or sales to another site.
104
105
 
105
- **Check:** `checkLooks` passes at start; a removed look that is owned is refused; a changed slot on an owned look is refused.
106
+ **Check:** `checkLooks` passes at start; a removed look that is owned is refused; a changed slot or wear slot on an owned look is refused.
106
107
 
107
108
  ## 8. Leaving the page (any game that a player walks in with keys or a stick)
108
109
 
@@ -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) => {
@@ -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",
@@ -3,16 +3,16 @@ import { type Look, type Problem, type Slots } from "./look.js";
3
3
  export declare function sameSlots(a: Slots | undefined, b: Slots | undefined): boolean;
4
4
  /**
5
5
  * The mistakes that a new list would make for the players: an id that someone owns (a paid order gave it to them) is gone from the new
6
- * list, or its slots changed (the game reads the slots of the id that a player owns, so a new slot changes what they own: add a new look instead).
7
- * `owned` is every look id that any player owns. The free look is not owned by an order: it is every player's, and its slots may change.
8
- * A change of a price, a name, a body colour or a hat on an owned look is allowed.
6
+ * list, its wear slot changed (a shovel that a player owns must stay a shovel), or its slots changed (the game reads the slots of the id that a player
7
+ * owns, so a new slot changes what they own: add a new look instead). `owned` is every look id that any player owns. The free look is not owned by an
8
+ * order: it is every player's, and its slots may change. A change of a price, a name, a body colour or a hat on an owned look is allowed.
9
9
  */
10
10
  export declare function removalProblems(before: readonly Look[], after: readonly Look[], owned: Iterable<string>): Problem[];
11
- export type LookField = "name" | "priceUsd" | "body" | "hat" | "hatColor" | "slots";
11
+ export type LookField = "name" | "priceUsd" | "wear" | "body" | "hat" | "hatColor" | "slots";
12
12
  export type LooksDiff = {
13
13
  added: Look[];
14
14
  removed: Look[];
15
- /** Looks with the same id and a different name, price, colour, hat or slots. */
15
+ /** Looks with the same id and a different name, price, wear slot, colour, hat or slots. */
16
16
  changed: {
17
17
  id: string;
18
18
  before: Look;
@@ -1,4 +1,4 @@
1
- import { COLOR_PATTERN, cleanSlots } from "./look.js";
1
+ import { COLOR_PATTERN, cleanSlots, wearOf } from "./look.js";
2
2
  /** Whether two looks have the same slots: the same names with the same values, in any order. No slots and empty slots are the same; a colour is the same in upper and lower case. */
3
3
  export function sameSlots(a, b) {
4
4
  const x = cleanSlots(a) ?? {};
@@ -12,9 +12,9 @@ export function sameSlots(a, b) {
12
12
  // What a change of the list does, before it goes live. Owned looks are stored by id: a look that someone owns must stay in the file.
13
13
  /**
14
14
  * The mistakes that a new list would make for the players: an id that someone owns (a paid order gave it to them) is gone from the new
15
- * list, or its slots changed (the game reads the slots of the id that a player owns, so a new slot changes what they own: add a new look instead).
16
- * `owned` is every look id that any player owns. The free look is not owned by an order: it is every player's, and its slots may change.
17
- * A change of a price, a name, a body colour or a hat on an owned look is allowed.
15
+ * list, its wear slot changed (a shovel that a player owns must stay a shovel), or its slots changed (the game reads the slots of the id that a player
16
+ * owns, so a new slot changes what they own: add a new look instead). `owned` is every look id that any player owns. The free look is not owned by an
17
+ * order: it is every player's, and its slots may change. A change of a price, a name, a body colour or a hat on an owned look is allowed.
18
18
  */
19
19
  export function removalProblems(before, after, owned) {
20
20
  const now = new Set(after.map((l) => l.id));
@@ -28,6 +28,9 @@ export function removalProblems(before, after, owned) {
28
28
  if (!kept) {
29
29
  out.push({ look: null, field: "id", message: `The look "${l.id}" (${l.name}) is owned by a player and is not in the new list. Keep it in the file; change its price if you do not want to sell it.` });
30
30
  }
31
+ else if (wearOf(l) !== wearOf(kept.look)) {
32
+ out.push({ look: kept.place, field: "wear", message: `The look "${l.id}" (${l.name}) is worn in the "${wearOf(l)}" slot, and a player owns it. Keep it in that slot, or add a new look for the "${wearOf(kept.look)}" slot.` });
33
+ }
31
34
  else if (!sameSlots(l.slots, kept.look.slots)) {
32
35
  out.push({ look: kept.place, field: "slots", message: `The slots of the look "${l.id}" (${l.name}) changed, and a player owns it. Keep its slots as they were, or add a new look with the new slots.` });
33
36
  }
@@ -48,11 +51,13 @@ export function diffLooks(before, after) {
48
51
  fields.push("name");
49
52
  if (a.priceUsd !== b.priceUsd)
50
53
  fields.push("priceUsd");
51
- if (a.change.body.toLowerCase() !== b.change.body.toLowerCase())
54
+ if (wearOf(a) !== wearOf(b))
55
+ fields.push("wear");
56
+ if ((a.change?.body ?? "").toLowerCase() !== (b.change?.body ?? "").toLowerCase())
52
57
  fields.push("body");
53
- if (a.change.hat !== b.change.hat)
58
+ if (a.change?.hat !== b.change?.hat)
54
59
  fields.push("hat");
55
- if ((a.change.hatColor ?? "").toLowerCase() !== (b.change.hatColor ?? "").toLowerCase())
60
+ if ((a.change?.hatColor ?? "").toLowerCase() !== (b.change?.hatColor ?? "").toLowerCase())
56
61
  fields.push("hatColor");
57
62
  if (!sameSlots(a.slots, b.slots))
58
63
  fields.push("slots");
@@ -1,5 +1,5 @@
1
- export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText } from "./look.js";
2
- export type { Hat, Look, Problem, Slots } from "./look.js";
1
+ export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText, wearOf, isOutfit } from "./look.js";
2
+ export type { Change, Hat, Look, Problem, Slots } from "./look.js";
3
3
  export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
4
4
  export type { Rung } from "./ladder.js";
5
5
  export { removalProblems, diffLooks, sameSlots } from "./change.js";
@@ -1,4 +1,4 @@
1
- export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText } from "./look.js";
1
+ export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText, wearOf, isOutfit } from "./look.js";
2
2
  export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
3
3
  export { removalProblems, diffLooks, sameSlots } from "./change.js";
4
4
  export { looksJson, slugId } from "./json.js";
@@ -1,7 +1,8 @@
1
1
  import { type Look } from "./look.js";
2
2
  /**
3
- * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, change (and slots, when the look has any), as the game
4
- * starter's file is written, so a change of one look is a change of one line in a diff. A look with no slots is written exactly as before. It ends with a new line.
3
+ * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, wear (an item of a wear slot other than the outfit's),
4
+ * change (an outfit) and slots (when the look has any), as the game starter's file is written, so a change of one look is a change of one line in a diff. An
5
+ * outfit with no slots is written exactly as before. It ends with a new line.
5
6
  */
6
7
  export declare function looksJson(looks: readonly Look[]): string;
7
8
  /** An id for a name: lowercase letters, digits and dashes, at most 40, and not one of `taken` ("mint-cap", then "mint-cap-2"). */
@@ -1,18 +1,25 @@
1
- import { cleanSlots, ID_PATTERN } from "./look.js";
1
+ import { cleanSlots, ID_PATTERN, isOutfit, OUTFIT, wearOf } from "./look.js";
2
2
  /**
3
- * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, change (and slots, when the look has any), as the game
4
- * starter's file is written, so a change of one look is a change of one line in a diff. A look with no slots is written exactly as before. It ends with a new line.
3
+ * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, wear (an item of a wear slot other than the outfit's),
4
+ * change (an outfit) and slots (when the look has any), as the game starter's file is written, so a change of one look is a change of one line in a diff. An
5
+ * outfit with no slots is written exactly as before. It ends with a new line.
5
6
  */
6
7
  export function looksJson(looks) {
7
8
  const row = (l) => {
8
- const change = [`"body": ${JSON.stringify(l.change.body)}`];
9
- if (l.change.hat !== undefined)
10
- change.push(`"hat": ${JSON.stringify(l.change.hat)}`);
11
- if (l.change.hatColor !== undefined)
12
- change.push(`"hatColor": ${JSON.stringify(l.change.hatColor)}`);
9
+ let middle;
10
+ if (isOutfit(l)) {
11
+ const change = [`"body": ${JSON.stringify(l.change.body)}`];
12
+ if (l.change.hat !== undefined)
13
+ change.push(`"hat": ${JSON.stringify(l.change.hat)}`);
14
+ if (l.change.hatColor !== undefined)
15
+ change.push(`"hatColor": ${JSON.stringify(l.change.hatColor)}`);
16
+ middle = `, "change": { ${change.join(", ")} }`;
17
+ }
18
+ else
19
+ middle = wearOf(l) === OUTFIT ? "" : `, "wear": ${JSON.stringify(wearOf(l))}`;
13
20
  const slots = cleanSlots(l.slots);
14
21
  const tail = slots ? `, "slots": { ${Object.keys(slots).map((k) => `${JSON.stringify(k)}: ${JSON.stringify(slots[k])}`).join(", ")} }` : "";
15
- return ` { "id": ${JSON.stringify(l.id)}, "name": ${JSON.stringify(l.name)}, "priceUsd": ${l.priceUsd}, "change": { ${change.join(", ")} }${tail} }`;
22
+ return ` { "id": ${JSON.stringify(l.id)}, "name": ${JSON.stringify(l.name)}, "priceUsd": ${l.priceUsd}${middle}${tail} }`;
16
23
  };
17
24
  return `[\n${looks.map(row).join(",\n")}\n]\n`;
18
25
  }
@@ -1,6 +1,12 @@
1
1
  /** The hats that the starter's character can wear. */
2
2
  export declare const HATS: readonly ["cap", "beanie", "crown", "tophat"];
3
3
  export type Hat = (typeof HATS)[number];
4
+ /** What an outfit changes: the body's colour, and a hat in its colour. */
5
+ export type Change = {
6
+ body: string;
7
+ hat?: Hat;
8
+ hatColor?: string;
9
+ };
4
10
  export type Look = {
5
11
  /** 1 to 40 lowercase letters, digits or dashes: "mint-cap". Never change the id of a look that someone owns. */
6
12
  id: string;
@@ -8,11 +14,14 @@ export type Look = {
8
14
  name: string;
9
15
  /** The price in US dollars with at most 2 decimals, for example 0.25. 0 means free for everyone. */
10
16
  priceUsd: number;
11
- change: {
12
- body: string;
13
- hat?: Hat;
14
- hatColor?: string;
15
- };
17
+ /**
18
+ * The wear slot that the look fills: none (or "outfit") for an outfit, or a slot of the game's own ("shovel", "charm"): 1 to 24 lowercase letters, digits
19
+ * or dashes, starting with a letter. A player wears one look in each slot. Never change the slot of a look that someone owns. `checkLooks` leaves it out
20
+ * for an outfit.
21
+ */
22
+ wear?: string;
23
+ /** What an outfit changes (an outfit must have it; an item of another slot must not: `isOutfit`). */
24
+ change?: Change;
16
25
  /**
17
26
  * What a game defines for itself: up to 8 slots, each a name and a value, that the game's own code reads ("wings": "ember", "trail": "#ffcf5a"). A name is 1 to 24
18
27
  * lowercase letters, digits or dashes, starting with a letter; a value is a word (1 to 40 lowercase letters, digits or dashes) or a colour ("#rrggbb"). Optional: a look
@@ -23,6 +32,8 @@ export type Look = {
23
32
  /** The slots of a look: names and values, words or colours. */
24
33
  export type Slots = Record<string, string>;
25
34
  export declare const ID_PATTERN: RegExp;
35
+ /** The wear slot of an outfit (the character's own look). A look with no `wear` is an outfit too. */
36
+ export declare const OUTFIT = "outfit";
26
37
  export declare const COLOR_PATTERN: RegExp;
27
38
  /** At most this many slots on a look. */
28
39
  export declare const SLOTS_MAX = 8;
@@ -38,7 +49,10 @@ export declare const PRICE_MAX_USD = 1000;
38
49
  export type Problem = {
39
50
  /** The place of the look in the list, from 1, or null for a mistake of the whole list. */
40
51
  look: number | null;
41
- /** The field: "list", "id", "name", "priceUsd", "change.body", "change.hat", "change.hatColor", "slots", "slots.<name>" or "free". (The change rules also use "slots" for a slot that changed on a look that players own.) */
52
+ /**
53
+ * The field: "list", "id", "name", "priceUsd", "wear", "change", "change.body", "change.hat", "change.hatColor", "slots", "slots.<name>" or "free". (The change
54
+ * rules also use "slots" and "wear" for a slot or a wear slot that changed on a look that players own.)
55
+ */
42
56
  field: string;
43
57
  message: string;
44
58
  };
@@ -60,3 +74,9 @@ export declare function cleanSlots(slots: Slots | undefined): Slots | undefined;
60
74
  export declare function checkLooks(rawInput: unknown): Look[];
61
75
  /** "$0.25" */
62
76
  export declare const usdText: (usd: number) => string;
77
+ /** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
78
+ export declare const wearOf: (l: Pick<Look, "wear">) => string;
79
+ /** Whether a look is an outfit (the character's own look, with its `change`), not an item of another wear slot. */
80
+ export declare const isOutfit: (l: Look) => l is Look & {
81
+ change: Change;
82
+ };
@@ -1,8 +1,14 @@
1
1
  // The look: one thing a player can buy to change how their character looks. The rules are the game starter's (looks.json): a look only
2
2
  // changes how the character looks, it is never random and it never changes a score, a speed or a rule.
3
+ //
4
+ // A look fills one WEAR SLOT. An outfit (no `wear`, or "outfit") is the character's own look: its body colour and its hat (`change`); a player wears one
5
+ // outfit. Every other wear slot is the game's own (a tool's skin, a charm, a trail: "shovel", "charm"): an item there has no `change` (the game reads what
6
+ // it is from its id or its `slots`), and a player wears one item in each slot, beside the outfit.
3
7
  /** The hats that the starter's character can wear. */
4
8
  export const HATS = ["cap", "beanie", "crown", "tophat"];
5
9
  export const ID_PATTERN = /^[a-z0-9-]{1,40}$/;
10
+ /** The wear slot of an outfit (the character's own look). A look with no `wear` is an outfit too. */
11
+ export const OUTFIT = "outfit";
6
12
  export const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
7
13
  /** At most this many slots on a look. */
8
14
  export const SLOTS_MAX = 8;
@@ -27,7 +33,7 @@ export function lookProblems(raw) {
27
33
  if (!Array.isArray(raw) || raw.length === 0)
28
34
  return [{ look: null, field: "list", message: "looks.json must be a list with at least one look" }];
29
35
  const seen = new Set();
30
- let free = false;
36
+ let freeOutfit = false;
31
37
  raw.forEach((l, i) => {
32
38
  const where = `looks.json, look ${i + 1}`;
33
39
  const v = (l ?? {});
@@ -44,19 +50,32 @@ export function lookProblems(raw) {
44
50
  add("priceUsd", '"priceUsd" must be a number from 0 to 1000');
45
51
  else if (!wholeCents(v.priceUsd))
46
52
  add("priceUsd", '"priceUsd" can have at most 2 decimals (cents)');
47
- else if (v.priceUsd === 0)
48
- free = true;
49
- if (typeof v.change?.body !== "string" || !COLOR_PATTERN.test(v.change.body))
50
- add("change.body", '"change.body" must be a colour like "#4aa8ff"');
51
- if (v.change?.hat !== undefined && !HATS.includes(v.change.hat))
52
- add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
53
- if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor)))
54
- add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
53
+ const wearOk = v.wear === undefined || (typeof v.wear === "string" && SLOT_NAME_PATTERN.test(v.wear) && !RESERVED_SLOT_NAMES.includes(v.wear));
54
+ if (!wearOk)
55
+ add("wear", `"wear" must be "${OUTFIT}" or the name of a wear slot: 1 to 24 lowercase letters, digits or dashes, starting with a letter ("shovel")`);
56
+ const outfit = v.wear === undefined || v.wear === OUTFIT;
57
+ if (outfit) {
58
+ if (typeof v.change?.body !== "string" || !COLOR_PATTERN.test(v.change.body))
59
+ add("change.body", '"change.body" must be a colour like "#4aa8ff"');
60
+ if (v.change?.hat !== undefined && !HATS.includes(v.change.hat))
61
+ add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
62
+ if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor)))
63
+ add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
64
+ if (v.priceUsd === 0)
65
+ freeOutfit = true;
66
+ }
67
+ else if (wearOk && v.change !== undefined)
68
+ add("change", `"change" is only for an outfit: a look worn in the "${v.wear}" slot does not change the body (remove "change")`);
55
69
  if (v.slots !== undefined)
56
70
  slotProblems(v.slots).forEach((s) => add(s.field, s.message));
57
71
  });
58
- if (!out.length && !free)
59
- out.push({ look: null, field: "free", message: "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
72
+ // the starter's words for a list of outfits only; a list with items says that an item does not count
73
+ const items = raw.some((l) => {
74
+ const w = l?.wear;
75
+ return w !== undefined && w !== OUTFIT;
76
+ });
77
+ if (!out.length && !freeOutfit)
78
+ out.push({ look: null, field: "free", message: items ? 'looks.json needs at least one free outfit (priceUsd 0, and no "wear"): every player starts with it' : "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
60
79
  return out;
61
80
  }
62
81
  /** Whether `v` is a plain object (not null, not a list, not a class instance). */
@@ -106,8 +125,14 @@ export function checkLooks(rawInput) {
106
125
  throw new Error(first.message);
107
126
  return raw.map((l) => {
108
127
  const slots = cleanSlots(l.slots);
109
- return { id: l.id, name: l.name, priceUsd: l.priceUsd, change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor }, ...(slots ? { slots } : {}) };
128
+ const wear = wearOf(l);
129
+ const change = isOutfit(l) ? { change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor } } : {};
130
+ return { id: l.id, name: l.name, priceUsd: l.priceUsd, ...(wear === OUTFIT ? {} : { wear }), ...change, ...(slots ? { slots } : {}) };
110
131
  });
111
132
  }
112
133
  /** "$0.25" */
113
134
  export const usdText = (usd) => `$${usd.toFixed(2)}`;
135
+ /** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
136
+ export const wearOf = (l) => l.wear ?? OUTFIT;
137
+ /** Whether a look is an outfit (the character's own look, with its `change`), not an item of another wear slot. */
138
+ export const isOutfit = (l) => wearOf(l) === OUTFIT && l.change !== undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton) and the looks ladder.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -156,8 +156,8 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
156
156
  return shelf.el;
157
157
  }
158
158
 
159
- /** A look's body colour, while the game still has the look (for the dots in the lists). */
160
- const colourOf = (id: string): string | null => options.looks.find((l) => l.id === id)?.change.body ?? null;
159
+ /** 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. */
160
+ const colourOf = (id: string): string | null => options.looks.find((l) => l.id === id)?.change?.body ?? null;
161
161
 
162
162
  function build(): Dashboard {
163
163
  const tabs = tabBar("overview");
@@ -10,7 +10,7 @@ import { diffLooks, removalProblems } from "../../looks/change.js";
10
10
  import { SLOTS_MAX } from "../../looks/look.js";
11
11
  import { looksJson, slugId } from "../../looks/json.js";
12
12
  import { PRICE_LADDER, priceText, rungOf, rungUsd } from "../../looks/ladder.js";
13
- import { COLOR_PATTERN, HATS, lookProblems, usdText, type Hat, type Look, type Problem } from "../../looks/look.js";
13
+ import { COLOR_PATTERN, HATS, isOutfit, lookProblems, OUTFIT, SLOT_NAME_PATTERN, usdText, wearOf, type Change, type Hat, type Look, type Problem } from "../../looks/look.js";
14
14
  import { copy, dollars, h, plural } from "./dom.js";
15
15
  import { icon } from "./icons.js";
16
16
  import { aiPrompt, changeText, ownedIds, shelfStats, soldText, type LookStats, type ShelfOrder, type ShelfPlayer } from "./shelfData.js";
@@ -38,8 +38,23 @@ function hatShapes(hat: Hat, fill: string): Element[] {
38
38
  return hats[hat]();
39
39
  }
40
40
 
41
- /** 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.) */
41
+ /**
42
+ * 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
43
+ * knows what the item is). (The game draws the real thing; a game can give its own picture.)
44
+ */
42
45
  export function lookPicture(l: Look): Element {
46
+ if (!isOutfit(l)) {
47
+ const wear = wearOf(l);
48
+ const label = svg("text", { x: 33, y: 40, "font-size": 9, "font-family": "system-ui, sans-serif", "text-anchor": "middle", fill: INK });
49
+ label.textContent = wear.slice(0, 8);
50
+ return svg(
51
+ "svg",
52
+ { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: worn in the ${wear} slot` },
53
+ shape("path", { d: "M14 20 L36 20 L52 36 L36 52 L14 52 Z", fill: "#f6e7c1" }),
54
+ shape("circle", { cx: 22, cy: 36, r: 3.5, fill: "#ffffff" }),
55
+ label,
56
+ );
57
+ }
43
58
  const hat = l.change.hat;
44
59
  return svg(
45
60
  "svg",
@@ -72,7 +87,7 @@ export function previewPicture(load: () => Promise<(l: Look) => Promise<string |
72
87
  return (l) => {
73
88
  const box = h("span", { class: "shelf-preview" }, fallback(l));
74
89
  ready ??= load().catch(() => null);
75
- const look: Look = { ...l, change: { ...l.change }, ...(l.slots ? { slots: { ...l.slots } } : {}) };
90
+ const look = copyLook(l);
76
91
  void ready
77
92
  .then((render) => (render ? render(look) : null))
78
93
  .then((src) => {
@@ -85,7 +100,9 @@ export function previewPicture(load: () => Promise<(l: Look) => Promise<string |
85
100
  };
86
101
  }
87
102
 
88
- const clone = (ls: readonly Look[]): Look[] => ls.map((l) => ({ id: l.id, name: l.name, priceUsd: l.priceUsd, change: { ...l.change }, ...(l.slots ? { slots: { ...l.slots } } : {}) }));
103
+ /** A copy of a look that shares nothing with it: its wear slot, an outfit's change, its slots. */
104
+ const copyLook = (l: Look): Look => ({ 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 } } : {}) });
105
+ const clone = (ls: readonly Look[]): Look[] => ls.map(copyLook);
89
106
 
90
107
  /** The names of the slots that differ between two looks (added, removed or another value). */
91
108
  function slotNamesChanged(before: Look, after: Look): string {
@@ -98,7 +115,8 @@ function fieldWords(d: ReturnType<typeof diffLooks>["changed"][number]): string
98
115
  if (f === "name") return `name "${d.before.name}" to "${d.after.name}"`;
99
116
  if (f === "priceUsd") return `price ${usdText(d.before.priceUsd)} to ${usdText(d.after.priceUsd)}`;
100
117
  if (f === "body") return "body colour";
101
- if (f === "hat") return `hat ${d.before.change.hat ?? "none"} to ${d.after.change.hat ?? "none"}`;
118
+ if (f === "hat") return `hat ${d.before.change?.hat ?? "none"} to ${d.after.change?.hat ?? "none"}`;
119
+ if (f === "wear") return `wear slot ${wearOf(d.before)} to ${wearOf(d.after)}`;
102
120
  if (f === "slots") return `slots changed (${slotNamesChanged(d.before, d.after)})`;
103
121
  return "hat colour";
104
122
  });
@@ -187,7 +205,12 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
187
205
  const stats = () => shelfStats(data.orders, data.players);
188
206
  const owned = () => ownedIds(data.players);
189
207
  const isOriginal = (id: string) => LOOKS.some((l) => l.id === id);
190
- const freeCount = () => draft.filter((l) => l.priceUsd === 0).length;
208
+ /** Free outfits in the draft: one must stay (every player starts with it). */
209
+ const freeCount = () => draft.filter((l) => l.priceUsd === 0 && wearOf(l) === OUTFIT).length;
210
+ /** The wear slots that the draft uses besides the outfit, for the wear slot field's suggestions. */
211
+ const wearSlots = () => [...new Set(draft.map(wearOf).filter((w) => w !== OUTFIT))].sort();
212
+ /** What a card says under the price: who has it (a free outfit), what it sold, or that it is new. */
213
+ const fineWords = (l: Look, s: LookStats | undefined) => (l.priceUsd === 0 && wearOf(l) === OUTFIT ? "Every player starts with it" : soldText(s));
191
214
  /** 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). */
192
215
  const changedFromGame = () => looksJson(draft) !== looksJson(LOOKS);
193
216
 
@@ -198,7 +221,7 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
198
221
  const pictures = new Map<string, Element>();
199
222
  let used = new Set<string>();
200
223
  function pictureOf(l: Look): Element {
201
- const key = JSON.stringify([l.id, l.name, l.priceUsd, l.change, l.slots ?? null]);
224
+ const key = JSON.stringify([l.id, l.name, l.priceUsd, l.wear ?? null, l.change ?? null, l.slots ?? null]);
202
225
  const kept = pictures.get(key);
203
226
  if (kept && !used.has(key)) {
204
227
  used.add(key);
@@ -331,10 +354,16 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
331
354
 
332
355
  function stage(l: Look, ...extra: (Node | null)[]): HTMLElement {
333
356
  const box = h("div", { class: "shelf-stage" }, h("div", { class: "shelf-pic" }, pictureOf(l)), ...extra);
334
- if (COLOR_PATTERN.test(l.change.body)) box.style.setProperty("--tint", l.change.body);
357
+ const body = l.change?.body;
358
+ if (body && COLOR_PATTERN.test(body)) box.style.setProperty("--tint", body);
335
359
  return box;
336
360
  }
337
361
 
362
+ /** An item's wear slot, under its name ("Worn in the shovel slot"); nothing for an outfit. */
363
+ function wearLine(l: Look): HTMLElement | null {
364
+ return wearOf(l) === OUTFIT ? null : h("span", { class: "fine shelf-wear" }, `Worn in the ${wearOf(l)} slot`);
365
+ }
366
+
338
367
  function slotList(l: Look): HTMLElement | null {
339
368
  const entries = Object.entries(l.slots ?? {});
340
369
  if (!entries.length) return null;
@@ -353,7 +382,8 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
353
382
  "div",
354
383
  { class: "shelf-body" },
355
384
  h("strong", { class: "shelf-name" }, l.name || "(no name)"),
356
- h("span", { class: "fine" }, l.priceUsd === 0 ? "Every player starts with it" : soldText(s)),
385
+ wearLine(l),
386
+ h("span", { class: "fine" }, fineWords(l, s)),
357
387
  share ? h("div", { class: "share", title: `${share}% of the money from sales` }, bar, h("span", { class: "fine" }, `${share}% of sales`)) : null,
358
388
  slotList(l),
359
389
  ),
@@ -400,7 +430,8 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
400
430
  { class: "shelf-body" },
401
431
  h("strong", { class: "shelf-name" }, l.name || "(no name)"),
402
432
  h("span", { class: "shelf-price" }, priceWords(l.priceUsd)),
403
- 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"),
433
+ wearLine(l),
434
+ h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"),
404
435
  marks(l, own, mine, mark),
405
436
  ),
406
437
  h(
@@ -448,7 +479,7 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
448
479
  const stageBox = h("div", { class: "shelf-stage is-big" }, pic);
449
480
  const nameLine = h("strong", { class: "shelf-name" }, l.name || "(no name)");
450
481
  const priceLine = h("span", { class: "shelf-price" }, priceWords(l.priceUsd));
451
- const tint = () => (COLOR_PATTERN.test(l.change.body) ? stageBox.style.setProperty("--tint", l.change.body) : stageBox.style.removeProperty("--tint"));
482
+ const tint = () => (l.change && COLOR_PATTERN.test(l.change.body) ? stageBox.style.setProperty("--tint", l.change.body) : stageBox.style.removeProperty("--tint"));
452
483
  tint();
453
484
  // The real picture is drawn a moment after the last change: a colour dragged across the picker draws once, not at every step.
454
485
  let later: ReturnType<typeof setTimeout> | null = null;
@@ -459,8 +490,10 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
459
490
  if (later) clearTimeout(later);
460
491
  later = setTimeout(() => pic.replaceChildren(pictureOf(l)), 150);
461
492
  ladder.querySelectorAll<HTMLButtonElement>("button").forEach((c) => c.setAttribute("aria-pressed", Number(c.dataset.usd) === l.priceUsd ? "true" : "false"));
462
- swatches.querySelectorAll<HTMLButtonElement>("button").forEach((c) => c.setAttribute("aria-pressed", c.dataset.colour === l.change.body.toLowerCase() ? "true" : "false"));
463
- hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
493
+ if (outfit) {
494
+ lookParts?.swatches.querySelectorAll<HTMLButtonElement>("button").forEach((c) => c.setAttribute("aria-pressed", c.dataset.colour === outfit.change.body.toLowerCase() ? "true" : "false"));
495
+ lookParts?.hatChoices.style.setProperty("color", outfit.change.hatColor ?? "#9aa4b2");
496
+ }
464
497
  refreshFoot();
465
498
  };
466
499
 
@@ -486,6 +519,85 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
486
519
  ladder.append(step(0, "Free", "everyone", "Every player has it"));
487
520
  for (const r of PRICE_LADDER) ladder.append(step(rungUsd(r), priceText(rungUsd(r)), r.name, `${r.name}: ${r.for}`));
488
521
 
522
+ // An outfit's body colour and hat (an item of another wear slot has neither).
523
+ const outfit = isOutfit(l) ? l : null;
524
+ const lookParts = outfit ? outfitFields(outfit, i, sync) : null;
525
+ const lastFree = l.priceUsd === 0 && wearOf(l) === OUTFIT && freeCount() === 1;
526
+ const keep = isOriginal(l.id) && own.has(l.id);
527
+ 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: () => {
528
+ draft = draft.filter((x) => x !== l);
529
+ open = null;
530
+ redraw();
531
+ } }, icon("trash", 18), "Remove");
532
+ const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, icon("dup", 18), "Duplicate");
533
+
534
+ const openMarks = marks(l, own, mine, mark);
535
+ const openProblems = h("ul", { class: "card-problems" }, ...mine.map((p) => h("li", {}, p.message)));
536
+ openProblems.hidden = !mine.length;
537
+ openParts = { marks: openMarks, problems: openProblems };
538
+ const group = (title: string, ...kids: (Node | null)[]) => h("div", { class: "edit-group" }, h("h3", {}, title), ...kids);
539
+ return h(
540
+ "article",
541
+ { class: `shelf-card shelf-editing${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}`, tabindex: -1, "aria-label": `Editing ${l.name || "a look"}` },
542
+ h(
543
+ "div",
544
+ { class: "edit-top" },
545
+ stageBox,
546
+ h(
547
+ "div",
548
+ { class: "shelf-body" },
549
+ nameLine,
550
+ priceLine,
551
+ wearLine(l),
552
+ h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"),
553
+ openMarks,
554
+ ),
555
+ h("button", { class: "btn btn-small card-done", type: "button", "aria-expanded": "true", onclick: () => openCard(null) }, icon("check", 16), "Done"),
556
+ ),
557
+ openProblems,
558
+ h(
559
+ "div",
560
+ { class: "edit-groups" },
561
+ group("Name", h("label", { class: "field" }, h("span", {}, "What players read"), name)),
562
+ group("Price", h("label", { class: "field" }, h("span", {}, "Price in US dollars"), priceInput), ladder),
563
+ wearEditor(l, keep || lastFree, i),
564
+ lookParts?.group ?? null,
565
+ slotsEditor(l, isOriginal(l.id) && own.has(l.id)),
566
+ ),
567
+ keep ? h("p", { class: "fine owned-note" }, icon("lock", 14), "A player owns this look: its id stays, and it cannot be removed.") : null,
568
+ h("div", { class: "shelf-actions" }, dup, remove),
569
+ );
570
+ }
571
+
572
+ /**
573
+ * 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
574
+ * 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
575
+ * item that becomes an outfit gets the free look's blue body.
576
+ */
577
+ function wearEditor(l: Look, locked: boolean, i: number): HTMLElement {
578
+ const list = `n133-wears-${i}`;
579
+ 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: Event) => {
580
+ const v = (e.target as HTMLInputElement).value.trim();
581
+ if (v === "" || v === OUTFIT) {
582
+ delete l.wear;
583
+ l.change ??= { body: "#4aa8ff" };
584
+ } else {
585
+ l.wear = v;
586
+ if (SLOT_NAME_PATTERN.test(v)) delete l.change;
587
+ }
588
+ redraw();
589
+ } });
590
+ return h(
591
+ "div",
592
+ { class: "edit-group" },
593
+ h("h3", {}, "Wear slot"),
594
+ 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),
595
+ h("datalist", { id: list }, ...[OUTFIT, ...wearSlots()].map((w) => h("option", { value: w }))),
596
+ );
597
+ }
598
+
599
+ /** An outfit's fields: the body colour (swatches and a picker), the hat (pictures) and the hat colour, as the editor's "Look" group. */
600
+ function outfitFields(l: Look & { change: Change }, i: number, sync: () => void): { group: HTMLElement; swatches: HTMLElement; hatChoices: HTMLElement } {
489
601
  const body = h("input", { class: "colour", type: "color", value: l.change.body, "aria-label": `Body colour of ${l.name}`, oninput: (e: Event) => {
490
602
  l.change.body = (e.target as HTMLInputElement).value;
491
603
  sync();
@@ -524,61 +636,19 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
524
636
  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")));
525
637
  }
526
638
  hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
527
-
528
- const lastFree = l.priceUsd === 0 && freeCount() === 1;
529
- const keep = isOriginal(l.id) && own.has(l.id);
530
- 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: () => {
531
- draft = draft.filter((x) => x !== l);
532
- open = null;
533
- redraw();
534
- } }, icon("trash", 18), "Remove");
535
- const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, icon("dup", 18), "Duplicate");
536
-
537
- const openMarks = marks(l, own, mine, mark);
538
- const openProblems = h("ul", { class: "card-problems" }, ...mine.map((p) => h("li", {}, p.message)));
539
- openProblems.hidden = !mine.length;
540
- openParts = { marks: openMarks, problems: openProblems };
541
- const group = (title: string, ...kids: (Node | null)[]) => h("div", { class: "edit-group" }, h("h3", {}, title), ...kids);
542
- return h(
543
- "article",
544
- { class: `shelf-card shelf-editing${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}`, tabindex: -1, "aria-label": `Editing ${l.name || "a look"}` },
545
- h(
546
- "div",
547
- { class: "edit-top" },
548
- stageBox,
549
- h(
550
- "div",
551
- { class: "shelf-body" },
552
- nameLine,
553
- priceLine,
554
- 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"),
555
- openMarks,
556
- ),
557
- h("button", { class: "btn btn-small card-done", type: "button", "aria-expanded": "true", onclick: () => openCard(null) }, icon("check", 16), "Done"),
558
- ),
559
- openProblems,
639
+ const group = h(
640
+ "div",
641
+ { class: "edit-group group-look" },
642
+ h("h3", {}, "Look"),
560
643
  h(
561
644
  "div",
562
- { class: "edit-groups" },
563
- group("Name", h("label", { class: "field" }, h("span", {}, "What players read"), name)),
564
- group("Price", h("label", { class: "field" }, h("span", {}, "Price in US dollars"), priceInput), ladder),
565
- h(
566
- "div",
567
- { class: "edit-group group-look" },
568
- h("h3", {}, "Look"),
569
- h(
570
- "div",
571
- { class: "look-fields" },
572
- h("div", { class: "field" }, h("span", {}, "Body colour"), h("div", { class: "colour-row" }, swatches, body)),
573
- h("div", { class: "field" }, h("span", {}, "Hat"), hatChoices),
574
- h("label", { class: "field" }, h("span", {}, "Hat colour"), hatColor),
575
- ),
576
- ),
577
- slotsEditor(l, isOriginal(l.id) && own.has(l.id)),
645
+ { class: "look-fields" },
646
+ h("div", { class: "field" }, h("span", {}, "Body colour"), h("div", { class: "colour-row" }, swatches, body)),
647
+ h("div", { class: "field" }, h("span", {}, "Hat"), hatChoices),
648
+ h("label", { class: "field" }, h("span", {}, "Hat colour"), hatColor),
578
649
  ),
579
- keep ? h("p", { class: "fine owned-note" }, icon("lock", 14), "A player owns this look: its id stays, and it cannot be removed.") : null,
580
- h("div", { class: "shelf-actions" }, dup, remove),
581
650
  );
651
+ return { group, swatches, hatChoices };
582
652
  }
583
653
 
584
654
  /** The slots of a look: names and values (words or #rrggbb colours) that the game reads. Rows to type in, at most SLOTS_MAX. */
@@ -622,7 +692,7 @@ export function shelfView(first: ShelfData, looks: readonly Look[], picture: (l:
622
692
 
623
693
  function addLook(from?: Look) {
624
694
  const name = from ? `${from.name} copy`.slice(0, 40) : "New look";
625
- const base: Look = from ? { id: "", name, priceUsd: from.priceUsd, change: { ...from.change }, ...(from.slots ? { slots: { ...from.slots } } : {}) } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
695
+ const base: Look = from ? { ...copyLook(from), id: "", name } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
626
696
  // 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.
627
697
  base.id = slugId(name, [...draft.map((x) => x.id), ...owned()]);
628
698
  const at = from ? draft.indexOf(from) + 1 : draft.length;
@@ -76,7 +76,7 @@ export function aiPrompt(text: string, diff: LooksDiff, owned: readonly string[]
76
76
  diff.changed.length ? `Changed: ${names(diff.changed.map((c) => c.after))}.` : "",
77
77
  "",
78
78
  `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)` : ""}.`,
79
- `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.)`,
79
+ `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.)`,
80
80
  "",
81
81
  "looks.json:",
82
82
  "```json",
@@ -1,4 +1,4 @@
1
- import { COLOR_PATTERN, cleanSlots, type Look, type Problem, type Slots } from "./look.js";
1
+ import { COLOR_PATTERN, cleanSlots, wearOf, type Look, type Problem, type Slots } from "./look.js";
2
2
 
3
3
  /** Whether two looks have the same slots: the same names with the same values, in any order. No slots and empty slots are the same; a colour is the same in upper and lower case. */
4
4
  export function sameSlots(a: Slots | undefined, b: Slots | undefined): boolean {
@@ -14,9 +14,9 @@ export function sameSlots(a: Slots | undefined, b: Slots | undefined): boolean {
14
14
 
15
15
  /**
16
16
  * The mistakes that a new list would make for the players: an id that someone owns (a paid order gave it to them) is gone from the new
17
- * list, or its slots changed (the game reads the slots of the id that a player owns, so a new slot changes what they own: add a new look instead).
18
- * `owned` is every look id that any player owns. The free look is not owned by an order: it is every player's, and its slots may change.
19
- * A change of a price, a name, a body colour or a hat on an owned look is allowed.
17
+ * list, its wear slot changed (a shovel that a player owns must stay a shovel), or its slots changed (the game reads the slots of the id that a player
18
+ * owns, so a new slot changes what they own: add a new look instead). `owned` is every look id that any player owns. The free look is not owned by an
19
+ * order: it is every player's, and its slots may change. A change of a price, a name, a body colour or a hat on an owned look is allowed.
20
20
  */
21
21
  export function removalProblems(before: readonly Look[], after: readonly Look[], owned: Iterable<string>): Problem[] {
22
22
  const now = new Set(after.map((l) => l.id));
@@ -28,6 +28,8 @@ export function removalProblems(before: readonly Look[], after: readonly Look[],
28
28
  const kept = next.get(l.id);
29
29
  if (!kept) {
30
30
  out.push({ look: null, field: "id", message: `The look "${l.id}" (${l.name}) is owned by a player and is not in the new list. Keep it in the file; change its price if you do not want to sell it.` });
31
+ } else if (wearOf(l) !== wearOf(kept.look)) {
32
+ out.push({ look: kept.place, field: "wear", message: `The look "${l.id}" (${l.name}) is worn in the "${wearOf(l)}" slot, and a player owns it. Keep it in that slot, or add a new look for the "${wearOf(kept.look)}" slot.` });
31
33
  } else if (!sameSlots(l.slots, kept.look.slots)) {
32
34
  out.push({ look: kept.place, field: "slots", message: `The slots of the look "${l.id}" (${l.name}) changed, and a player owns it. Keep its slots as they were, or add a new look with the new slots.` });
33
35
  }
@@ -35,12 +37,12 @@ export function removalProblems(before: readonly Look[], after: readonly Look[],
35
37
  return out;
36
38
  }
37
39
 
38
- export type LookField = "name" | "priceUsd" | "body" | "hat" | "hatColor" | "slots";
40
+ export type LookField = "name" | "priceUsd" | "wear" | "body" | "hat" | "hatColor" | "slots";
39
41
 
40
42
  export type LooksDiff = {
41
43
  added: Look[];
42
44
  removed: Look[];
43
- /** Looks with the same id and a different name, price, colour, hat or slots. */
45
+ /** Looks with the same id and a different name, price, wear slot, colour, hat or slots. */
44
46
  changed: { id: string; before: Look; after: Look; fields: LookField[] }[];
45
47
  };
46
48
 
@@ -55,9 +57,10 @@ export function diffLooks(before: readonly Look[], after: readonly Look[]): Look
55
57
  const fields: LookField[] = [];
56
58
  if (a.name !== b.name) fields.push("name");
57
59
  if (a.priceUsd !== b.priceUsd) fields.push("priceUsd");
58
- if (a.change.body.toLowerCase() !== b.change.body.toLowerCase()) fields.push("body");
59
- if (a.change.hat !== b.change.hat) fields.push("hat");
60
- if ((a.change.hatColor ?? "").toLowerCase() !== (b.change.hatColor ?? "").toLowerCase()) fields.push("hatColor");
60
+ if (wearOf(a) !== wearOf(b)) fields.push("wear");
61
+ if ((a.change?.body ?? "").toLowerCase() !== (b.change?.body ?? "").toLowerCase()) fields.push("body");
62
+ if (a.change?.hat !== b.change?.hat) fields.push("hat");
63
+ if ((a.change?.hatColor ?? "").toLowerCase() !== (b.change?.hatColor ?? "").toLowerCase()) fields.push("hatColor");
61
64
  if (!sameSlots(a.slots, b.slots)) fields.push("slots");
62
65
  if (fields.length) diff.changed.push({ id: a.id, before: b, after: a, fields });
63
66
  }
@@ -1,5 +1,5 @@
1
- export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText } from "./look.js";
2
- export type { Hat, Look, Problem, Slots } from "./look.js";
1
+ export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText, wearOf, isOutfit } from "./look.js";
2
+ export type { Change, Hat, Look, Problem, Slots } from "./look.js";
3
3
  export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
4
4
  export type { Rung } from "./ladder.js";
5
5
  export { removalProblems, diffLooks, sameSlots } from "./change.js";
package/src/looks/json.ts CHANGED
@@ -1,17 +1,22 @@
1
- import { cleanSlots, ID_PATTERN, type Look } from "./look.js";
1
+ import { cleanSlots, ID_PATTERN, isOutfit, OUTFIT, wearOf, type Look } from "./look.js";
2
2
 
3
3
  /**
4
- * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, change (and slots, when the look has any), as the game
5
- * starter's file is written, so a change of one look is a change of one line in a diff. A look with no slots is written exactly as before. It ends with a new line.
4
+ * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, wear (an item of a wear slot other than the outfit's),
5
+ * change (an outfit) and slots (when the look has any), as the game starter's file is written, so a change of one look is a change of one line in a diff. An
6
+ * outfit with no slots is written exactly as before. It ends with a new line.
6
7
  */
7
8
  export function looksJson(looks: readonly Look[]): string {
8
9
  const row = (l: Look) => {
9
- const change = [`"body": ${JSON.stringify(l.change.body)}`];
10
- if (l.change.hat !== undefined) change.push(`"hat": ${JSON.stringify(l.change.hat)}`);
11
- if (l.change.hatColor !== undefined) change.push(`"hatColor": ${JSON.stringify(l.change.hatColor)}`);
10
+ let middle: string;
11
+ if (isOutfit(l)) {
12
+ const change = [`"body": ${JSON.stringify(l.change.body)}`];
13
+ if (l.change.hat !== undefined) change.push(`"hat": ${JSON.stringify(l.change.hat)}`);
14
+ if (l.change.hatColor !== undefined) change.push(`"hatColor": ${JSON.stringify(l.change.hatColor)}`);
15
+ middle = `, "change": { ${change.join(", ")} }`;
16
+ } else middle = wearOf(l) === OUTFIT ? "" : `, "wear": ${JSON.stringify(wearOf(l))}`;
12
17
  const slots = cleanSlots(l.slots);
13
18
  const tail = slots ? `, "slots": { ${Object.keys(slots).map((k) => `${JSON.stringify(k)}: ${JSON.stringify(slots[k])}`).join(", ")} }` : "";
14
- return ` { "id": ${JSON.stringify(l.id)}, "name": ${JSON.stringify(l.name)}, "priceUsd": ${l.priceUsd}, "change": { ${change.join(", ")} }${tail} }`;
19
+ return ` { "id": ${JSON.stringify(l.id)}, "name": ${JSON.stringify(l.name)}, "priceUsd": ${l.priceUsd}${middle}${tail} }`;
15
20
  };
16
21
  return `[\n${looks.map(row).join(",\n")}\n]\n`;
17
22
  }
package/src/looks/look.ts CHANGED
@@ -1,10 +1,17 @@
1
1
  // The look: one thing a player can buy to change how their character looks. The rules are the game starter's (looks.json): a look only
2
2
  // changes how the character looks, it is never random and it never changes a score, a speed or a rule.
3
+ //
4
+ // A look fills one WEAR SLOT. An outfit (no `wear`, or "outfit") is the character's own look: its body colour and its hat (`change`); a player wears one
5
+ // outfit. Every other wear slot is the game's own (a tool's skin, a charm, a trail: "shovel", "charm"): an item there has no `change` (the game reads what
6
+ // it is from its id or its `slots`), and a player wears one item in each slot, beside the outfit.
3
7
 
4
8
  /** The hats that the starter's character can wear. */
5
9
  export const HATS = ["cap", "beanie", "crown", "tophat"] as const;
6
10
  export type Hat = (typeof HATS)[number];
7
11
 
12
+ /** What an outfit changes: the body's colour, and a hat in its colour. */
13
+ export type Change = { body: string; hat?: Hat; hatColor?: string };
14
+
8
15
  export type Look = {
9
16
  /** 1 to 40 lowercase letters, digits or dashes: "mint-cap". Never change the id of a look that someone owns. */
10
17
  id: string;
@@ -12,7 +19,14 @@ export type Look = {
12
19
  name: string;
13
20
  /** The price in US dollars with at most 2 decimals, for example 0.25. 0 means free for everyone. */
14
21
  priceUsd: number;
15
- change: { body: string; hat?: Hat; hatColor?: string };
22
+ /**
23
+ * The wear slot that the look fills: none (or "outfit") for an outfit, or a slot of the game's own ("shovel", "charm"): 1 to 24 lowercase letters, digits
24
+ * or dashes, starting with a letter. A player wears one look in each slot. Never change the slot of a look that someone owns. `checkLooks` leaves it out
25
+ * for an outfit.
26
+ */
27
+ wear?: string;
28
+ /** What an outfit changes (an outfit must have it; an item of another slot must not: `isOutfit`). */
29
+ change?: Change;
16
30
  /**
17
31
  * What a game defines for itself: up to 8 slots, each a name and a value, that the game's own code reads ("wings": "ember", "trail": "#ffcf5a"). A name is 1 to 24
18
32
  * lowercase letters, digits or dashes, starting with a letter; a value is a word (1 to 40 lowercase letters, digits or dashes) or a colour ("#rrggbb"). Optional: a look
@@ -25,6 +39,8 @@ export type Look = {
25
39
  export type Slots = Record<string, string>;
26
40
 
27
41
  export const ID_PATTERN = /^[a-z0-9-]{1,40}$/;
42
+ /** The wear slot of an outfit (the character's own look). A look with no `wear` is an outfit too. */
43
+ export const OUTFIT = "outfit";
28
44
  export const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
29
45
  /** At most this many slots on a look. */
30
46
  export const SLOTS_MAX = 8;
@@ -41,7 +57,10 @@ export const PRICE_MAX_USD = 1000;
41
57
  export type Problem = {
42
58
  /** The place of the look in the list, from 1, or null for a mistake of the whole list. */
43
59
  look: number | null;
44
- /** The field: "list", "id", "name", "priceUsd", "change.body", "change.hat", "change.hatColor", "slots", "slots.<name>" or "free". (The change rules also use "slots" for a slot that changed on a look that players own.) */
60
+ /**
61
+ * The field: "list", "id", "name", "priceUsd", "wear", "change", "change.body", "change.hat", "change.hatColor", "slots", "slots.<name>" or "free". (The change
62
+ * rules also use "slots" and "wear" for a slot or a wear slot that changed on a look that players own.)
63
+ */
45
64
  field: string;
46
65
  message: string;
47
66
  };
@@ -59,7 +78,7 @@ export function lookProblems(raw: unknown): Problem[] {
59
78
  const out: Problem[] = [];
60
79
  if (!Array.isArray(raw) || raw.length === 0) return [{ look: null, field: "list", message: "looks.json must be a list with at least one look" }];
61
80
  const seen = new Set<string>();
62
- let free = false;
81
+ let freeOutfit = false;
63
82
  raw.forEach((l: unknown, i: number) => {
64
83
  const where = `looks.json, look ${i + 1}`;
65
84
  const v = (l ?? {}) as Record<string, any>;
@@ -70,13 +89,24 @@ export function lookProblems(raw: unknown): Problem[] {
70
89
  if (typeof v.name !== "string" || !v.name.trim() || v.name.length > NAME_MAX) add("name", '"name" must be 1 to 40 characters');
71
90
  if (typeof v.priceUsd !== "number" || !Number.isFinite(v.priceUsd) || v.priceUsd < 0 || v.priceUsd > PRICE_MAX_USD) add("priceUsd", '"priceUsd" must be a number from 0 to 1000');
72
91
  else if (!wholeCents(v.priceUsd)) add("priceUsd", '"priceUsd" can have at most 2 decimals (cents)');
73
- else if (v.priceUsd === 0) free = true;
74
- if (typeof v.change?.body !== "string" || !COLOR_PATTERN.test(v.change.body)) add("change.body", '"change.body" must be a colour like "#4aa8ff"');
75
- if (v.change?.hat !== undefined && !(HATS as readonly unknown[]).includes(v.change.hat)) add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
76
- if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor))) add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
92
+ const wearOk = v.wear === undefined || (typeof v.wear === "string" && SLOT_NAME_PATTERN.test(v.wear) && !RESERVED_SLOT_NAMES.includes(v.wear));
93
+ if (!wearOk) add("wear", `"wear" must be "${OUTFIT}" or the name of a wear slot: 1 to 24 lowercase letters, digits or dashes, starting with a letter ("shovel")`);
94
+ const outfit = v.wear === undefined || v.wear === OUTFIT;
95
+ if (outfit) {
96
+ if (typeof v.change?.body !== "string" || !COLOR_PATTERN.test(v.change.body)) add("change.body", '"change.body" must be a colour like "#4aa8ff"');
97
+ if (v.change?.hat !== undefined && !(HATS as readonly unknown[]).includes(v.change.hat)) add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
98
+ if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor))) add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
99
+ if (v.priceUsd === 0) freeOutfit = true;
100
+ } else if (wearOk && v.change !== undefined) add("change", `"change" is only for an outfit: a look worn in the "${v.wear}" slot does not change the body (remove "change")`);
77
101
  if (v.slots !== undefined) slotProblems(v.slots).forEach((s) => add(s.field, s.message));
78
102
  });
79
- if (!out.length && !free) out.push({ look: null, field: "free", message: "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
103
+ // the starter's words for a list of outfits only; a list with items says that an item does not count
104
+ const items = raw.some((l: unknown) => {
105
+ const w = (l as { wear?: unknown } | null)?.wear;
106
+ return w !== undefined && w !== OUTFIT;
107
+ });
108
+ if (!out.length && !freeOutfit)
109
+ out.push({ look: null, field: "free", message: items ? 'looks.json needs at least one free outfit (priceUsd 0, and no "wear"): every player starts with it' : "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
80
110
  return out;
81
111
  }
82
112
 
@@ -123,9 +153,17 @@ export function checkLooks(rawInput: unknown): Look[] {
123
153
  if (first) throw new Error(first.message);
124
154
  return (raw as Look[]).map((l) => {
125
155
  const slots = cleanSlots(l.slots);
126
- return { id: l.id, name: l.name, priceUsd: l.priceUsd, change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor }, ...(slots ? { slots } : {}) };
156
+ const wear = wearOf(l);
157
+ const change = isOutfit(l) ? { change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor } } : {};
158
+ return { id: l.id, name: l.name, priceUsd: l.priceUsd, ...(wear === OUTFIT ? {} : { wear }), ...change, ...(slots ? { slots } : {}) };
127
159
  });
128
160
  }
129
161
 
130
162
  /** "$0.25" */
131
163
  export const usdText = (usd: number): string => `$${usd.toFixed(2)}`;
164
+
165
+ /** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
166
+ export const wearOf = (l: Pick<Look, "wear">): string => l.wear ?? OUTFIT;
167
+
168
+ /** Whether a look is an outfit (the character's own look, with its `change`), not an item of another wear slot. */
169
+ export const isOutfit = (l: Look): l is Look & { change: Change } => wearOf(l) === OUTFIT && l.change !== undefined;