@nano133/nanoterra 0.3.2 → 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 +29 -0
- package/README.md +51 -7
- package/ai/SPEC.md +2 -1
- package/dist/admin/panel/dom.d.ts +11 -0
- package/dist/admin/panel/dom.js +33 -0
- package/dist/admin/panel/gameCard.d.ts +5 -2
- package/dist/admin/panel/gameCard.js +8 -4
- package/dist/admin/panel/icons.d.ts +121 -0
- package/dist/admin/panel/icons.js +41 -0
- package/dist/admin/panel/index.d.ts +11 -2
- package/dist/admin/panel/index.js +6 -1
- package/dist/admin/panel/mount.js +101 -67
- package/dist/admin/panel/orders.d.ts +23 -0
- package/dist/admin/panel/orders.js +130 -0
- package/dist/admin/panel/overview.d.ts +22 -0
- package/dist/admin/panel/overview.js +75 -0
- package/dist/admin/panel/players.d.ts +7 -0
- package/dist/admin/panel/players.js +61 -0
- package/dist/admin/panel/shelf.d.ts +15 -3
- package/dist/admin/panel/shelf.js +385 -84
- package/dist/admin/panel/shelfData.js +1 -1
- package/dist/admin/panel/tabs.d.ts +23 -0
- package/dist/admin/panel/tabs.js +68 -0
- 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/dom.ts +34 -0
- package/src/admin/panel/gameCard.ts +8 -4
- package/src/admin/panel/icons.ts +46 -0
- package/src/admin/panel/index.ts +11 -2
- package/src/admin/panel/mount.ts +120 -101
- package/src/admin/panel/orders.ts +172 -0
- package/src/admin/panel/overview.ts +134 -0
- package/src/admin/panel/players.ts +79 -0
- package/src/admin/panel/shelf.ts +549 -121
- package/src/admin/panel/shelfData.ts +1 -1
- package/src/admin/panel/style.css +1775 -270
- package/src/admin/panel/tabs.ts +94 -0
- 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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The panel's tab bar: Overview, Cosmetics, Orders, Players and Settings. Real tabs (role tablist, tab and tabpanel): a press or the arrow keys, Home and End
|
|
2
|
+
// choose one, and only the chosen tab is in the Tab order. Every panel is made once and only hidden, so a draft, a search or a switch keeps its state when
|
|
3
|
+
// the creator goes to another tab and back. The choice lives in this page's memory only: the panel never writes the page's address (the host page owns it).
|
|
4
|
+
|
|
5
|
+
import { h } from "./dom.js";
|
|
6
|
+
import { icon, type IconName } from "./icons.js";
|
|
7
|
+
|
|
8
|
+
export type TabId = "overview" | "cosmetics" | "orders" | "players" | "settings";
|
|
9
|
+
|
|
10
|
+
export const TABS: readonly { id: TabId; label: string; icon: IconName }[] = [
|
|
11
|
+
{ id: "overview", label: "Overview", icon: "overview" },
|
|
12
|
+
{ id: "cosmetics", label: "Cosmetics", icon: "shirt" },
|
|
13
|
+
{ id: "orders", label: "Orders", icon: "receipt" },
|
|
14
|
+
{ id: "players", label: "Players", icon: "players" },
|
|
15
|
+
{ id: "settings", label: "Settings", icon: "settings" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export type TabBar = {
|
|
19
|
+
/** The bar (role tablist). */
|
|
20
|
+
bar: HTMLElement;
|
|
21
|
+
/** The panel of each tab (role tabpanel), empty: the caller fills it. */
|
|
22
|
+
panels: Record<TabId, HTMLElement>;
|
|
23
|
+
/**
|
|
24
|
+
* Shows a tab. `focus` moves the keyboard to it (an arrow key does; a link from another tab does not). When the page was scrolled into the panels, the top of
|
|
25
|
+
* the new panel comes up under the bar (a short tab after a long one must not open at its end).
|
|
26
|
+
*/
|
|
27
|
+
select(id: TabId, focus?: boolean): void;
|
|
28
|
+
/** The tab that shows now. */
|
|
29
|
+
readonly current: TabId;
|
|
30
|
+
/** The small number on a tab ("Orders 25"); null takes it away. */
|
|
31
|
+
setCount(id: TabId, n: number | null): void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function tabBar(first: TabId = "overview"): TabBar {
|
|
35
|
+
let current: TabId = first;
|
|
36
|
+
const bar = h("div", { class: "tabbar", role: "tablist", "aria-label": "Admin sections" });
|
|
37
|
+
const buttons = {} as Record<TabId, HTMLButtonElement>;
|
|
38
|
+
const counts = {} as Record<TabId, HTMLElement>;
|
|
39
|
+
const panels = {} as Record<TabId, HTMLElement>;
|
|
40
|
+
|
|
41
|
+
for (const t of TABS) {
|
|
42
|
+
counts[t.id] = h("span", { class: "tab-count" });
|
|
43
|
+
counts[t.id].hidden = true;
|
|
44
|
+
const b = h(
|
|
45
|
+
"button",
|
|
46
|
+
{ class: "tabbar-tab", type: "button", role: "tab", id: `n133-tab-${t.id}`, "aria-controls": `n133-panel-${t.id}` },
|
|
47
|
+
icon(t.icon, 18),
|
|
48
|
+
h("span", { class: "tab-label" }, t.label),
|
|
49
|
+
counts[t.id],
|
|
50
|
+
);
|
|
51
|
+
b.addEventListener("click", () => select(t.id));
|
|
52
|
+
b.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
53
|
+
const i = TABS.findIndex((x) => x.id === t.id);
|
|
54
|
+
const next = e.key === "ArrowRight" ? i + 1 : e.key === "ArrowLeft" ? i - 1 : e.key === "Home" ? 0 : e.key === "End" ? TABS.length - 1 : null;
|
|
55
|
+
if (next === null) return;
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
select(TABS[(next + TABS.length) % TABS.length].id, true);
|
|
58
|
+
});
|
|
59
|
+
buttons[t.id] = b;
|
|
60
|
+
bar.append(b);
|
|
61
|
+
panels[t.id] = h("section", { class: `tabpanel tabpanel-${t.id}`, role: "tabpanel", id: `n133-panel-${t.id}`, "aria-labelledby": `n133-tab-${t.id}`, tabindex: 0 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function select(id: TabId, focus = false) {
|
|
65
|
+
current = id;
|
|
66
|
+
for (const t of TABS) {
|
|
67
|
+
const on = t.id === id;
|
|
68
|
+
buttons[t.id].setAttribute("aria-selected", on ? "true" : "false");
|
|
69
|
+
buttons[t.id].tabIndex = on ? 0 : -1;
|
|
70
|
+
buttons[t.id].classList.toggle("is-on", on);
|
|
71
|
+
panels[t.id].hidden = !on;
|
|
72
|
+
}
|
|
73
|
+
if (focus) buttons[id].focus({ preventScroll: true });
|
|
74
|
+
// Keep the chosen tab in view in the bar, when the bar scrolls sideways (only the bar moves).
|
|
75
|
+
const b = buttons[id];
|
|
76
|
+
if (bar.scrollWidth > bar.clientWidth) bar.scrollLeft = Math.max(0, b.offsetLeft - (bar.clientWidth - b.offsetWidth) / 2);
|
|
77
|
+
const panel = panels[id];
|
|
78
|
+
if (panel.isConnected && panel.getBoundingClientRect().top < bar.getBoundingClientRect().bottom) panel.scrollIntoView?.({ block: "start" });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
select(first);
|
|
82
|
+
return {
|
|
83
|
+
bar,
|
|
84
|
+
panels,
|
|
85
|
+
select,
|
|
86
|
+
get current() {
|
|
87
|
+
return current;
|
|
88
|
+
},
|
|
89
|
+
setCount(id, n) {
|
|
90
|
+
counts[id].hidden = n === null;
|
|
91
|
+
counts[id].textContent = n === null ? "" : n.toLocaleString("en-US");
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
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;
|