@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.
- package/CHANGELOG.md +64 -0
- package/README.md +189 -2
- package/ai/SPEC.md +2 -1
- package/dist/admin/adapters/d1.d.ts +27 -0
- package/dist/admin/adapters/d1.js +73 -0
- package/dist/admin/adapters/firestore.d.ts +40 -0
- package/dist/admin/adapters/firestore.js +53 -0
- package/dist/admin/amount.d.ts +5 -0
- package/dist/admin/amount.js +11 -0
- package/dist/admin/conformance.d.ts +132 -0
- package/dist/admin/conformance.js +467 -0
- package/dist/admin/guard.d.ts +38 -0
- package/dist/admin/guard.js +57 -0
- package/dist/admin/handler.d.ts +63 -0
- package/dist/admin/handler.js +170 -0
- package/dist/admin/http.d.ts +17 -0
- package/dist/admin/http.js +76 -0
- package/dist/admin/index.d.ts +13 -0
- package/dist/admin/index.js +14 -0
- package/dist/admin/memory.d.ts +32 -0
- package/dist/admin/memory.js +40 -0
- package/dist/admin/panel/api.d.ts +11 -0
- package/dist/admin/panel/api.js +33 -0
- package/dist/admin/panel/dom.d.ts +7 -0
- package/dist/admin/panel/dom.js +33 -0
- package/dist/admin/panel/gameCard.d.ts +4 -0
- package/dist/admin/panel/gameCard.js +56 -0
- package/dist/admin/panel/index.d.ts +10 -0
- package/dist/admin/panel/index.js +13 -0
- package/dist/admin/panel/mount.d.ts +30 -0
- package/dist/admin/panel/mount.js +147 -0
- package/dist/admin/panel/shelf.d.ts +17 -0
- package/dist/admin/panel/shelf.js +247 -0
- package/dist/admin/panel/shelfData.d.ts +37 -0
- package/dist/admin/panel/shelfData.js +68 -0
- package/dist/admin/panel/signins.d.ts +6 -0
- package/dist/admin/panel/signins.js +69 -0
- package/dist/admin/store.d.ts +121 -0
- package/dist/admin/store.js +3 -0
- package/dist/admin/summary.d.ts +39 -0
- package/dist/admin/summary.js +85 -0
- package/dist/admin/switches.d.ts +58 -0
- package/dist/admin/switches.js +61 -0
- package/dist/looks/change.d.ts +8 -4
- package/dist/looks/change.js +25 -3
- package/dist/looks/index.d.ts +3 -3
- package/dist/looks/index.js +2 -2
- package/dist/looks/json.d.ts +2 -2
- package/dist/looks/json.js +6 -4
- package/dist/looks/look.d.ts +29 -3
- package/dist/looks/look.js +60 -4
- package/package.json +9 -2
- package/src/admin/adapters/d1.ts +94 -0
- package/src/admin/adapters/firestore.ts +78 -0
- package/src/admin/amount.ts +14 -0
- package/src/admin/conformance.ts +605 -0
- package/src/admin/guard.ts +86 -0
- package/src/admin/handler.ts +200 -0
- package/src/admin/http.ts +74 -0
- package/src/admin/index.ts +21 -0
- package/src/admin/memory.ts +59 -0
- package/src/admin/panel/api.ts +37 -0
- package/src/admin/panel/dom.ts +31 -0
- package/src/admin/panel/gameCard.ts +63 -0
- package/src/admin/panel/index.ts +18 -0
- package/src/admin/panel/mount.ts +227 -0
- package/src/admin/panel/shelf.ts +337 -0
- package/src/admin/panel/shelfData.ts +87 -0
- package/src/admin/panel/signins.ts +68 -0
- package/src/admin/panel/style.css +542 -0
- package/src/admin/store.ts +98 -0
- package/src/admin/summary.ts +103 -0
- package/src/admin/switches.ts +74 -0
- package/src/looks/change.ts +25 -6
- package/src/looks/index.ts +3 -3
- package/src/looks/json.ts +6 -4
- package/src/looks/look.ts +66 -5
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
|
|
4
|
+
import { isRawText, xnoText } from "./amount.js";
|
|
5
|
+
import type { AdminOrder, Money, StoreSummary } from "./store.js";
|
|
6
|
+
|
|
7
|
+
/** An order as an adapter reads it: the amount is the raw amount (digits), the times are ms. */
|
|
8
|
+
export type OrderRow = {
|
|
9
|
+
id: string;
|
|
10
|
+
player: string;
|
|
11
|
+
lookId: string;
|
|
12
|
+
usd: string;
|
|
13
|
+
amountRaw: string;
|
|
14
|
+
status: string;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
paidAt: number | null;
|
|
18
|
+
payer: string | null;
|
|
19
|
+
hash: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const ORDERS_SHOWN = 500;
|
|
23
|
+
|
|
24
|
+
/** 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. */
|
|
25
|
+
const toCents = (usd: string): number => {
|
|
26
|
+
if (typeof usd !== "string" || !/^\d{1,9}(\.\d{1,8})?$/.test(usd)) return 0;
|
|
27
|
+
return Math.round(Number(usd) * 100);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** 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). */
|
|
31
|
+
export function moneyOf(rows: readonly { amountRaw: string; usd: string }[]): Money {
|
|
32
|
+
let raw = 0n;
|
|
33
|
+
let cents = 0;
|
|
34
|
+
for (const r of rows) {
|
|
35
|
+
if (isRawText(r.amountRaw)) raw += BigInt(r.amountRaw);
|
|
36
|
+
cents += toCents(r.usd);
|
|
37
|
+
}
|
|
38
|
+
return { count: rows.length, xno: xnoText(raw.toString()), usd: (cents / 100).toFixed(2) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where an order counts: an open order past its expiry counts as expired. */
|
|
42
|
+
export const statusOf = (status: string, expiresAt: number, now: number): string => (status === "open" && expiresAt < now ? "expired" : status);
|
|
43
|
+
|
|
44
|
+
/** The orders as the page lists them: newest first, at most ORDERS_SHOWN, an open one past its expiry read as "expired". */
|
|
45
|
+
export function shapeOrders(rows: readonly OrderRow[], now: number): Omit<AdminOrder, "look">[] {
|
|
46
|
+
return [...rows]
|
|
47
|
+
.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : 1))
|
|
48
|
+
.slice(0, ORDERS_SHOWN)
|
|
49
|
+
.map((o) => ({
|
|
50
|
+
id: o.id,
|
|
51
|
+
player: o.player,
|
|
52
|
+
lookId: o.lookId,
|
|
53
|
+
usd: o.usd,
|
|
54
|
+
xno: isRawText(o.amountRaw) ? xnoText(o.amountRaw) : "0",
|
|
55
|
+
status: statusOf(o.status, o.expiresAt, now),
|
|
56
|
+
createdAt: o.createdAt,
|
|
57
|
+
expiresAt: o.expiresAt,
|
|
58
|
+
paidAt: o.paidAt,
|
|
59
|
+
payer: o.payer,
|
|
60
|
+
hash: o.hash,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The looks that players own, by player (players and their looks in a steady order). */
|
|
65
|
+
export function shapePlayers(owned: readonly { player: string; lookId: string; at: number }[]): StoreSummary["players"] {
|
|
66
|
+
const players = new Map<string, { player: string; looks: { id: string; at: number }[] }>();
|
|
67
|
+
for (const o of [...owned].sort((a, b) => (a.player < b.player ? -1 : a.player > b.player ? 1 : a.at - b.at))) {
|
|
68
|
+
const p = players.get(o.player) ?? { player: o.player, looks: [] };
|
|
69
|
+
p.looks.push({ id: o.lookId, at: o.at });
|
|
70
|
+
players.set(o.player, p);
|
|
71
|
+
}
|
|
72
|
+
return [...players.values()];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The whole summary from ALL the rows (for a store that reads the rows itself: Firestore, memory). */
|
|
76
|
+
export function summarize(rows: readonly OrderRow[], owned: readonly { player: string; lookId: string; at: number }[], rate: StoreSummary["rate"], now: number): StoreSummary {
|
|
77
|
+
const counts = { open: 0, paid: 0, expired: 0, paid_late: 0 };
|
|
78
|
+
for (const o of rows) {
|
|
79
|
+
const key = statusOf(o.status, o.expiresAt, now);
|
|
80
|
+
if (Object.hasOwn(counts, key)) counts[key as keyof typeof counts]++;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
counts,
|
|
84
|
+
totals: { paid: moneyOf(rows.filter((o) => o.status === "paid")), paidLate: moneyOf(rows.filter((o) => o.status === "paid_late")) },
|
|
85
|
+
orders: shapeOrders(rows, now),
|
|
86
|
+
players: shapePlayers(owned),
|
|
87
|
+
rate,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The `rate` row of the meta table: JSON { usd, sources } kept with the time. Anything that is not that is no rate. */
|
|
92
|
+
export function parseRate(value: unknown, at: unknown): StoreSummary["rate"] {
|
|
93
|
+
if (typeof value !== "string" || typeof at !== "number") return null;
|
|
94
|
+
try {
|
|
95
|
+
const v: unknown = JSON.parse(value);
|
|
96
|
+
if (!v || typeof v !== "object") return null;
|
|
97
|
+
const { usd, sources } = v as { usd?: unknown; sources?: unknown };
|
|
98
|
+
if (typeof usd !== "number" || !Number.isFinite(usd) || !Array.isArray(sources) || !sources.every((s) => typeof s === "string")) return null;
|
|
99
|
+
return { usd, sources: sources as string[], at };
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
|
|
4
|
+
/** Which character the player is: Nanoterra's island character (the one the player has there) or the game's own. */
|
|
5
|
+
export type Character = "nanoterra" | "own";
|
|
6
|
+
/** How the game signs a Nanoterra player in: the code exchange, a signed ticket (a game with no server), or not at all. */
|
|
7
|
+
export type Signin = "exchange" | "ticket" | "none";
|
|
8
|
+
export type GameSettings = { character: Character; signin: Signin };
|
|
9
|
+
/** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
|
|
10
|
+
export type GamePatch = { character?: Character; signin?: "exchange" | "none" };
|
|
11
|
+
/** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
|
|
12
|
+
export type GameState = GameSettings & { fromAdmin: { character: boolean; signin: boolean } };
|
|
13
|
+
|
|
14
|
+
export const isCharacter = (v: unknown): v is Character => v === "nanoterra" || v === "own";
|
|
15
|
+
export const isSigninChoice = (v: unknown): v is "exchange" | "none" => v === "exchange" || v === "none";
|
|
16
|
+
export const isSignin = (v: unknown): v is Signin => v === "exchange" || v === "ticket" || v === "none";
|
|
17
|
+
/** The sign-in switch is on for any way but "none". */
|
|
18
|
+
export const signinOn = (s: Signin): boolean => s !== "none";
|
|
19
|
+
|
|
20
|
+
/** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
|
|
21
|
+
export function parsePatch(body: unknown): { ok: true; patch: GamePatch } | { ok: false; error: string } {
|
|
22
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: 'Send an object with "character" or "signin".' };
|
|
23
|
+
const patch: GamePatch = {};
|
|
24
|
+
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
|
|
25
|
+
if (key === "character" && isCharacter(value)) patch.character = value;
|
|
26
|
+
else if (key === "signin" && isSigninChoice(value)) patch.signin = value;
|
|
27
|
+
else if (key === "character") return { ok: false, error: '"character" must be "nanoterra" or "own".' };
|
|
28
|
+
else if (key === "signin") return { ok: false, error: '"signin" must be "exchange" or "none".' };
|
|
29
|
+
else return { ok: false, error: `"${key}" is not a game switch.` };
|
|
30
|
+
}
|
|
31
|
+
if (!Object.keys(patch).length) return { ok: false, error: 'Send "character" or "signin".' };
|
|
32
|
+
return { ok: true, patch };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 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. */
|
|
36
|
+
export function mergeSettings(stored: { character?: unknown; signin?: unknown }, defaults: GameSettings): GameState {
|
|
37
|
+
return {
|
|
38
|
+
character: isCharacter(stored.character) ? stored.character : defaults.character,
|
|
39
|
+
signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
|
|
40
|
+
fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin) },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One switch of the card: its title, and one plain sentence for each state. */
|
|
45
|
+
export type SwitchId = "character" | "signin";
|
|
46
|
+
export const SWITCHES: Record<SwitchId, { title: string; on: string; off: string }> = {
|
|
47
|
+
character: {
|
|
48
|
+
title: "Players use their Nanoterra character",
|
|
49
|
+
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.",
|
|
50
|
+
off: "Off: every player plays as this game's own character.",
|
|
51
|
+
},
|
|
52
|
+
signin: {
|
|
53
|
+
title: "Players can sign in with Nanoterra",
|
|
54
|
+
on: "On: this game's listing file says players can sign in with Nanoterra, and the game accepts that sign-in.",
|
|
55
|
+
off: "Off: the listing file says no sign-in, and the game refuses it: a link made before still signs nobody in.",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 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
|
|
61
|
+
* 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
|
|
62
|
+
* to do (the starter's src/client/ui/player.ts draws no Nanoterra character while sign-in is Off); the card only says it.
|
|
63
|
+
*/
|
|
64
|
+
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.";
|
|
65
|
+
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.";
|
|
66
|
+
|
|
67
|
+
/** What to tell the creator under the sign-in switch about Nanoterra's review. A game cannot see that review: it says so. */
|
|
68
|
+
export const REVIEW_NOTE =
|
|
69
|
+
"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).";
|
|
70
|
+
export const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
|
|
71
|
+
|
|
72
|
+
/** The state of a switch from the settings, and the patch that a press sends (the other state). */
|
|
73
|
+
export const stateOf = (id: SwitchId, s: GameSettings): boolean => (id === "character" ? s.character === "nanoterra" : signinOn(s.signin));
|
|
74
|
+
export const pressOf = (id: SwitchId, s: GameSettings): GamePatch => (id === "character" ? { character: s.character === "nanoterra" ? "own" : "nanoterra" } : { signin: signinOn(s.signin) ? "none" : "exchange" });
|
package/src/looks/change.ts
CHANGED
|
@@ -1,28 +1,46 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { COLOR_PATTERN, cleanSlots, type Look, type Problem, type Slots } from "./look.js";
|
|
2
|
+
|
|
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
|
+
export function sameSlots(a: Slots | undefined, b: Slots | undefined): boolean {
|
|
5
|
+
const x = cleanSlots(a) ?? {};
|
|
6
|
+
const y = cleanSlots(b) ?? {};
|
|
7
|
+
const names = Object.keys(x);
|
|
8
|
+
if (names.length !== Object.keys(y).length) return false;
|
|
9
|
+
const norm = (v: string) => (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
|
+
}
|
|
2
12
|
|
|
3
13
|
// 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.
|
|
4
14
|
|
|
5
15
|
/**
|
|
6
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
|
|
7
|
-
* list
|
|
17
|
+
* list, or its slots changed (the game reads the slots of the id that a player owns, so a new slot changes what they own: add a new look instead).
|
|
18
|
+
* `owned` is every look id that any player owns. The free look is not owned by an order: it is every player's, and its slots may change.
|
|
19
|
+
* A change of a price, a name, a body colour or a hat on an owned look is allowed.
|
|
8
20
|
*/
|
|
9
21
|
export function removalProblems(before: readonly Look[], after: readonly Look[], owned: Iterable<string>): Problem[] {
|
|
10
22
|
const now = new Set(after.map((l) => l.id));
|
|
11
23
|
const own = new Set(owned);
|
|
12
24
|
const out: Problem[] = [];
|
|
25
|
+
const next = new Map(after.map((l, i) => [l.id, { look: l, place: i + 1 }]));
|
|
13
26
|
for (const l of before) {
|
|
14
|
-
if (
|
|
15
|
-
|
|
27
|
+
if (!own.has(l.id)) continue;
|
|
28
|
+
const kept = next.get(l.id);
|
|
29
|
+
if (!kept) {
|
|
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 (!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
|
+
}
|
|
16
34
|
}
|
|
17
35
|
return out;
|
|
18
36
|
}
|
|
19
37
|
|
|
20
|
-
export type LookField = "name" | "priceUsd" | "body" | "hat" | "hatColor";
|
|
38
|
+
export type LookField = "name" | "priceUsd" | "body" | "hat" | "hatColor" | "slots";
|
|
21
39
|
|
|
22
40
|
export type LooksDiff = {
|
|
23
41
|
added: Look[];
|
|
24
42
|
removed: Look[];
|
|
25
|
-
/** Looks with the same id and a different name, price, colour or
|
|
43
|
+
/** Looks with the same id and a different name, price, colour, hat or slots. */
|
|
26
44
|
changed: { id: string; before: Look; after: Look; fields: LookField[] }[];
|
|
27
45
|
};
|
|
28
46
|
|
|
@@ -40,6 +58,7 @@ export function diffLooks(before: readonly Look[], after: readonly Look[]): Look
|
|
|
40
58
|
if (a.change.body.toLowerCase() !== b.change.body.toLowerCase()) fields.push("body");
|
|
41
59
|
if (a.change.hat !== b.change.hat) fields.push("hat");
|
|
42
60
|
if ((a.change.hatColor ?? "").toLowerCase() !== (b.change.hatColor ?? "").toLowerCase()) fields.push("hatColor");
|
|
61
|
+
if (!sameSlots(a.slots, b.slots)) fields.push("slots");
|
|
43
62
|
if (fields.length) diff.changed.push({ id: a.id, before: b, after: a, fields });
|
|
44
63
|
}
|
|
45
64
|
return diff;
|
package/src/looks/index.ts
CHANGED
|
@@ -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";
|
package/src/looks/json.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { ID_PATTERN, type Look } from "./look.js";
|
|
1
|
+
import { cleanSlots, ID_PATTERN, type Look } from "./look.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, change, as the game
|
|
5
|
-
* file is written, so a change of one look is a change of one line in a diff. It ends with a new line.
|
|
4
|
+
* The text of looks.json for a list of looks: one look on each line, in the order id, name, priceUsd, change (and slots, when the look has any), as the game
|
|
5
|
+
* starter's file is written, so a change of one look is a change of one line in a diff. A look with no slots is written exactly as before. It ends with a new line.
|
|
6
6
|
*/
|
|
7
7
|
export function looksJson(looks: readonly Look[]): string {
|
|
8
8
|
const row = (l: Look) => {
|
|
9
9
|
const change = [`"body": ${JSON.stringify(l.change.body)}`];
|
|
10
10
|
if (l.change.hat !== undefined) change.push(`"hat": ${JSON.stringify(l.change.hat)}`);
|
|
11
11
|
if (l.change.hatColor !== undefined) change.push(`"hatColor": ${JSON.stringify(l.change.hatColor)}`);
|
|
12
|
-
|
|
12
|
+
const slots = cleanSlots(l.slots);
|
|
13
|
+
const tail = slots ? `, "slots": { ${Object.keys(slots).map((k) => `${JSON.stringify(k)}: ${JSON.stringify(slots[k])}`).join(", ")} }` : "";
|
|
14
|
+
return ` { "id": ${JSON.stringify(l.id)}, "name": ${JSON.stringify(l.name)}, "priceUsd": ${l.priceUsd}, "change": { ${change.join(", ")} }${tail} }`;
|
|
13
15
|
};
|
|
14
16
|
return `[\n${looks.map(row).join(",\n")}\n]\n`;
|
|
15
17
|
}
|
package/src/looks/look.ts
CHANGED
|
@@ -13,10 +13,27 @@ export type Look = {
|
|
|
13
13
|
/** The price in US dollars with at most 2 decimals, for example 0.25. 0 means free for everyone. */
|
|
14
14
|
priceUsd: number;
|
|
15
15
|
change: { body: string; hat?: Hat; hatColor?: string };
|
|
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
|
};
|
|
17
23
|
|
|
24
|
+
/** The slots of a look: names and values, words or colours. */
|
|
25
|
+
export type Slots = Record<string, string>;
|
|
26
|
+
|
|
18
27
|
export const ID_PATTERN = /^[a-z0-9-]{1,40}$/;
|
|
19
28
|
export const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
29
|
+
/** At most this many slots on a look. */
|
|
30
|
+
export const SLOTS_MAX = 8;
|
|
31
|
+
/** A slot's name: 1 to 24 lowercase letters, digits or dashes, starting with a letter. */
|
|
32
|
+
export const SLOT_NAME_PATTERN = /^[a-z][a-z0-9-]{0,23}$/;
|
|
33
|
+
/** Names that a slot may not have: they mean something to an object (`constructor`, `prototype`). */
|
|
34
|
+
export const RESERVED_SLOT_NAMES: readonly string[] = ["constructor", "prototype"];
|
|
35
|
+
/** 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.) */
|
|
36
|
+
export const SLOT_WORD_PATTERN = /^[a-z0-9-]{1,40}$/;
|
|
20
37
|
export const NAME_MAX = 40;
|
|
21
38
|
export const PRICE_MAX_USD = 1000;
|
|
22
39
|
|
|
@@ -24,14 +41,17 @@ export const PRICE_MAX_USD = 1000;
|
|
|
24
41
|
export type Problem = {
|
|
25
42
|
/** The place of the look in the list, from 1, or null for a mistake of the whole list. */
|
|
26
43
|
look: number | null;
|
|
27
|
-
/** The field: "list", "id", "name", "priceUsd", "change.body", "change.hat", "change.hatColor" or "free". */
|
|
44
|
+
/** The field: "list", "id", "name", "priceUsd", "change.body", "change.hat", "change.hatColor", "slots", "slots.<name>" or "free". (The change rules also use "slots" for a slot that changed on a look that players own.) */
|
|
28
45
|
field: string;
|
|
29
46
|
message: string;
|
|
30
47
|
};
|
|
31
48
|
|
|
32
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* 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
|
|
51
|
+
* and 1e-9 would pass): a price is whole cents when it is exactly the number that its own two-decimal text names.
|
|
52
|
+
*/
|
|
33
53
|
export function wholeCents(usd: number): boolean {
|
|
34
|
-
return
|
|
54
|
+
return typeof usd === "number" && Number.isFinite(usd) && Number(usd.toFixed(2)) === usd;
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
/** 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. */
|
|
@@ -54,16 +74,57 @@ export function lookProblems(raw: unknown): Problem[] {
|
|
|
54
74
|
if (typeof v.change?.body !== "string" || !COLOR_PATTERN.test(v.change.body)) add("change.body", '"change.body" must be a colour like "#4aa8ff"');
|
|
55
75
|
if (v.change?.hat !== undefined && !(HATS as readonly unknown[]).includes(v.change.hat)) add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
|
|
56
76
|
if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor))) add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
|
|
77
|
+
if (v.slots !== undefined) slotProblems(v.slots).forEach((s) => add(s.field, s.message));
|
|
57
78
|
});
|
|
58
79
|
if (!out.length && !free) out.push({ look: null, field: "free", message: "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
|
|
59
80
|
return out;
|
|
60
81
|
}
|
|
61
82
|
|
|
83
|
+
/** Whether `v` is a plain object (not null, not a list, not a class instance). */
|
|
84
|
+
const isPlain = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
85
|
+
|
|
86
|
+
/** Whether a slot's value is good: a word or a colour. */
|
|
87
|
+
export const slotValueOk = (v: unknown): v is string => typeof v === "string" && (SLOT_WORD_PATTERN.test(v) || COLOR_PATTERN.test(v));
|
|
88
|
+
|
|
89
|
+
/** The mistakes in one look's `slots` (the look's own message is added by the caller): the field is "slots" or "slots.<name>". */
|
|
90
|
+
function slotProblems(slots: unknown): { field: string; message: string }[] {
|
|
91
|
+
if (!isPlain(slots)) return [{ field: "slots", message: '"slots" must be an object of names and values, like { "wings": "ember" }' }];
|
|
92
|
+
const names = Object.keys(slots);
|
|
93
|
+
const out: { field: string; message: string }[] = [];
|
|
94
|
+
if (names.length > SLOTS_MAX) out.push({ field: "slots", message: `"slots" can have at most ${SLOTS_MAX} slots (this one has ${names.length})` });
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
if (!SLOT_NAME_PATTERN.test(name)) out.push({ field: `slots.${name}`, message: `the slot name "${name}" must be 1 to 24 lowercase letters, digits or dashes, starting with a letter` });
|
|
97
|
+
else if (RESERVED_SLOT_NAMES.includes(name)) out.push({ field: `slots.${name}`, message: `the slot name "${name}" is reserved: choose another` });
|
|
98
|
+
else if (!slotValueOk(slots[name])) out.push({ field: `slots.${name}`, message: `"slots.${name}" must be a word (1 to 40 lowercase letters, digits or dashes) or a colour like "#ffcf5a"` });
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 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
|
|
105
|
+
* ("#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.
|
|
106
|
+
*/
|
|
107
|
+
export function cleanSlots(slots: Slots | undefined): Slots | undefined {
|
|
108
|
+
if (!slots) return undefined;
|
|
109
|
+
const names = Object.keys(slots);
|
|
110
|
+
return names.length ? Object.fromEntries(names.map((n) => [n, typeof slots[n] === "string" && COLOR_PATTERN.test(slots[n]) ? slots[n].toLowerCase() : slots[n]])) : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
62
113
|
/** The looks, or an Error that says the first mistake in plain words. The same words as the starter's `checkLooks`. */
|
|
63
|
-
export function checkLooks(
|
|
114
|
+
export function checkLooks(rawInput: unknown): Look[] {
|
|
115
|
+
// One snapshot of the data is checked AND copied: a getter or a changing value cannot be good for the check and something else for the copy. (JSON cannot hold one.)
|
|
116
|
+
let raw: unknown = rawInput;
|
|
117
|
+
try {
|
|
118
|
+
raw = rawInput === undefined ? undefined : JSON.parse(JSON.stringify(rawInput));
|
|
119
|
+
} catch {
|
|
120
|
+
throw new Error("looks.json must be plain JSON data");
|
|
121
|
+
}
|
|
64
122
|
const first = lookProblems(raw)[0];
|
|
65
123
|
if (first) throw new Error(first.message);
|
|
66
|
-
return (raw as Look[]).map((l) =>
|
|
124
|
+
return (raw as Look[]).map((l) => {
|
|
125
|
+
const slots = cleanSlots(l.slots);
|
|
126
|
+
return { id: l.id, name: l.name, priceUsd: l.priceUsd, change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor }, ...(slots ? { slots } : {}) };
|
|
127
|
+
});
|
|
67
128
|
}
|
|
68
129
|
|
|
69
130
|
/** "$0.25" */
|