@nano133/nanoterra 0.1.0 → 0.2.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 (77) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +189 -2
  3. package/ai/SPEC.md +2 -1
  4. package/dist/admin/adapters/d1.d.ts +27 -0
  5. package/dist/admin/adapters/d1.js +73 -0
  6. package/dist/admin/adapters/firestore.d.ts +40 -0
  7. package/dist/admin/adapters/firestore.js +53 -0
  8. package/dist/admin/amount.d.ts +5 -0
  9. package/dist/admin/amount.js +11 -0
  10. package/dist/admin/conformance.d.ts +132 -0
  11. package/dist/admin/conformance.js +467 -0
  12. package/dist/admin/guard.d.ts +38 -0
  13. package/dist/admin/guard.js +57 -0
  14. package/dist/admin/handler.d.ts +63 -0
  15. package/dist/admin/handler.js +170 -0
  16. package/dist/admin/http.d.ts +17 -0
  17. package/dist/admin/http.js +76 -0
  18. package/dist/admin/index.d.ts +13 -0
  19. package/dist/admin/index.js +14 -0
  20. package/dist/admin/memory.d.ts +32 -0
  21. package/dist/admin/memory.js +40 -0
  22. package/dist/admin/panel/api.d.ts +11 -0
  23. package/dist/admin/panel/api.js +33 -0
  24. package/dist/admin/panel/dom.d.ts +7 -0
  25. package/dist/admin/panel/dom.js +33 -0
  26. package/dist/admin/panel/gameCard.d.ts +4 -0
  27. package/dist/admin/panel/gameCard.js +56 -0
  28. package/dist/admin/panel/index.d.ts +10 -0
  29. package/dist/admin/panel/index.js +13 -0
  30. package/dist/admin/panel/mount.d.ts +30 -0
  31. package/dist/admin/panel/mount.js +147 -0
  32. package/dist/admin/panel/shelf.d.ts +17 -0
  33. package/dist/admin/panel/shelf.js +247 -0
  34. package/dist/admin/panel/shelfData.d.ts +37 -0
  35. package/dist/admin/panel/shelfData.js +68 -0
  36. package/dist/admin/panel/signins.d.ts +6 -0
  37. package/dist/admin/panel/signins.js +69 -0
  38. package/dist/admin/store.d.ts +121 -0
  39. package/dist/admin/store.js +3 -0
  40. package/dist/admin/summary.d.ts +39 -0
  41. package/dist/admin/summary.js +85 -0
  42. package/dist/admin/switches.d.ts +58 -0
  43. package/dist/admin/switches.js +61 -0
  44. package/dist/looks/change.d.ts +8 -4
  45. package/dist/looks/change.js +25 -3
  46. package/dist/looks/index.d.ts +3 -3
  47. package/dist/looks/index.js +2 -2
  48. package/dist/looks/json.d.ts +2 -2
  49. package/dist/looks/json.js +6 -4
  50. package/dist/looks/look.d.ts +29 -3
  51. package/dist/looks/look.js +60 -4
  52. package/package.json +9 -2
  53. package/src/admin/adapters/d1.ts +94 -0
  54. package/src/admin/adapters/firestore.ts +78 -0
  55. package/src/admin/amount.ts +14 -0
  56. package/src/admin/conformance.ts +605 -0
  57. package/src/admin/guard.ts +86 -0
  58. package/src/admin/handler.ts +200 -0
  59. package/src/admin/http.ts +74 -0
  60. package/src/admin/index.ts +21 -0
  61. package/src/admin/memory.ts +59 -0
  62. package/src/admin/panel/api.ts +37 -0
  63. package/src/admin/panel/dom.ts +31 -0
  64. package/src/admin/panel/gameCard.ts +63 -0
  65. package/src/admin/panel/index.ts +18 -0
  66. package/src/admin/panel/mount.ts +227 -0
  67. package/src/admin/panel/shelf.ts +337 -0
  68. package/src/admin/panel/shelfData.ts +87 -0
  69. package/src/admin/panel/signins.ts +68 -0
  70. package/src/admin/panel/style.css +542 -0
  71. package/src/admin/store.ts +98 -0
  72. package/src/admin/summary.ts +103 -0
  73. package/src/admin/switches.ts +74 -0
  74. package/src/looks/change.ts +25 -6
  75. package/src/looks/index.ts +3 -3
  76. package/src/looks/json.ts +6 -4
  77. package/src/looks/look.ts +66 -5
@@ -0,0 +1,69 @@
1
+ // The "Sign-ins" card of /admin: the last admin sign-ins (when, the rough country, a short browser name) and "Sign out everywhere". It shows only what the store gave for a
2
+ // sign-in: the three things of `AdminLogin`. The sign-out is two presses, so that one slip does not end every login.
3
+ import { ApiError } from "./api.js";
4
+ import { h } from "./dom.js";
5
+ /** What the card says about what is kept, unless the game says its own (its retention). */
6
+ export const DEFAULT_LOGINS_NOTE = "For each sign-in only the time, the rough country and a short browser name are kept: no address and no full browser text.";
7
+ /** "United States (US)", or "US" when the name is not known, or "unknown country". */
8
+ function countryText(code) {
9
+ if (!code)
10
+ return "unknown country";
11
+ try {
12
+ const name = new Intl.DisplayNames(["en"], { type: "region" }).of(code);
13
+ return name && name !== code ? `${name} (${code})` : code;
14
+ }
15
+ catch {
16
+ return code;
17
+ }
18
+ }
19
+ /** The card, or null when the store lists no sign-ins and cannot sign out. `done` runs after a sign-out everywhere (the panel loads again and asks for a sign-in). */
20
+ export function signinsCard(s, api, done, note = DEFAULT_LOGINS_NOTE) {
21
+ if (!s.logins && !s.canSignOutEverywhere)
22
+ return null;
23
+ const card = h("section", { class: "admin-card signins", "aria-labelledby": "signins-title" }, h("h2", { id: "signins-title" }, "Sign-ins"));
24
+ if (s.logins) {
25
+ card.append(h("p", { class: "fine" }, "The last sign-ins to this admin, newest first. If one is not yours, press Sign out everywhere."));
26
+ const list = h("ul", { class: "signin-list" });
27
+ for (const l of s.logins)
28
+ list.append(h("li", {}, h("strong", {}, new Date(l.at).toLocaleString()), h("span", {}, ` · ${countryText(l.country)} · ${l.browser}`)));
29
+ if (!s.logins.length)
30
+ list.append(h("li", { class: "fine" }, "No sign-in is listed yet."));
31
+ card.append(list, h("p", { class: "fine" }, note));
32
+ }
33
+ if (s.canSignOutEverywhere) {
34
+ const box = h("div", { class: "signout-box" });
35
+ const status = h("span", { class: "fine", role: "status" });
36
+ const first = h("button", { class: "btn", type: "button" }, "Sign out everywhere");
37
+ const confirm = h("div", { class: "signout-confirm" });
38
+ confirm.hidden = true;
39
+ const yes = h("button", { class: "btn btn-primary", type: "button" }, "Yes, sign out everywhere");
40
+ const cancel = h("button", { class: "btn", type: "button" }, "Cancel");
41
+ confirm.append(h("p", {}, "This ends every admin sign-in, in every browser, this one too. You will sign in again."), yes, " ", cancel);
42
+ first.addEventListener("click", () => {
43
+ first.hidden = true;
44
+ confirm.hidden = false;
45
+ status.textContent = "";
46
+ });
47
+ cancel.addEventListener("click", () => {
48
+ confirm.hidden = true;
49
+ first.hidden = false;
50
+ });
51
+ yes.addEventListener("click", async () => {
52
+ yes.disabled = true;
53
+ cancel.disabled = true;
54
+ status.textContent = "Signing out…";
55
+ try {
56
+ await api.post("/signout-all");
57
+ done();
58
+ }
59
+ catch (e) {
60
+ status.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Nothing was changed.";
61
+ yes.disabled = false;
62
+ cancel.disabled = false;
63
+ }
64
+ });
65
+ box.append(first, confirm, status);
66
+ card.append(box);
67
+ }
68
+ return card;
69
+ }
@@ -0,0 +1,121 @@
1
+ import type { GamePatch } from "./switches.js";
2
+ /** Money as the admin page shows it: a count, the XNO text and the US dollars as text. */
3
+ export type Money = {
4
+ count: number;
5
+ xno: string;
6
+ usd: string;
7
+ };
8
+ export type OrderStatus = "open" | "paid" | "expired" | "paid_late";
9
+ export type AdminOrder = {
10
+ id: string;
11
+ /** The player's Nano address. */
12
+ player: string;
13
+ lookId: string;
14
+ /** What the player reads for the look (the handler fills it from `lookName`). */
15
+ look: string;
16
+ usd: string;
17
+ xno: string;
18
+ status: OrderStatus | string;
19
+ createdAt: number;
20
+ expiresAt: number;
21
+ paidAt: number | null;
22
+ payer: string | null;
23
+ hash: string | null;
24
+ };
25
+ export type AdminPlayer = {
26
+ player: string;
27
+ looks: {
28
+ id: string;
29
+ name: string;
30
+ at: number;
31
+ }[];
32
+ };
33
+ /** The numbers of the orders and the looks that players own. */
34
+ export type StoreSummary = {
35
+ counts: {
36
+ open: number;
37
+ paid: number;
38
+ expired: number;
39
+ paid_late: number;
40
+ };
41
+ totals: {
42
+ paid: Money;
43
+ paidLate: Money;
44
+ };
45
+ /** Newest first, at most 500. `look` is filled by the handler. */
46
+ orders: Omit<AdminOrder, "look">[];
47
+ /** Players and the looks that a paid order gave them. `name` is filled by the handler. */
48
+ players: {
49
+ player: string;
50
+ looks: {
51
+ id: string;
52
+ at: number;
53
+ }[];
54
+ }[];
55
+ /** The price of XNO in US dollars that the game uses, and where it came from. */
56
+ rate: {
57
+ usd: number;
58
+ sources: string[];
59
+ at: number;
60
+ } | null;
61
+ };
62
+ /**
63
+ * One admin sign-in, as the panel lists it: when, the rough country (two letters, or null when the game did not know it) and a short browser name ("Chrome on macOS").
64
+ * Nothing else: no address, no IP, no browser text. The game writes these when it makes an admin login; the package only reads them.
65
+ */
66
+ export type AdminLogin = {
67
+ at: number;
68
+ country: string | null;
69
+ browser: string;
70
+ };
71
+ /** How many sign-ins the panel lists. */
72
+ export declare const ADMIN_LOGINS_SHOWN = 10;
73
+ /**
74
+ * The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the two settings and, when the game turns it on, the deletion of the admin sessions
75
+ * (`signOutEverywhere`). Nothing else.
76
+ * (There is no call that writes a look, a price or an order: the Shelf gives text to copy, and a change of looks is a change of the repository.)
77
+ */
78
+ export interface AdminStore {
79
+ /** The settings that /admin has saved. A value that is not a known word is ignored by the handler, so an adapter returns what it holds. */
80
+ readSettings(): Promise<{
81
+ character?: unknown;
82
+ signin?: unknown;
83
+ }>;
84
+ /** Saves a checked patch: all of it or none of it. */
85
+ saveSettings(patch: GamePatch, now: number): Promise<void>;
86
+ summary(now: number): Promise<StoreSummary>;
87
+ /**
88
+ * Optional. The last sign-ins to the admin, newest first, at most `limit`, with only the three things of `AdminLogin`. With it the summary lists them (the "Sign-ins" card).
89
+ * An adapter that has it must never return more than those three things for a sign-in.
90
+ */
91
+ recentLogins?(limit: number): Promise<AdminLogin[]>;
92
+ /**
93
+ * Optional. Deletes EVERY admin session (the admin login of the owner, in every browser, this one too) and no other session; returns how many it deleted. With it the handler has
94
+ * the route `POST {base}/signout-all` and the panel has "Sign out everywhere". An adapter must delete only sessions of the ADMIN scope.
95
+ */
96
+ signOutEverywhere?(): Promise<number>;
97
+ }
98
+ /** What the game says about itself on the Status card: only the game knows its address, its test mode and what is still off. */
99
+ export type AdminStatus = {
100
+ address: string | null;
101
+ testMode: boolean;
102
+ /** Whether Turnstile (the bot check) is on. */
103
+ turnstile?: boolean;
104
+ /** The names of the Nano nodes that check payments. */
105
+ nodes?: string[];
106
+ /** "owner/repo" on GitHub, for the link to looks.json. */
107
+ repo?: string | null;
108
+ /** Plain sentences about what is still off. */
109
+ warnings: string[];
110
+ };
111
+ /** The whole of GET summary. */
112
+ export type AdminSummary = AdminStatus & Omit<StoreSummary, "orders" | "players"> & {
113
+ orders: AdminOrder[];
114
+ players: AdminPlayer[];
115
+ /** Whether the game gave a payment check (POST check). The panel shows "Check payments now" only then. */
116
+ canCheck: boolean;
117
+ /** The last admin sign-ins (newest first), when the store can list them. */
118
+ logins?: AdminLogin[];
119
+ /** Whether the route "sign out everywhere" exists (the store can do it). */
120
+ canSignOutEverywhere?: boolean;
121
+ };
@@ -0,0 +1,3 @@
1
+ // What a game gives the admin handler: a store (an adapter for its database) and a status. The package has no database of its own.
2
+ /** How many sign-ins the panel lists. */
3
+ export const ADMIN_LOGINS_SHOWN = 10;
@@ -0,0 +1,39 @@
1
+ import type { AdminOrder, Money, StoreSummary } from "./store.js";
2
+ /** An order as an adapter reads it: the amount is the raw amount (digits), the times are ms. */
3
+ export type OrderRow = {
4
+ id: string;
5
+ player: string;
6
+ lookId: string;
7
+ usd: string;
8
+ amountRaw: string;
9
+ status: string;
10
+ createdAt: number;
11
+ expiresAt: number;
12
+ paidAt: number | null;
13
+ payer: string | null;
14
+ hash: string | null;
15
+ };
16
+ export declare const ORDERS_SHOWN = 500;
17
+ /** Money from rows that are all of one status: the count, the XNO text of the raw sum and the dollars (summed in cents, so 0.1 + 0.2 is 0.30). */
18
+ export declare function moneyOf(rows: readonly {
19
+ amountRaw: string;
20
+ usd: string;
21
+ }[]): Money;
22
+ /** Where an order counts: an open order past its expiry counts as expired. */
23
+ export declare const statusOf: (status: string, expiresAt: number, now: number) => string;
24
+ /** The orders as the page lists them: newest first, at most ORDERS_SHOWN, an open one past its expiry read as "expired". */
25
+ export declare function shapeOrders(rows: readonly OrderRow[], now: number): Omit<AdminOrder, "look">[];
26
+ /** The looks that players own, by player (players and their looks in a steady order). */
27
+ export declare function shapePlayers(owned: readonly {
28
+ player: string;
29
+ lookId: string;
30
+ at: number;
31
+ }[]): StoreSummary["players"];
32
+ /** The whole summary from ALL the rows (for a store that reads the rows itself: Firestore, memory). */
33
+ export declare function summarize(rows: readonly OrderRow[], owned: readonly {
34
+ player: string;
35
+ lookId: string;
36
+ at: number;
37
+ }[], rate: StoreSummary["rate"], now: number): StoreSummary;
38
+ /** The `rate` row of the meta table: JSON { usd, sources } kept with the time. Anything that is not that is no rate. */
39
+ export declare function parseRate(value: unknown, at: unknown): StoreSummary["rate"];
@@ -0,0 +1,85 @@
1
+ // The numbers of the summary from the rows of the orders, the same for every adapter: the counts by status, the totals and the shape of an order.
2
+ // An adapter reads its rows; this file turns them into a `StoreSummary`.
3
+ import { isRawText, xnoText } from "./amount.js";
4
+ export const ORDERS_SHOWN = 500;
5
+ /** Cents from a dollar text like "0.25". Anything that is not plain dollars (a sign, an exponent, hex) counts as 0: a bad row must not change a total. */
6
+ const toCents = (usd) => {
7
+ if (typeof usd !== "string" || !/^\d{1,9}(\.\d{1,8})?$/.test(usd))
8
+ return 0;
9
+ return Math.round(Number(usd) * 100);
10
+ };
11
+ /** Money from rows that are all of one status: the count, the XNO text of the raw sum and the dollars (summed in cents, so 0.1 + 0.2 is 0.30). */
12
+ export function moneyOf(rows) {
13
+ let raw = 0n;
14
+ let cents = 0;
15
+ for (const r of rows) {
16
+ if (isRawText(r.amountRaw))
17
+ raw += BigInt(r.amountRaw);
18
+ cents += toCents(r.usd);
19
+ }
20
+ return { count: rows.length, xno: xnoText(raw.toString()), usd: (cents / 100).toFixed(2) };
21
+ }
22
+ /** Where an order counts: an open order past its expiry counts as expired. */
23
+ export const statusOf = (status, expiresAt, now) => (status === "open" && expiresAt < now ? "expired" : status);
24
+ /** The orders as the page lists them: newest first, at most ORDERS_SHOWN, an open one past its expiry read as "expired". */
25
+ export function shapeOrders(rows, now) {
26
+ return [...rows]
27
+ .sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : 1))
28
+ .slice(0, ORDERS_SHOWN)
29
+ .map((o) => ({
30
+ id: o.id,
31
+ player: o.player,
32
+ lookId: o.lookId,
33
+ usd: o.usd,
34
+ xno: isRawText(o.amountRaw) ? xnoText(o.amountRaw) : "0",
35
+ status: statusOf(o.status, o.expiresAt, now),
36
+ createdAt: o.createdAt,
37
+ expiresAt: o.expiresAt,
38
+ paidAt: o.paidAt,
39
+ payer: o.payer,
40
+ hash: o.hash,
41
+ }));
42
+ }
43
+ /** The looks that players own, by player (players and their looks in a steady order). */
44
+ export function shapePlayers(owned) {
45
+ const players = new Map();
46
+ for (const o of [...owned].sort((a, b) => (a.player < b.player ? -1 : a.player > b.player ? 1 : a.at - b.at))) {
47
+ const p = players.get(o.player) ?? { player: o.player, looks: [] };
48
+ p.looks.push({ id: o.lookId, at: o.at });
49
+ players.set(o.player, p);
50
+ }
51
+ return [...players.values()];
52
+ }
53
+ /** The whole summary from ALL the rows (for a store that reads the rows itself: Firestore, memory). */
54
+ export function summarize(rows, owned, rate, now) {
55
+ const counts = { open: 0, paid: 0, expired: 0, paid_late: 0 };
56
+ for (const o of rows) {
57
+ const key = statusOf(o.status, o.expiresAt, now);
58
+ if (Object.hasOwn(counts, key))
59
+ counts[key]++;
60
+ }
61
+ return {
62
+ counts,
63
+ totals: { paid: moneyOf(rows.filter((o) => o.status === "paid")), paidLate: moneyOf(rows.filter((o) => o.status === "paid_late")) },
64
+ orders: shapeOrders(rows, now),
65
+ players: shapePlayers(owned),
66
+ rate,
67
+ };
68
+ }
69
+ /** The `rate` row of the meta table: JSON { usd, sources } kept with the time. Anything that is not that is no rate. */
70
+ export function parseRate(value, at) {
71
+ if (typeof value !== "string" || typeof at !== "number")
72
+ return null;
73
+ try {
74
+ const v = JSON.parse(value);
75
+ if (!v || typeof v !== "object")
76
+ return null;
77
+ const { usd, sources } = v;
78
+ if (typeof usd !== "number" || !Number.isFinite(usd) || !Array.isArray(sources) || !sources.every((s) => typeof s === "string"))
79
+ return null;
80
+ return { usd, sources: sources, at };
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
@@ -0,0 +1,58 @@
1
+ /** Which character the player is: Nanoterra's island character (the one the player has there) or the game's own. */
2
+ export type Character = "nanoterra" | "own";
3
+ /** How the game signs a Nanoterra player in: the code exchange, a signed ticket (a game with no server), or not at all. */
4
+ export type Signin = "exchange" | "ticket" | "none";
5
+ export type GameSettings = {
6
+ character: Character;
7
+ signin: Signin;
8
+ };
9
+ /** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
10
+ export type GamePatch = {
11
+ character?: Character;
12
+ signin?: "exchange" | "none";
13
+ };
14
+ /** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
15
+ export type GameState = GameSettings & {
16
+ fromAdmin: {
17
+ character: boolean;
18
+ signin: boolean;
19
+ };
20
+ };
21
+ export declare const isCharacter: (v: unknown) => v is Character;
22
+ export declare const isSigninChoice: (v: unknown) => v is "exchange" | "none";
23
+ export declare const isSignin: (v: unknown) => v is Signin;
24
+ /** The sign-in switch is on for any way but "none". */
25
+ export declare const signinOn: (s: Signin) => boolean;
26
+ /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
27
+ export declare function parsePatch(body: unknown): {
28
+ ok: true;
29
+ patch: GamePatch;
30
+ } | {
31
+ ok: false;
32
+ error: string;
33
+ };
34
+ /** The settings from what the store kept and the game's defaults. A stored value that is not one of the known words is ignored. */
35
+ export declare function mergeSettings(stored: {
36
+ character?: unknown;
37
+ signin?: unknown;
38
+ }, defaults: GameSettings): GameState;
39
+ /** One switch of the card: its title, and one plain sentence for each state. */
40
+ export type SwitchId = "character" | "signin";
41
+ export declare const SWITCHES: Record<SwitchId, {
42
+ title: string;
43
+ on: string;
44
+ off: string;
45
+ }>;
46
+ /**
47
+ * The character comes through the sign-in: only a player who signs in from Nanoterra brings one. So the character switch is shown with its dependency. With sign-in Off the
48
+ * card says what a player gets: the island's first character, every player, a player who signed in from Nanoterra before included ("Off means Off"). That is the GAME's page
49
+ * to do (the starter's src/client/ui/player.ts draws no Nanoterra character while sign-in is Off); the card only says it.
50
+ */
51
+ export declare const CHARACTER_NEEDS_SIGNIN = "This needs \u201CPlayers can sign in with Nanoterra\u201D to be On: only a player who signs in from Nanoterra brings a character.";
52
+ export declare const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every player plays as the island's first character, a player who signed in from Nanoterra before included.";
53
+ /** What to tell the creator under the sign-in switch about Nanoterra's review. A game cannot see that review: it says so. */
54
+ export declare const REVIEW_NOTE = "Your choice is saved here at once. The sign-in word is part of your listing file, and Nanoterra's review decides what Nanoterra shows for a game that is listed. This page cannot see that review. Check it on Nanoterra's launch page (step 6).";
55
+ export declare const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
56
+ /** The state of a switch from the settings, and the patch that a press sends (the other state). */
57
+ export declare const stateOf: (id: SwitchId, s: GameSettings) => boolean;
58
+ export declare const pressOf: (id: SwitchId, s: GameSettings) => GamePatch;
@@ -0,0 +1,61 @@
1
+ // The two switches of the "Game" card in /admin, and the words that say what each does. The handler checks a press with `parsePatch`; the panel
2
+ // draws the card from `SWITCHES`. A switch changes ONLY these two settings; never a look, a price or an order.
3
+ export const isCharacter = (v) => v === "nanoterra" || v === "own";
4
+ export const isSigninChoice = (v) => v === "exchange" || v === "none";
5
+ export const isSignin = (v) => v === "exchange" || v === "ticket" || v === "none";
6
+ /** The sign-in switch is on for any way but "none". */
7
+ export const signinOn = (s) => s !== "none";
8
+ /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
9
+ export function parsePatch(body) {
10
+ if (!body || typeof body !== "object" || Array.isArray(body))
11
+ return { ok: false, error: 'Send an object with "character" or "signin".' };
12
+ const patch = {};
13
+ for (const [key, value] of Object.entries(body)) {
14
+ if (key === "character" && isCharacter(value))
15
+ patch.character = value;
16
+ else if (key === "signin" && isSigninChoice(value))
17
+ patch.signin = value;
18
+ else if (key === "character")
19
+ return { ok: false, error: '"character" must be "nanoterra" or "own".' };
20
+ else if (key === "signin")
21
+ return { ok: false, error: '"signin" must be "exchange" or "none".' };
22
+ else
23
+ return { ok: false, error: `"${key}" is not a game switch.` };
24
+ }
25
+ if (!Object.keys(patch).length)
26
+ return { ok: false, error: 'Send "character" or "signin".' };
27
+ return { ok: true, patch };
28
+ }
29
+ /** The settings from what the store kept and the game's defaults. A stored value that is not one of the known words is ignored. */
30
+ export function mergeSettings(stored, defaults) {
31
+ return {
32
+ character: isCharacter(stored.character) ? stored.character : defaults.character,
33
+ signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
34
+ fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin) },
35
+ };
36
+ }
37
+ export const SWITCHES = {
38
+ character: {
39
+ title: "Players use their Nanoterra character",
40
+ on: "On: a player who signs in from Nanoterra plays as the character they picked there. Anyone else plays as the island's first character.",
41
+ off: "Off: every player plays as this game's own character.",
42
+ },
43
+ signin: {
44
+ title: "Players can sign in with Nanoterra",
45
+ on: "On: this game's listing file says players can sign in with Nanoterra, and the game accepts that sign-in.",
46
+ off: "Off: the listing file says no sign-in, and the game refuses it: a link made before still signs nobody in.",
47
+ },
48
+ };
49
+ /**
50
+ * The character comes through the sign-in: only a player who signs in from Nanoterra brings one. So the character switch is shown with its dependency. With sign-in Off the
51
+ * card says what a player gets: the island's first character, every player, a player who signed in from Nanoterra before included ("Off means Off"). That is the GAME's page
52
+ * to do (the starter's src/client/ui/player.ts draws no Nanoterra character while sign-in is Off); the card only says it.
53
+ */
54
+ export const CHARACTER_NEEDS_SIGNIN = "This needs “Players can sign in with Nanoterra” to be On: only a player who signs in from Nanoterra brings a character.";
55
+ export const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every player plays as the island's first character, a player who signed in from Nanoterra before included.";
56
+ /** What to tell the creator under the sign-in switch about Nanoterra's review. A game cannot see that review: it says so. */
57
+ export const REVIEW_NOTE = "Your choice is saved here at once. The sign-in word is part of your listing file, and Nanoterra's review decides what Nanoterra shows for a game that is listed. This page cannot see that review. Check it on Nanoterra's launch page (step 6).";
58
+ export const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
59
+ /** The state of a switch from the settings, and the patch that a press sends (the other state). */
60
+ export const stateOf = (id, s) => (id === "character" ? s.character === "nanoterra" : signinOn(s.signin));
61
+ export const pressOf = (id, s) => (id === "character" ? { character: s.character === "nanoterra" ? "own" : "nanoterra" } : { signin: signinOn(s.signin) ? "none" : "exchange" });
@@ -1,14 +1,18 @@
1
- import type { Look, Problem } from "./look.js";
1
+ import { type Look, type Problem, type Slots } from "./look.js";
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
+ export declare function sameSlots(a: Slots | undefined, b: Slots | undefined): boolean;
2
4
  /**
3
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
4
- * list. `owned` is every look id that any player owns. The free look is not owned by an order: it is every player's.
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.
5
9
  */
6
10
  export declare function removalProblems(before: readonly Look[], after: readonly Look[], owned: Iterable<string>): Problem[];
7
- export type LookField = "name" | "priceUsd" | "body" | "hat" | "hatColor";
11
+ export type LookField = "name" | "priceUsd" | "body" | "hat" | "hatColor" | "slots";
8
12
  export type LooksDiff = {
9
13
  added: Look[];
10
14
  removed: Look[];
11
- /** Looks with the same id and a different name, price, colour or hat. */
15
+ /** Looks with the same id and a different name, price, colour, hat or slots. */
12
16
  changed: {
13
17
  id: string;
14
18
  before: Look;
@@ -1,16 +1,36 @@
1
+ import { COLOR_PATTERN, cleanSlots } from "./look.js";
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
+ export function sameSlots(a, b) {
4
+ const x = cleanSlots(a) ?? {};
5
+ const y = cleanSlots(b) ?? {};
6
+ const names = Object.keys(x);
7
+ if (names.length !== Object.keys(y).length)
8
+ return false;
9
+ const norm = (v) => (COLOR_PATTERN.test(v) ? v.toLowerCase() : v);
10
+ return names.every((n) => Object.prototype.hasOwnProperty.call(y, n) && norm(x[n]) === norm(y[n]));
11
+ }
1
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.
2
13
  /**
3
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
4
- * list. `owned` is every look id that any player owns. The free look is not owned by an order: it is every player's.
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.
5
18
  */
6
19
  export function removalProblems(before, after, owned) {
7
20
  const now = new Set(after.map((l) => l.id));
8
21
  const own = new Set(owned);
9
22
  const out = [];
23
+ const next = new Map(after.map((l, i) => [l.id, { look: l, place: i + 1 }]));
10
24
  for (const l of before) {
11
- if (now.has(l.id) || !own.has(l.id))
25
+ if (!own.has(l.id))
12
26
  continue;
13
- 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.` });
27
+ const kept = next.get(l.id);
28
+ if (!kept) {
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
+ }
31
+ else if (!sameSlots(l.slots, kept.look.slots)) {
32
+ 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
+ }
14
34
  }
15
35
  return out;
16
36
  }
@@ -34,6 +54,8 @@ export function diffLooks(before, after) {
34
54
  fields.push("hat");
35
55
  if ((a.change.hatColor ?? "").toLowerCase() !== (b.change.hatColor ?? "").toLowerCase())
36
56
  fields.push("hatColor");
57
+ if (!sameSlots(a.slots, b.slots))
58
+ fields.push("slots");
37
59
  if (fields.length)
38
60
  diff.changed.push({ id: a.id, before: b, after: a, fields });
39
61
  }
@@ -1,7 +1,7 @@
1
- export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, PRICE_MAX_USD, wholeCents, lookProblems, checkLooks, usdText } from "./look.js";
2
- export type { Hat, Look, Problem } from "./look.js";
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";
3
3
  export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
4
4
  export type { Rung } from "./ladder.js";
5
- export { removalProblems, diffLooks } from "./change.js";
5
+ export { removalProblems, diffLooks, sameSlots } from "./change.js";
6
6
  export type { LooksDiff, LookField } from "./change.js";
7
7
  export { looksJson, slugId } from "./json.js";
@@ -1,4 +1,4 @@
1
- export { HATS, ID_PATTERN, COLOR_PATTERN, NAME_MAX, PRICE_MAX_USD, wholeCents, lookProblems, checkLooks, usdText } from "./look.js";
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
2
  export { PRICE_LADDER, rungUsd, rungOf, priceText } from "./ladder.js";
3
- export { removalProblems, diffLooks } from "./change.js";
3
+ export { removalProblems, diffLooks, sameSlots } from "./change.js";
4
4
  export { looksJson, slugId } from "./json.js";
@@ -1,7 +1,7 @@
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, as the game starter's
4
- * file is written, so a change of one look is a change of one line in a diff. 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, 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.
5
5
  */
6
6
  export declare function looksJson(looks: readonly Look[]): string;
7
7
  /** 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,7 +1,7 @@
1
- import { ID_PATTERN } from "./look.js";
1
+ import { cleanSlots, ID_PATTERN } 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, as the game starter's
4
- * file is written, so a change of one look is a change of one line in a diff. 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, 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.
5
5
  */
6
6
  export function looksJson(looks) {
7
7
  const row = (l) => {
@@ -10,7 +10,9 @@ export function looksJson(looks) {
10
10
  change.push(`"hat": ${JSON.stringify(l.change.hat)}`);
11
11
  if (l.change.hatColor !== undefined)
12
12
  change.push(`"hatColor": ${JSON.stringify(l.change.hatColor)}`);
13
- return ` { "id": ${JSON.stringify(l.id)}, "name": ${JSON.stringify(l.name)}, "priceUsd": ${l.priceUsd}, "change": { ${change.join(", ")} } }`;
13
+ const slots = cleanSlots(l.slots);
14
+ 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} }`;
14
16
  };
15
17
  return `[\n${looks.map(row).join(",\n")}\n]\n`;
16
18
  }
@@ -13,24 +13,50 @@ export type Look = {
13
13
  hat?: Hat;
14
14
  hatColor?: string;
15
15
  };
16
+ /**
17
+ * 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
+ * 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
19
+ * without `slots`, or with an empty one, is the same look. Because players own a look by its id, the slots of a look that someone owns must not change.
20
+ */
21
+ slots?: Slots;
16
22
  };
23
+ /** The slots of a look: names and values, words or colours. */
24
+ export type Slots = Record<string, string>;
17
25
  export declare const ID_PATTERN: RegExp;
18
26
  export declare const COLOR_PATTERN: RegExp;
27
+ /** At most this many slots on a look. */
28
+ export declare const SLOTS_MAX = 8;
29
+ /** A slot's name: 1 to 24 lowercase letters, digits or dashes, starting with a letter. */
30
+ export declare const SLOT_NAME_PATTERN: RegExp;
31
+ /** Names that a slot may not have: they mean something to an object (`constructor`, `prototype`). */
32
+ export declare const RESERVED_SLOT_NAMES: readonly string[];
33
+ /** A slot's value that is a word: 1 to 40 lowercase letters, digits or dashes. (The other kind of value is a colour, COLOR_PATTERN.) */
34
+ export declare const SLOT_WORD_PATTERN: RegExp;
19
35
  export declare const NAME_MAX = 40;
20
36
  export declare const PRICE_MAX_USD = 1000;
21
37
  /** One mistake in a list of looks. `message` says it in plain words, with where it is. */
22
38
  export type Problem = {
23
39
  /** The place of the look in the list, from 1, or null for a mistake of the whole list. */
24
40
  look: number | null;
25
- /** The field: "list", "id", "name", "priceUsd", "change.body", "change.hat", "change.hatColor" or "free". */
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.) */
26
42
  field: string;
27
43
  message: string;
28
44
  };
29
- /** Whether a price has no fraction of a cent. `Math.round(x * 100) === x * 100` is wrong for 0.07, 0.29 and 1.1 (floating point). */
45
+ /**
46
+ * Whether a price has no fraction of a cent. `Math.round(x * 100) === x * 100` is wrong for 0.07, 0.29 and 1.1 (floating point), and a tolerance is wrong the other way (0.300000001
47
+ * and 1e-9 would pass): a price is whole cents when it is exactly the number that its own two-decimal text names.
48
+ */
30
49
  export declare function wholeCents(usd: number): boolean;
31
50
  /** Every mistake in a list of looks (looks.json as it was read), in the order of the list. An empty array means the list is good. */
32
51
  export declare function lookProblems(raw: unknown): Problem[];
52
+ /** Whether a slot's value is good: a word or a colour. */
53
+ export declare const slotValueOk: (v: unknown) => v is string;
54
+ /**
55
+ * The slots as a clean copy, or undefined when there are none (an empty `slots` is the same as none). The creator's order of names is kept. A COLOUR is written in lower case
56
+ * ("#ffcf5a"), so the game always reads one text for one colour: a change of only the case of a colour is then no change at all, for the game as for the check.
57
+ */
58
+ export declare function cleanSlots(slots: Slots | undefined): Slots | undefined;
33
59
  /** The looks, or an Error that says the first mistake in plain words. The same words as the starter's `checkLooks`. */
34
- export declare function checkLooks(raw: unknown): Look[];
60
+ export declare function checkLooks(rawInput: unknown): Look[];
35
61
  /** "$0.25" */
36
62
  export declare const usdText: (usd: number) => string;