@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +51 -7
  3. package/ai/SPEC.md +2 -1
  4. package/dist/admin/panel/dom.d.ts +11 -0
  5. package/dist/admin/panel/dom.js +33 -0
  6. package/dist/admin/panel/gameCard.d.ts +5 -2
  7. package/dist/admin/panel/gameCard.js +8 -4
  8. package/dist/admin/panel/icons.d.ts +121 -0
  9. package/dist/admin/panel/icons.js +41 -0
  10. package/dist/admin/panel/index.d.ts +11 -2
  11. package/dist/admin/panel/index.js +6 -1
  12. package/dist/admin/panel/mount.js +101 -67
  13. package/dist/admin/panel/orders.d.ts +23 -0
  14. package/dist/admin/panel/orders.js +130 -0
  15. package/dist/admin/panel/overview.d.ts +22 -0
  16. package/dist/admin/panel/overview.js +75 -0
  17. package/dist/admin/panel/players.d.ts +7 -0
  18. package/dist/admin/panel/players.js +61 -0
  19. package/dist/admin/panel/shelf.d.ts +15 -3
  20. package/dist/admin/panel/shelf.js +385 -84
  21. package/dist/admin/panel/shelfData.js +1 -1
  22. package/dist/admin/panel/tabs.d.ts +23 -0
  23. package/dist/admin/panel/tabs.js +68 -0
  24. package/dist/looks/change.d.ts +5 -5
  25. package/dist/looks/change.js +12 -7
  26. package/dist/looks/index.d.ts +2 -2
  27. package/dist/looks/index.js +1 -1
  28. package/dist/looks/json.d.ts +3 -2
  29. package/dist/looks/json.js +16 -9
  30. package/dist/looks/look.d.ts +26 -6
  31. package/dist/looks/look.js +37 -12
  32. package/package.json +1 -1
  33. package/src/admin/panel/dom.ts +34 -0
  34. package/src/admin/panel/gameCard.ts +8 -4
  35. package/src/admin/panel/icons.ts +46 -0
  36. package/src/admin/panel/index.ts +11 -2
  37. package/src/admin/panel/mount.ts +120 -101
  38. package/src/admin/panel/orders.ts +172 -0
  39. package/src/admin/panel/overview.ts +134 -0
  40. package/src/admin/panel/players.ts +79 -0
  41. package/src/admin/panel/shelf.ts +549 -121
  42. package/src/admin/panel/shelfData.ts +1 -1
  43. package/src/admin/panel/style.css +1775 -270
  44. package/src/admin/panel/tabs.ts +94 -0
  45. package/src/looks/change.ts +12 -9
  46. package/src/looks/index.ts +2 -2
  47. package/src/looks/json.ts +12 -7
  48. package/src/looks/look.ts +47 -9
@@ -57,7 +57,7 @@ export function aiPrompt(text, diff, owned, before) {
57
57
  diff.changed.length ? `Changed: ${names(diff.changed.map((c) => c.after))}.` : "",
58
58
  "",
59
59
  `Looks that players own and that must stay in the file with the same id: ${ownedOk.length ? quote(ownedOk) : "none yet"}${odd ? ` (and ${odd} more with an id that is not a valid look id: do not remove any look)` : ""}.`,
60
- `Rules: keep one free look (priceUsd 0), prices in US dollars with at most 2 decimals, never change or remove the id of a look that someone owns, never change the slots of a look that someone owns (slots are words and #rrggbb colours that the game reads, at most 8 on a look), and a look never changes a score or a rule. (The file had ${before.length} looks before.)`,
60
+ `Rules: keep one free outfit (priceUsd 0, no "wear"), prices in US dollars with at most 2 decimals, never change or remove the id of a look that someone owns, never change the wear slot or the slots of a look that someone owns (a look with "wear" is an item of that slot, such as a shovel, and has no "change"; slots are words and #rrggbb colours that the game reads, at most 8 on a look), and a look never changes a score or a rule. (The file had ${before.length} looks before.)`,
61
61
  "",
62
62
  "looks.json:",
63
63
  "```json",
@@ -0,0 +1,23 @@
1
+ import { type IconName } from "./icons.js";
2
+ export type TabId = "overview" | "cosmetics" | "orders" | "players" | "settings";
3
+ export declare const TABS: readonly {
4
+ id: TabId;
5
+ label: string;
6
+ icon: IconName;
7
+ }[];
8
+ export type TabBar = {
9
+ /** The bar (role tablist). */
10
+ bar: HTMLElement;
11
+ /** The panel of each tab (role tabpanel), empty: the caller fills it. */
12
+ panels: Record<TabId, HTMLElement>;
13
+ /**
14
+ * 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
15
+ * the new panel comes up under the bar (a short tab after a long one must not open at its end).
16
+ */
17
+ select(id: TabId, focus?: boolean): void;
18
+ /** The tab that shows now. */
19
+ readonly current: TabId;
20
+ /** The small number on a tab ("Orders 25"); null takes it away. */
21
+ setCount(id: TabId, n: number | null): void;
22
+ };
23
+ export declare function tabBar(first?: TabId): TabBar;
@@ -0,0 +1,68 @@
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
+ import { h } from "./dom.js";
5
+ import { icon } from "./icons.js";
6
+ export const TABS = [
7
+ { id: "overview", label: "Overview", icon: "overview" },
8
+ { id: "cosmetics", label: "Cosmetics", icon: "shirt" },
9
+ { id: "orders", label: "Orders", icon: "receipt" },
10
+ { id: "players", label: "Players", icon: "players" },
11
+ { id: "settings", label: "Settings", icon: "settings" },
12
+ ];
13
+ export function tabBar(first = "overview") {
14
+ let current = first;
15
+ const bar = h("div", { class: "tabbar", role: "tablist", "aria-label": "Admin sections" });
16
+ const buttons = {};
17
+ const counts = {};
18
+ const panels = {};
19
+ for (const t of TABS) {
20
+ counts[t.id] = h("span", { class: "tab-count" });
21
+ counts[t.id].hidden = true;
22
+ const b = h("button", { class: "tabbar-tab", type: "button", role: "tab", id: `n133-tab-${t.id}`, "aria-controls": `n133-panel-${t.id}` }, icon(t.icon, 18), h("span", { class: "tab-label" }, t.label), counts[t.id]);
23
+ b.addEventListener("click", () => select(t.id));
24
+ b.addEventListener("keydown", (e) => {
25
+ const i = TABS.findIndex((x) => x.id === t.id);
26
+ const next = e.key === "ArrowRight" ? i + 1 : e.key === "ArrowLeft" ? i - 1 : e.key === "Home" ? 0 : e.key === "End" ? TABS.length - 1 : null;
27
+ if (next === null)
28
+ return;
29
+ e.preventDefault();
30
+ select(TABS[(next + TABS.length) % TABS.length].id, true);
31
+ });
32
+ buttons[t.id] = b;
33
+ bar.append(b);
34
+ 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 });
35
+ }
36
+ function select(id, focus = false) {
37
+ current = id;
38
+ for (const t of TABS) {
39
+ const on = t.id === id;
40
+ buttons[t.id].setAttribute("aria-selected", on ? "true" : "false");
41
+ buttons[t.id].tabIndex = on ? 0 : -1;
42
+ buttons[t.id].classList.toggle("is-on", on);
43
+ panels[t.id].hidden = !on;
44
+ }
45
+ if (focus)
46
+ buttons[id].focus({ preventScroll: true });
47
+ // Keep the chosen tab in view in the bar, when the bar scrolls sideways (only the bar moves).
48
+ const b = buttons[id];
49
+ if (bar.scrollWidth > bar.clientWidth)
50
+ bar.scrollLeft = Math.max(0, b.offsetLeft - (bar.clientWidth - b.offsetWidth) / 2);
51
+ const panel = panels[id];
52
+ if (panel.isConnected && panel.getBoundingClientRect().top < bar.getBoundingClientRect().bottom)
53
+ panel.scrollIntoView?.({ block: "start" });
54
+ }
55
+ select(first);
56
+ return {
57
+ bar,
58
+ panels,
59
+ select,
60
+ get current() {
61
+ return current;
62
+ },
63
+ setCount(id, n) {
64
+ counts[id].hidden = n === null;
65
+ counts[id].textContent = n === null ? "" : n.toLocaleString("en-US");
66
+ },
67
+ };
68
+ }
@@ -3,16 +3,16 @@ import { type Look, type Problem, type Slots } from "./look.js";
3
3
  export declare function sameSlots(a: Slots | undefined, b: Slots | undefined): boolean;
4
4
  /**
5
5
  * The mistakes that a new list would make for the players: an id that someone owns (a paid order gave it to them) is gone from the new
6
- * list, or its slots changed (the game reads the slots of the id that a player owns, so a new slot changes what they own: add a new look instead).
7
- * `owned` is every look id that any player owns. The free look is not owned by an order: it is every player's, and its slots may change.
8
- * A change of a price, a name, a body colour or a hat on an owned look is allowed.
6
+ * list, its wear slot changed (a shovel that a player owns must stay a shovel), or its slots changed (the game reads the slots of the id that a player
7
+ * owns, so a new slot changes what they own: add a new look instead). `owned` is every look id that any player owns. The free look is not owned by an
8
+ * order: it is every player's, and its slots may change. A change of a price, a name, a body colour or a hat on an owned look is allowed.
9
9
  */
10
10
  export declare function removalProblems(before: readonly Look[], after: readonly Look[], owned: Iterable<string>): Problem[];
11
- export type LookField = "name" | "priceUsd" | "body" | "hat" | "hatColor" | "slots";
11
+ export type LookField = "name" | "priceUsd" | "wear" | "body" | "hat" | "hatColor" | "slots";
12
12
  export type LooksDiff = {
13
13
  added: Look[];
14
14
  removed: Look[];
15
- /** Looks with the same id and a different name, price, colour, hat or slots. */
15
+ /** Looks with the same id and a different name, price, wear slot, colour, hat or slots. */
16
16
  changed: {
17
17
  id: string;
18
18
  before: Look;
@@ -1,4 +1,4 @@
1
- import { COLOR_PATTERN, cleanSlots } from "./look.js";
1
+ import { COLOR_PATTERN, cleanSlots, wearOf } from "./look.js";
2
2
  /** Whether two looks have the same slots: the same names with the same values, in any order. No slots and empty slots are the same; a colour is the same in upper and lower case. */
3
3
  export function sameSlots(a, b) {
4
4
  const x = cleanSlots(a) ?? {};
@@ -12,9 +12,9 @@ export function sameSlots(a, b) {
12
12
  // What a change of the list does, before it goes live. Owned looks are stored by id: a look that someone owns must stay in the file.
13
13
  /**
14
14
  * The mistakes that a new list would make for the players: an id that someone owns (a paid order gave it to them) is gone from the new
15
- * list, or its slots changed (the game reads the slots of the id that a player owns, so a new slot changes what they own: add a new look instead).
16
- * `owned` is every look id that any player owns. The free look is not owned by an order: it is every player's, and its slots may change.
17
- * A change of a price, a name, a body colour or a hat on an owned look is allowed.
15
+ * list, its wear slot changed (a shovel that a player owns must stay a shovel), or its slots changed (the game reads the slots of the id that a player
16
+ * owns, so a new slot changes what they own: add a new look instead). `owned` is every look id that any player owns. The free look is not owned by an
17
+ * order: it is every player's, and its slots may change. A change of a price, a name, a body colour or a hat on an owned look is allowed.
18
18
  */
19
19
  export function removalProblems(before, after, owned) {
20
20
  const now = new Set(after.map((l) => l.id));
@@ -28,6 +28,9 @@ export function removalProblems(before, after, owned) {
28
28
  if (!kept) {
29
29
  out.push({ look: null, field: "id", message: `The look "${l.id}" (${l.name}) is owned by a player and is not in the new list. Keep it in the file; change its price if you do not want to sell it.` });
30
30
  }
31
+ else if (wearOf(l) !== wearOf(kept.look)) {
32
+ out.push({ look: kept.place, field: "wear", message: `The look "${l.id}" (${l.name}) is worn in the "${wearOf(l)}" slot, and a player owns it. Keep it in that slot, or add a new look for the "${wearOf(kept.look)}" slot.` });
33
+ }
31
34
  else if (!sameSlots(l.slots, kept.look.slots)) {
32
35
  out.push({ look: kept.place, field: "slots", message: `The slots of the look "${l.id}" (${l.name}) changed, and a player owns it. Keep its slots as they were, or add a new look with the new slots.` });
33
36
  }
@@ -48,11 +51,13 @@ export function diffLooks(before, after) {
48
51
  fields.push("name");
49
52
  if (a.priceUsd !== b.priceUsd)
50
53
  fields.push("priceUsd");
51
- if (a.change.body.toLowerCase() !== b.change.body.toLowerCase())
54
+ if (wearOf(a) !== wearOf(b))
55
+ fields.push("wear");
56
+ if ((a.change?.body ?? "").toLowerCase() !== (b.change?.body ?? "").toLowerCase())
52
57
  fields.push("body");
53
- if (a.change.hat !== b.change.hat)
58
+ if (a.change?.hat !== b.change?.hat)
54
59
  fields.push("hat");
55
- if ((a.change.hatColor ?? "").toLowerCase() !== (b.change.hatColor ?? "").toLowerCase())
60
+ if ((a.change?.hatColor ?? "").toLowerCase() !== (b.change?.hatColor ?? "").toLowerCase())
56
61
  fields.push("hatColor");
57
62
  if (!sameSlots(a.slots, b.slots))
58
63
  fields.push("slots");
@@ -1,5 +1,5 @@
1
- export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText } from "./look.js";
2
- export type { Hat, Look, Problem, Slots } from "./look.js";
1
+ export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText, wearOf, isOutfit } from "./look.js";
2
+ export type { Change, Hat, Look, Problem, Slots } from "./look.js";
3
3
  export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
4
4
  export type { Rung } from "./ladder.js";
5
5
  export { removalProblems, diffLooks, sameSlots } from "./change.js";
@@ -1,4 +1,4 @@
1
- export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText } from "./look.js";
1
+ export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, OUTFIT, PRICE_MAX_USD, SLOTS_MAX, SLOT_NAME_PATTERN, SLOT_WORD_PATTERN, RESERVED_SLOT_NAMES, wholeCents, lookProblems, checkLooks, cleanSlots, slotValueOk, usdText, wearOf, isOutfit } from "./look.js";
2
2
  export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
3
3
  export { removalProblems, diffLooks, sameSlots } from "./change.js";
4
4
  export { looksJson, slugId } from "./json.js";
@@ -1,7 +1,8 @@
1
1
  import { type Look } from "./look.js";
2
2
  /**
3
- * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, change (and slots, when the look has any), as the game
4
- * starter's file is written, so a change of one look is a change of one line in a diff. A look with no slots is written exactly as before. It ends with a new line.
3
+ * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, wear (an item of a wear slot other than the outfit's),
4
+ * change (an outfit) and slots (when the look has any), as the game starter's file is written, so a change of one look is a change of one line in a diff. An
5
+ * outfit with no slots is written exactly as before. It ends with a new line.
5
6
  */
6
7
  export declare function looksJson(looks: readonly Look[]): string;
7
8
  /** An id for a name: lowercase letters, digits and dashes, at most 40, and not one of `taken` ("mint-cap", then "mint-cap-2"). */
@@ -1,18 +1,25 @@
1
- import { cleanSlots, ID_PATTERN } from "./look.js";
1
+ import { cleanSlots, ID_PATTERN, isOutfit, OUTFIT, wearOf } from "./look.js";
2
2
  /**
3
- * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, change (and slots, when the look has any), as the game
4
- * starter's file is written, so a change of one look is a change of one line in a diff. A look with no slots is written exactly as before. It ends with a new line.
3
+ * The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, wear (an item of a wear slot other than the outfit's),
4
+ * change (an outfit) and slots (when the look has any), as the game starter's file is written, so a change of one look is a change of one line in a diff. An
5
+ * outfit with no slots is written exactly as before. It ends with a new line.
5
6
  */
6
7
  export function looksJson(looks) {
7
8
  const row = (l) => {
8
- const change = [`"body": ${JSON.stringify(l.change.body)}`];
9
- if (l.change.hat !== undefined)
10
- change.push(`"hat": ${JSON.stringify(l.change.hat)}`);
11
- if (l.change.hatColor !== undefined)
12
- change.push(`"hatColor": ${JSON.stringify(l.change.hatColor)}`);
9
+ let middle;
10
+ if (isOutfit(l)) {
11
+ const change = [`"body": ${JSON.stringify(l.change.body)}`];
12
+ if (l.change.hat !== undefined)
13
+ change.push(`"hat": ${JSON.stringify(l.change.hat)}`);
14
+ if (l.change.hatColor !== undefined)
15
+ change.push(`"hatColor": ${JSON.stringify(l.change.hatColor)}`);
16
+ middle = `, "change": { ${change.join(", ")} }`;
17
+ }
18
+ else
19
+ middle = wearOf(l) === OUTFIT ? "" : `, "wear": ${JSON.stringify(wearOf(l))}`;
13
20
  const slots = cleanSlots(l.slots);
14
21
  const tail = slots ? `, "slots": { ${Object.keys(slots).map((k) => `${JSON.stringify(k)}: ${JSON.stringify(slots[k])}`).join(", ")} }` : "";
15
- return ` { "id": ${JSON.stringify(l.id)}, "name": ${JSON.stringify(l.name)}, "priceUsd": ${l.priceUsd}, "change": { ${change.join(", ")} }${tail} }`;
22
+ return ` { "id": ${JSON.stringify(l.id)}, "name": ${JSON.stringify(l.name)}, "priceUsd": ${l.priceUsd}${middle}${tail} }`;
16
23
  };
17
24
  return `[\n${looks.map(row).join(",\n")}\n]\n`;
18
25
  }
@@ -1,6 +1,12 @@
1
1
  /** The hats that the starter's character can wear. */
2
2
  export declare const HATS: readonly ["cap", "beanie", "crown", "tophat"];
3
3
  export type Hat = (typeof HATS)[number];
4
+ /** What an outfit changes: the body's colour, and a hat in its colour. */
5
+ export type Change = {
6
+ body: string;
7
+ hat?: Hat;
8
+ hatColor?: string;
9
+ };
4
10
  export type Look = {
5
11
  /** 1 to 40 lowercase letters, digits or dashes: "mint-cap". Never change the id of a look that someone owns. */
6
12
  id: string;
@@ -8,11 +14,14 @@ export type Look = {
8
14
  name: string;
9
15
  /** The price in US dollars with at most 2 decimals, for example 0.25. 0 means free for everyone. */
10
16
  priceUsd: number;
11
- change: {
12
- body: string;
13
- hat?: Hat;
14
- hatColor?: string;
15
- };
17
+ /**
18
+ * The wear slot that the look fills: none (or "outfit") for an outfit, or a slot of the game's own ("shovel", "charm"): 1 to 24 lowercase letters, digits
19
+ * or dashes, starting with a letter. A player wears one look in each slot. Never change the slot of a look that someone owns. `checkLooks` leaves it out
20
+ * for an outfit.
21
+ */
22
+ wear?: string;
23
+ /** What an outfit changes (an outfit must have it; an item of another slot must not: `isOutfit`). */
24
+ change?: Change;
16
25
  /**
17
26
  * What a game defines for itself: up to 8 slots, each a name and a value, that the game's own code reads ("wings": "ember", "trail": "#ffcf5a"). A name is 1 to 24
18
27
  * lowercase letters, digits or dashes, starting with a letter; a value is a word (1 to 40 lowercase letters, digits or dashes) or a colour ("#rrggbb"). Optional: a look
@@ -23,6 +32,8 @@ export type Look = {
23
32
  /** The slots of a look: names and values, words or colours. */
24
33
  export type Slots = Record<string, string>;
25
34
  export declare const ID_PATTERN: RegExp;
35
+ /** The wear slot of an outfit (the character's own look). A look with no `wear` is an outfit too. */
36
+ export declare const OUTFIT = "outfit";
26
37
  export declare const COLOR_PATTERN: RegExp;
27
38
  /** At most this many slots on a look. */
28
39
  export declare const SLOTS_MAX = 8;
@@ -38,7 +49,10 @@ export declare const PRICE_MAX_USD = 1000;
38
49
  export type Problem = {
39
50
  /** The place of the look in the list, from 1, or null for a mistake of the whole list. */
40
51
  look: number | null;
41
- /** The field: "list", "id", "name", "priceUsd", "change.body", "change.hat", "change.hatColor", "slots", "slots.<name>" or "free". (The change rules also use "slots" for a slot that changed on a look that players own.) */
52
+ /**
53
+ * The field: "list", "id", "name", "priceUsd", "wear", "change", "change.body", "change.hat", "change.hatColor", "slots", "slots.<name>" or "free". (The change
54
+ * rules also use "slots" and "wear" for a slot or a wear slot that changed on a look that players own.)
55
+ */
42
56
  field: string;
43
57
  message: string;
44
58
  };
@@ -60,3 +74,9 @@ export declare function cleanSlots(slots: Slots | undefined): Slots | undefined;
60
74
  export declare function checkLooks(rawInput: unknown): Look[];
61
75
  /** "$0.25" */
62
76
  export declare const usdText: (usd: number) => string;
77
+ /** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
78
+ export declare const wearOf: (l: Pick<Look, "wear">) => string;
79
+ /** Whether a look is an outfit (the character's own look, with its `change`), not an item of another wear slot. */
80
+ export declare const isOutfit: (l: Look) => l is Look & {
81
+ change: Change;
82
+ };
@@ -1,8 +1,14 @@
1
1
  // The look: one thing a player can buy to change how their character looks. The rules are the game starter's (looks.json): a look only
2
2
  // changes how the character looks, it is never random and it never changes a score, a speed or a rule.
3
+ //
4
+ // A look fills one WEAR SLOT. An outfit (no `wear`, or "outfit") is the character's own look: its body colour and its hat (`change`); a player wears one
5
+ // outfit. Every other wear slot is the game's own (a tool's skin, a charm, a trail: "shovel", "charm"): an item there has no `change` (the game reads what
6
+ // it is from its id or its `slots`), and a player wears one item in each slot, beside the outfit.
3
7
  /** The hats that the starter's character can wear. */
4
8
  export const HATS = ["cap", "beanie", "crown", "tophat"];
5
9
  export const ID_PATTERN = /^[a-z0-9-]{1,40}$/;
10
+ /** The wear slot of an outfit (the character's own look). A look with no `wear` is an outfit too. */
11
+ export const OUTFIT = "outfit";
6
12
  export const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
7
13
  /** At most this many slots on a look. */
8
14
  export const SLOTS_MAX = 8;
@@ -27,7 +33,7 @@ export function lookProblems(raw) {
27
33
  if (!Array.isArray(raw) || raw.length === 0)
28
34
  return [{ look: null, field: "list", message: "looks.json must be a list with at least one look" }];
29
35
  const seen = new Set();
30
- let free = false;
36
+ let freeOutfit = false;
31
37
  raw.forEach((l, i) => {
32
38
  const where = `looks.json, look ${i + 1}`;
33
39
  const v = (l ?? {});
@@ -44,19 +50,32 @@ export function lookProblems(raw) {
44
50
  add("priceUsd", '"priceUsd" must be a number from 0 to 1000');
45
51
  else if (!wholeCents(v.priceUsd))
46
52
  add("priceUsd", '"priceUsd" can have at most 2 decimals (cents)');
47
- else if (v.priceUsd === 0)
48
- free = true;
49
- if (typeof v.change?.body !== "string" || !COLOR_PATTERN.test(v.change.body))
50
- add("change.body", '"change.body" must be a colour like "#4aa8ff"');
51
- if (v.change?.hat !== undefined && !HATS.includes(v.change.hat))
52
- add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
53
- if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor)))
54
- add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
53
+ const wearOk = v.wear === undefined || (typeof v.wear === "string" && SLOT_NAME_PATTERN.test(v.wear) && !RESERVED_SLOT_NAMES.includes(v.wear));
54
+ if (!wearOk)
55
+ add("wear", `"wear" must be "${OUTFIT}" or the name of a wear slot: 1 to 24 lowercase letters, digits or dashes, starting with a letter ("shovel")`);
56
+ const outfit = v.wear === undefined || v.wear === OUTFIT;
57
+ if (outfit) {
58
+ if (typeof v.change?.body !== "string" || !COLOR_PATTERN.test(v.change.body))
59
+ add("change.body", '"change.body" must be a colour like "#4aa8ff"');
60
+ if (v.change?.hat !== undefined && !HATS.includes(v.change.hat))
61
+ add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
62
+ if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor)))
63
+ add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
64
+ if (v.priceUsd === 0)
65
+ freeOutfit = true;
66
+ }
67
+ else if (wearOk && v.change !== undefined)
68
+ add("change", `"change" is only for an outfit: a look worn in the "${v.wear}" slot does not change the body (remove "change")`);
55
69
  if (v.slots !== undefined)
56
70
  slotProblems(v.slots).forEach((s) => add(s.field, s.message));
57
71
  });
58
- if (!out.length && !free)
59
- out.push({ look: null, field: "free", message: "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
72
+ // the starter's words for a list of outfits only; a list with items says that an item does not count
73
+ const items = raw.some((l) => {
74
+ const w = l?.wear;
75
+ return w !== undefined && w !== OUTFIT;
76
+ });
77
+ if (!out.length && !freeOutfit)
78
+ out.push({ look: null, field: "free", message: items ? 'looks.json needs at least one free outfit (priceUsd 0, and no "wear"): every player starts with it' : "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
60
79
  return out;
61
80
  }
62
81
  /** Whether `v` is a plain object (not null, not a list, not a class instance). */
@@ -106,8 +125,14 @@ export function checkLooks(rawInput) {
106
125
  throw new Error(first.message);
107
126
  return raw.map((l) => {
108
127
  const slots = cleanSlots(l.slots);
109
- return { id: l.id, name: l.name, priceUsd: l.priceUsd, change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor }, ...(slots ? { slots } : {}) };
128
+ const wear = wearOf(l);
129
+ const change = isOutfit(l) ? { change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor } } : {};
130
+ return { id: l.id, name: l.name, priceUsd: l.priceUsd, ...(wear === OUTFIT ? {} : { wear }), ...change, ...(slots ? { slots } : {}) };
110
131
  });
111
132
  }
112
133
  /** "$0.25" */
113
134
  export const usdText = (usd) => `$${usd.toFixed(2)}`;
135
+ /** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
136
+ export const wearOf = (l) => l.wear ?? OUTFIT;
137
+ /** Whether a look is an outfit (the character's own look, with its `change`), not an item of another wear slot. */
138
+ export const isOutfit = (l) => wearOf(l) === OUTFIT && l.change !== undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.3.2",
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",
@@ -29,3 +29,37 @@ export async function copy(text: string, button: HTMLButtonElement): Promise<voi
29
29
  }
30
30
  setTimeout(() => (button.textContent = was), 1500);
31
31
  }
32
+
33
+ /**
34
+ * A small copy key: an icon and the word "Copy". A press puts `value` on the clipboard and says "Copied" for a moment; only the word changes, so the icon
35
+ * stays. `label` names what is copied, for a screen reader and the tooltip ("Copy the player's address").
36
+ */
37
+ export function copyKey(value: string, label: string, icon?: Element): HTMLButtonElement {
38
+ const word = h("span", { class: "copy-word" }, "Copy");
39
+ const button = h("button", { class: "btn btn-small btn-quiet copy-key", type: "button", "aria-label": label, title: label }, icon ?? null, word);
40
+ button.addEventListener("click", async () => {
41
+ try {
42
+ await navigator.clipboard.writeText(value);
43
+ word.textContent = "Copied";
44
+ } catch {
45
+ word.textContent = "Copy failed";
46
+ }
47
+ setTimeout(() => (word.textContent = "Copy"), 1500);
48
+ });
49
+ return button;
50
+ }
51
+
52
+ /** "just now", "5 min ago", "3 h ago", "2 days ago". A time in the future reads "just now". */
53
+ export function ago(ms: number, now = Date.now()): string {
54
+ const m = Math.max(0, Math.round((now - ms) / 60_000));
55
+ if (m < 1) return "just now";
56
+ if (m < 60) return `${m} min ago`;
57
+ const hours = Math.round(m / 60);
58
+ return hours < 48 ? `${hours} h ago` : `${Math.round(hours / 24)} days ago`;
59
+ }
60
+
61
+ /** "3 orders", "1 order" */
62
+ export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"}`;
63
+
64
+ /** "$1.25" from cents. */
65
+ export const dollars = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
@@ -6,14 +6,17 @@ import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE,
6
6
  import { ApiError, type Api } from "./api.js";
7
7
  import { h } from "./dom.js";
8
8
 
9
- /** The card for the settings `initial`. It keeps its own state: a press shows "Saving…", then "Saved." or the server's words. `defaultsFile` is where the game's defaults are written ("game.config.json"). */
10
- export function gameCard(api: Api, initial: GameState, defaultsFile = "the game's own settings"): HTMLElement {
9
+ /**
10
+ * The card for the settings `initial`. It keeps its own state: a press shows "Saving…", then "Saved." or the server's words. `defaultsFile` is where the game's defaults are
11
+ * written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the two switches too).
12
+ */
13
+ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game's own settings", onChange?: (state: GameState) => void): HTMLElement {
11
14
  let state = initial;
12
15
  const rows = new Map<SwitchId, { button: HTMLButtonElement; sentence: HTMLElement; status: HTMLElement }>();
13
16
  /** Under the character switch: it works only through sign-in, so it says so, and says what a player gets while sign-in is Off. */
14
17
  const needs = h("p", { class: "fine switch-needs" });
15
- const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("h2", { id: "game-title" }, "Game"));
16
- card.append(h("p", { class: "fine" }, `Two choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
18
+ const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("div", { class: "card-head" }, h("h2", { id: "game-title" }, "Game")));
19
+ card.append(h("p", { class: "fine card-intro" }, `Two choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
17
20
 
18
21
  const draw = () => {
19
22
  for (const [id, row] of rows) {
@@ -35,6 +38,7 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
35
38
  state = await api.post<GameState>("/game", pressOf(id, state));
36
39
  row.status.textContent = "Saved.";
37
40
  draw();
41
+ onChange?.(state);
38
42
  } catch (e) {
39
43
  row.status.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Nothing was changed.";
40
44
  } finally {
@@ -0,0 +1,46 @@
1
+ // The panel's small line icons, drawn as inline SVG with createElementNS (no icon font, no innerHTML: fine under a strict CSP). Each is 24 units square, drawn
2
+ // with the text colour (currentColor), and hidden from screen readers: the word next to it says what it is.
3
+
4
+ const NS = "http://www.w3.org/2000/svg";
5
+
6
+ type Shape = [tag: string, attrs: Record<string, string | number>];
7
+
8
+ const SHAPES = {
9
+ overview: [["rect", { x: 3, y: 3, width: 7.5, height: 7.5, rx: 2 }], ["rect", { x: 13.5, y: 3, width: 7.5, height: 7.5, rx: 2 }], ["rect", { x: 3, y: 13.5, width: 7.5, height: 7.5, rx: 2 }], ["rect", { x: 13.5, y: 13.5, width: 7.5, height: 7.5, rx: 2 }]],
10
+ shirt: [["path", { d: "M8.5 3.5 4 6 2.5 10.5l3.5 1.5V21h12v-9l3.5-1.5L20 6l-4.5-2.5a3.5 3.5 0 0 1-7 0Z" }]],
11
+ receipt: [["path", { d: "M5 3h14v18l-2.5-1.5L14 21l-2-1.5L10 21l-2.5-1.5L5 21Z" }], ["path", { d: "M9 8h6M9 12h6M9 16h3" }]],
12
+ players: [["circle", { cx: 9, cy: 8, r: 3.5 }], ["path", { d: "M2.5 20a6.5 6.5 0 0 1 13 0" }], ["path", { d: "M16 4.8a3.5 3.5 0 0 1 0 6.4M18 14.2a6.5 6.5 0 0 1 3.5 5.8" }]],
13
+ settings: [["circle", { cx: 12, cy: 12, r: 3 }], ["path", { d: "M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1" }]],
14
+ coin: [["circle", { cx: 12, cy: 12, r: 9 }], ["path", { d: "M12 6.5v11M14.8 9.2a2.6 2.6 0 0 0-2.6-1.7h-.6a2.2 2.2 0 0 0 0 4.4h.8a2.2 2.2 0 0 1 0 4.4h-.7a2.6 2.6 0 0 1-2.6-1.7" }]],
15
+ clock: [["circle", { cx: 12, cy: 12, r: 9 }], ["path", { d: "M12 7v5l3.5 2" }]],
16
+ back: [["path", { d: "M9 6 4 11l5 5" }], ["path", { d: "M4 11h10.5a5.5 5.5 0 0 1 0 11H11" }]],
17
+ tag: [["path", { d: "M3 12.2V4a1 1 0 0 1 1-1h8.2l8.6 8.6a1.5 1.5 0 0 1 0 2.1l-6.9 6.9a1.5 1.5 0 0 1-2.1 0Z" }], ["circle", { cx: 7.5, cy: 7.5, r: 1.5 }]],
18
+ copy: [["rect", { x: 8, y: 8, width: 13, height: 13, rx: 2.5 }], ["path", { d: "M16 8V5.5A2.5 2.5 0 0 0 13.5 3h-8A2.5 2.5 0 0 0 3 5.5v8A2.5 2.5 0 0 0 5.5 16H8" }]],
19
+ lock: [["rect", { x: 4.5, y: 10.5, width: 15, height: 10.5, rx: 2.5 }], ["path", { d: "M8 10.5V7.5a4 4 0 0 1 8 0v3" }]],
20
+ plus: [["path", { d: "M12 5v14M5 12h14" }]],
21
+ search: [["circle", { cx: 10.5, cy: 10.5, r: 6.5 }], ["path", { d: "m15.5 15.5 5 5" }]],
22
+ alert: [["path", { d: "M10.3 3.9 2.4 17.6A2 2 0 0 0 4.1 20.6h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" }], ["path", { d: "M12 9v4.5M12 17h.01" }]],
23
+ check: [["path", { d: "m4.5 12.5 5 5 10-11" }]],
24
+ external: [["path", { d: "M14 4h6v6M20 4l-9 9" }], ["path", { d: "M18 14v4.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 18.5v-11A1.5 1.5 0 0 1 5.5 6H10" }]],
25
+ refresh: [["path", { d: "M20 11a8 8 0 0 0-14.3-4.9L4 8M4 4v4h4" }], ["path", { d: "M4 13a8 8 0 0 0 14.3 4.9L20 16M20 20v-4h-4" }]],
26
+ out: [["path", { d: "M15 4h3.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H15" }], ["path", { d: "M10 8l-4 4 4 4M6 12h10" }]],
27
+ pencil: [["path", { d: "M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16Z" }], ["path", { d: "m13.5 6.5 4 4" }]],
28
+ close: [["path", { d: "M6 6l12 12M18 6 6 18" }]],
29
+ dup: [["rect", { x: 8, y: 8, width: 13, height: 13, rx: 2.5 }], ["path", { d: "M14.5 11.5v6M11.5 14.5h6M16 8V5.5A2.5 2.5 0 0 0 13.5 3h-8A2.5 2.5 0 0 0 3 5.5v8A2.5 2.5 0 0 0 5.5 16H8" }]],
30
+ trash: [["path", { d: "M4 7h16M9.5 7V4.5h5V7M6.5 7l1 13h9l1-13" }]],
31
+ sparkle: [["path", { d: "M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6" }]],
32
+ } satisfies Record<string, Shape[]>;
33
+
34
+ export type IconName = keyof typeof SHAPES;
35
+
36
+ /** An icon of `size` CSS pixels (default 20). */
37
+ export function icon(name: IconName, size = 20): SVGSVGElement {
38
+ const svg = document.createElementNS(NS, "svg");
39
+ for (const [k, v] of Object.entries({ viewBox: "0 0 24 24", width: size, height: size, fill: "none", stroke: "currentColor", "stroke-width": 1.8, "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", focusable: "false", class: `icon icon-${name}` })) svg.setAttribute(k, String(v));
40
+ for (const [tag, attrs] of SHAPES[name] as Shape[]) {
41
+ const el = document.createElementNS(NS, tag);
42
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
43
+ svg.append(el);
44
+ }
45
+ return svg;
46
+ }
@@ -11,8 +11,17 @@ export type { MountedAdmin, MountOptions } from "./mount.js";
11
11
  export { ApiError, createApi } from "./api.js";
12
12
  export type { Api } from "./api.js";
13
13
  export { gameCard } from "./gameCard.js";
14
- export { lookPicture, previewPicture, shelfView } from "./shelf.js";
14
+ export { BODY_COLOURS, lookPicture, previewPicture, shelfFilter, shelfSort, shelfView } from "./shelf.js";
15
15
  export { DEFAULT_LOGINS_NOTE, signinsCard } from "./signins.js";
16
- export type { ShelfData } from "./shelf.js";
16
+ export type { ShelfData, ShelfFilter, ShelfSort } from "./shelf.js";
17
+ export { orderMatches, ordersView } from "./orders.js";
18
+ export type { OrderFilter, OrdersView } from "./orders.js";
19
+ export { bestSellers, overviewView } from "./overview.js";
20
+ export type { BestSeller, OverviewView } from "./overview.js";
21
+ export { playerMatches, playersView } from "./players.js";
22
+ export { TABS, tabBar } from "./tabs.js";
23
+ export type { TabBar, TabId } from "./tabs.js";
24
+ export { icon } from "./icons.js";
25
+ export type { IconName } from "./icons.js";
17
26
  export { aiPrompt, centsOf, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
18
27
  export type { LookStats, ShelfOrder, ShelfPlayer } from "./shelfData.js";