@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 +12 -0
- package/README.md +28 -1
- package/ai/SPEC.md +2 -1
- package/dist/admin/panel/mount.js +2 -2
- package/dist/admin/panel/shelf.d.ts +4 -1
- package/dist/admin/panel/shelf.js +84 -29
- package/dist/admin/panel/shelfData.js +1 -1
- package/dist/looks/change.d.ts +5 -5
- package/dist/looks/change.js +12 -7
- package/dist/looks/index.d.ts +2 -2
- package/dist/looks/index.js +1 -1
- package/dist/looks/json.d.ts +3 -2
- package/dist/looks/json.js +16 -9
- package/dist/looks/look.d.ts +26 -6
- package/dist/looks/look.js +37 -12
- package/package.json +1 -1
- package/src/admin/panel/mount.ts +2 -2
- package/src/admin/panel/shelf.ts +135 -65
- package/src/admin/panel/shelfData.ts +1 -1
- package/src/looks/change.ts +12 -9
- package/src/looks/index.ts +2 -2
- package/src/looks/json.ts +12 -7
- package/src/looks/look.ts +47 -9
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
|
-
|
|
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
|
-
/**
|
|
114
|
-
const colourOf = (id) => options.looks.find((l) => l.id === id)?.change
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
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
|
|
420
|
-
|
|
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
|
|
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
|
|
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",
|
package/dist/looks/change.d.ts
CHANGED
|
@@ -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
|
|
7
|
-
* `owned` is every look id that any player owns. The free look is not owned by an
|
|
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;
|
package/dist/looks/change.js
CHANGED
|
@@ -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
|
|
16
|
-
* `owned` is every look id that any player owns. The free look is not owned by an
|
|
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
|
|
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
|
|
58
|
+
if (a.change?.hat !== b.change?.hat)
|
|
54
59
|
fields.push("hat");
|
|
55
|
-
if ((a.change
|
|
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");
|
package/dist/looks/index.d.ts
CHANGED
|
@@ -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/dist/looks/index.js
CHANGED
|
@@ -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";
|
package/dist/looks/json.d.ts
CHANGED
|
@@ -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,
|
|
4
|
-
* starter's file is written, so a change of one look is a change of one line in a diff.
|
|
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"). */
|
package/dist/looks/json.js
CHANGED
|
@@ -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,
|
|
4
|
-
* starter's file is written, so a change of one look is a change of one line in a diff.
|
|
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
|
-
|
|
9
|
-
if (l
|
|
10
|
-
change
|
|
11
|
-
|
|
12
|
-
|
|
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}
|
|
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
|
}
|
package/dist/looks/look.d.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
/**
|
|
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
|
+
};
|
package/dist/looks/look.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/admin/panel/mount.ts
CHANGED
|
@@ -156,8 +156,8 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
|
|
|
156
156
|
return shelf.el;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
/**
|
|
160
|
-
const colourOf = (id: string): string | null => options.looks.find((l) => l.id === id)?.change
|
|
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");
|
package/src/admin/panel/shelf.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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: "
|
|
563
|
-
|
|
564
|
-
|
|
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
|
|
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
|
|
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",
|
package/src/looks/change.ts
CHANGED
|
@@ -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
|
|
18
|
-
* `owned` is every look id that any player owns. The free look is not owned by an
|
|
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
|
|
59
|
-
if (a.change.
|
|
60
|
-
if (
|
|
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
|
}
|
package/src/looks/index.ts
CHANGED
|
@@ -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,
|
|
5
|
-
* starter's file is written, so a change of one look is a change of one line in a diff.
|
|
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
|
-
|
|
10
|
-
if (l
|
|
11
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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;
|