@nano133/nanoterra 0.4.0 → 0.6.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 +32 -0
- package/README.md +161 -9
- package/ai/SPEC.md +27 -4
- package/dist/admin/adapters/d1.d.ts +1 -0
- package/dist/admin/adapters/d1.js +6 -4
- package/dist/admin/adapters/firestore.d.ts +1 -0
- package/dist/admin/adapters/firestore.js +7 -5
- package/dist/admin/conformance.d.ts +1 -1
- package/dist/admin/conformance.js +13 -5
- package/dist/admin/handler.d.ts +1 -1
- package/dist/admin/handler.js +3 -3
- package/dist/admin/index.d.ts +2 -2
- package/dist/admin/index.js +2 -2
- package/dist/admin/memory.js +3 -1
- package/dist/admin/panel/gameCard.d.ts +1 -1
- package/dist/admin/panel/gameCard.js +56 -6
- package/dist/admin/panel/mount.js +3 -3
- package/dist/admin/panel/overview.d.ts +1 -1
- package/dist/admin/panel/overview.js +4 -2
- package/dist/admin/panel/shelf.d.ts +4 -1
- package/dist/admin/panel/shelf.js +84 -29
- package/dist/admin/panel/shelfData.js +1 -1
- package/dist/admin/store.d.ts +2 -1
- package/dist/admin/switches.d.ts +23 -1
- package/dist/admin/switches.js +26 -5
- package/dist/friends/adapters/d1.d.ts +4 -0
- package/dist/friends/adapters/d1.js +131 -0
- package/dist/friends/adapters/firestore.d.ts +49 -0
- package/dist/friends/adapters/firestore.js +163 -0
- package/dist/friends/conformance.d.ts +11 -0
- package/dist/friends/conformance.js +229 -0
- package/dist/friends/handler.d.ts +31 -0
- package/dist/friends/handler.js +139 -0
- package/dist/friends/index.d.ts +8 -0
- package/dist/friends/index.js +12 -0
- package/dist/friends/memory.d.ts +9 -0
- package/dist/friends/memory.js +79 -0
- package/dist/friends/panel/index.d.ts +4 -0
- package/dist/friends/panel/index.js +10 -0
- package/dist/friends/panel/mount.d.ts +38 -0
- package/dist/friends/panel/mount.js +321 -0
- package/dist/friends/panel/window.d.ts +52 -0
- package/dist/friends/panel/window.js +72 -0
- package/dist/friends/rules.d.ts +92 -0
- package/dist/friends/rules.js +180 -0
- package/dist/friends/types.d.ts +58 -0
- package/dist/friends/types.js +24 -0
- package/dist/looks/change.d.ts +5 -5
- package/dist/looks/change.js +12 -7
- package/dist/looks/index.d.ts +2 -2
- package/dist/looks/index.js +1 -1
- package/dist/looks/json.d.ts +3 -2
- package/dist/looks/json.js +16 -9
- package/dist/looks/look.d.ts +26 -6
- package/dist/looks/look.js +37 -12
- package/package.json +11 -4
- package/src/admin/adapters/d1.ts +5 -4
- package/src/admin/adapters/firestore.ts +6 -5
- package/src/admin/conformance.ts +15 -6
- package/src/admin/handler.ts +3 -3
- package/src/admin/index.ts +3 -3
- package/src/admin/memory.ts +2 -1
- package/src/admin/panel/gameCard.ts +52 -6
- package/src/admin/panel/mount.ts +3 -3
- package/src/admin/panel/overview.ts +4 -3
- package/src/admin/panel/shelf.ts +135 -65
- package/src/admin/panel/shelfData.ts +1 -1
- package/src/admin/panel/style.css +10 -1
- package/src/admin/store.ts +2 -2
- package/src/admin/switches.ts +35 -9
- package/src/friends/adapters/d1.sql +33 -0
- package/src/friends/adapters/d1.ts +146 -0
- package/src/friends/adapters/firestore.ts +197 -0
- package/src/friends/conformance.ts +260 -0
- package/src/friends/handler.ts +144 -0
- package/src/friends/index.ts +17 -0
- package/src/friends/memory.ts +83 -0
- package/src/friends/panel/index.ts +13 -0
- package/src/friends/panel/mount.ts +346 -0
- package/src/friends/panel/style.css +303 -0
- package/src/friends/panel/window.ts +79 -0
- package/src/friends/rules.ts +196 -0
- package/src/friends/types.ts +57 -0
- package/src/looks/change.ts +12 -9
- package/src/looks/index.ts +2 -2
- package/src/looks/json.ts +12 -7
- package/src/looks/look.ts +47 -9
package/dist/looks/look.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/** The hats that the starter's character can wear. */
|
|
2
2
|
export declare const HATS: readonly ["cap", "beanie", "crown", "tophat"];
|
|
3
3
|
export type Hat = (typeof HATS)[number];
|
|
4
|
+
/** What an outfit changes: the body's colour, and a hat in its colour. */
|
|
5
|
+
export type Change = {
|
|
6
|
+
body: string;
|
|
7
|
+
hat?: Hat;
|
|
8
|
+
hatColor?: string;
|
|
9
|
+
};
|
|
4
10
|
export type Look = {
|
|
5
11
|
/** 1 to 40 lowercase letters, digits or dashes: "mint-cap". Never change the id of a look that someone owns. */
|
|
6
12
|
id: string;
|
|
@@ -8,11 +14,14 @@ export type Look = {
|
|
|
8
14
|
name: string;
|
|
9
15
|
/** The price in US dollars with at most 2 decimals, for example 0.25. 0 means free for everyone. */
|
|
10
16
|
priceUsd: number;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The wear slot that the look fills: none (or "outfit") for an outfit, or a slot of the game's own ("shovel", "charm"): 1 to 24 lowercase letters, digits
|
|
19
|
+
* or dashes, starting with a letter. A player wears one look in each slot. Never change the slot of a look that someone owns. `checkLooks` leaves it out
|
|
20
|
+
* for an outfit.
|
|
21
|
+
*/
|
|
22
|
+
wear?: string;
|
|
23
|
+
/** What an outfit changes (an outfit must have it; an item of another slot must not: `isOutfit`). */
|
|
24
|
+
change?: Change;
|
|
16
25
|
/**
|
|
17
26
|
* What a game defines for itself: up to 8 slots, each a name and a value, that the game's own code reads ("wings": "ember", "trail": "#ffcf5a"). A name is 1 to 24
|
|
18
27
|
* lowercase letters, digits or dashes, starting with a letter; a value is a word (1 to 40 lowercase letters, digits or dashes) or a colour ("#rrggbb"). Optional: a look
|
|
@@ -23,6 +32,8 @@ export type Look = {
|
|
|
23
32
|
/** The slots of a look: names and values, words or colours. */
|
|
24
33
|
export type Slots = Record<string, string>;
|
|
25
34
|
export declare const ID_PATTERN: RegExp;
|
|
35
|
+
/** The wear slot of an outfit (the character's own look). A look with no `wear` is an outfit too. */
|
|
36
|
+
export declare const OUTFIT = "outfit";
|
|
26
37
|
export declare const COLOR_PATTERN: RegExp;
|
|
27
38
|
/** At most this many slots on a look. */
|
|
28
39
|
export declare const SLOTS_MAX = 8;
|
|
@@ -38,7 +49,10 @@ export declare const PRICE_MAX_USD = 1000;
|
|
|
38
49
|
export type Problem = {
|
|
39
50
|
/** The place of the look in the list, from 1, or null for a mistake of the whole list. */
|
|
40
51
|
look: number | null;
|
|
41
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* The field: "list", "id", "name", "priceUsd", "wear", "change", "change.body", "change.hat", "change.hatColor", "slots", "slots.<name>" or "free". (The change
|
|
54
|
+
* rules also use "slots" and "wear" for a slot or a wear slot that changed on a look that players own.)
|
|
55
|
+
*/
|
|
42
56
|
field: string;
|
|
43
57
|
message: string;
|
|
44
58
|
};
|
|
@@ -60,3 +74,9 @@ export declare function cleanSlots(slots: Slots | undefined): Slots | undefined;
|
|
|
60
74
|
export declare function checkLooks(rawInput: unknown): Look[];
|
|
61
75
|
/** "$0.25" */
|
|
62
76
|
export declare const usdText: (usd: number) => string;
|
|
77
|
+
/** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
|
|
78
|
+
export declare const wearOf: (l: Pick<Look, "wear">) => string;
|
|
79
|
+
/** Whether a look is an outfit (the character's own look, with its `change`), not an item of another wear slot. */
|
|
80
|
+
export declare const isOutfit: (l: Look) => l is Look & {
|
|
81
|
+
change: Change;
|
|
82
|
+
};
|
package/dist/looks/look.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
// The look: one thing a player can buy to change how their character looks. The rules are the game starter's (looks.json): a look only
|
|
2
2
|
// changes how the character looks, it is never random and it never changes a score, a speed or a rule.
|
|
3
|
+
//
|
|
4
|
+
// A look fills one WEAR SLOT. An outfit (no `wear`, or "outfit") is the character's own look: its body colour and its hat (`change`); a player wears one
|
|
5
|
+
// outfit. Every other wear slot is the game's own (a tool's skin, a charm, a trail: "shovel", "charm"): an item there has no `change` (the game reads what
|
|
6
|
+
// it is from its id or its `slots`), and a player wears one item in each slot, beside the outfit.
|
|
3
7
|
/** The hats that the starter's character can wear. */
|
|
4
8
|
export const HATS = ["cap", "beanie", "crown", "tophat"];
|
|
5
9
|
export const ID_PATTERN = /^[a-z0-9-]{1,40}$/;
|
|
10
|
+
/** The wear slot of an outfit (the character's own look). A look with no `wear` is an outfit too. */
|
|
11
|
+
export const OUTFIT = "outfit";
|
|
6
12
|
export const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
7
13
|
/** At most this many slots on a look. */
|
|
8
14
|
export const SLOTS_MAX = 8;
|
|
@@ -27,7 +33,7 @@ export function lookProblems(raw) {
|
|
|
27
33
|
if (!Array.isArray(raw) || raw.length === 0)
|
|
28
34
|
return [{ look: null, field: "list", message: "looks.json must be a list with at least one look" }];
|
|
29
35
|
const seen = new Set();
|
|
30
|
-
let
|
|
36
|
+
let freeOutfit = false;
|
|
31
37
|
raw.forEach((l, i) => {
|
|
32
38
|
const where = `looks.json, look ${i + 1}`;
|
|
33
39
|
const v = (l ?? {});
|
|
@@ -44,19 +50,32 @@ export function lookProblems(raw) {
|
|
|
44
50
|
add("priceUsd", '"priceUsd" must be a number from 0 to 1000');
|
|
45
51
|
else if (!wholeCents(v.priceUsd))
|
|
46
52
|
add("priceUsd", '"priceUsd" can have at most 2 decimals (cents)');
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
const wearOk = v.wear === undefined || (typeof v.wear === "string" && SLOT_NAME_PATTERN.test(v.wear) && !RESERVED_SLOT_NAMES.includes(v.wear));
|
|
54
|
+
if (!wearOk)
|
|
55
|
+
add("wear", `"wear" must be "${OUTFIT}" or the name of a wear slot: 1 to 24 lowercase letters, digits or dashes, starting with a letter ("shovel")`);
|
|
56
|
+
const outfit = v.wear === undefined || v.wear === OUTFIT;
|
|
57
|
+
if (outfit) {
|
|
58
|
+
if (typeof v.change?.body !== "string" || !COLOR_PATTERN.test(v.change.body))
|
|
59
|
+
add("change.body", '"change.body" must be a colour like "#4aa8ff"');
|
|
60
|
+
if (v.change?.hat !== undefined && !HATS.includes(v.change.hat))
|
|
61
|
+
add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
|
|
62
|
+
if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor)))
|
|
63
|
+
add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
|
|
64
|
+
if (v.priceUsd === 0)
|
|
65
|
+
freeOutfit = true;
|
|
66
|
+
}
|
|
67
|
+
else if (wearOk && v.change !== undefined)
|
|
68
|
+
add("change", `"change" is only for an outfit: a look worn in the "${v.wear}" slot does not change the body (remove "change")`);
|
|
55
69
|
if (v.slots !== undefined)
|
|
56
70
|
slotProblems(v.slots).forEach((s) => add(s.field, s.message));
|
|
57
71
|
});
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
// the starter's words for a list of outfits only; a list with items says that an item does not count
|
|
73
|
+
const items = raw.some((l) => {
|
|
74
|
+
const w = l?.wear;
|
|
75
|
+
return w !== undefined && w !== OUTFIT;
|
|
76
|
+
});
|
|
77
|
+
if (!out.length && !freeOutfit)
|
|
78
|
+
out.push({ look: null, field: "free", message: items ? 'looks.json needs at least one free outfit (priceUsd 0, and no "wear"): every player starts with it' : "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
|
|
60
79
|
return out;
|
|
61
80
|
}
|
|
62
81
|
/** Whether `v` is a plain object (not null, not a list, not a class instance). */
|
|
@@ -106,8 +125,14 @@ export function checkLooks(rawInput) {
|
|
|
106
125
|
throw new Error(first.message);
|
|
107
126
|
return raw.map((l) => {
|
|
108
127
|
const slots = cleanSlots(l.slots);
|
|
109
|
-
|
|
128
|
+
const wear = wearOf(l);
|
|
129
|
+
const change = isOutfit(l) ? { change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor } } : {};
|
|
130
|
+
return { id: l.id, name: l.name, priceUsd: l.priceUsd, ...(wear === OUTFIT ? {} : { wear }), ...change, ...(slots ? { slots } : {}) };
|
|
110
131
|
});
|
|
111
132
|
}
|
|
112
133
|
/** "$0.25" */
|
|
113
134
|
export const usdText = (usd) => `$${usd.toFixed(2)}`;
|
|
135
|
+
/** The wear slot that a look fills: OUTFIT for an outfit (no `wear`), or the game's own slot. */
|
|
136
|
+
export const wearOf = (l) => l.wear ?? OUTFIT;
|
|
137
|
+
/** Whether a look is an outfit (the character's own look, with its `change`), not an item of another wear slot. */
|
|
138
|
+
export const isOutfit = (l) => wearOf(l) === OUTFIT && l.change !== undefined;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano133/nanoterra",
|
|
3
|
-
"version": "0.
|
|
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
|
|
3
|
+
"version": "0.6.0",
|
|
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), the looks ladder, the admin and the friends list.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -18,7 +18,14 @@
|
|
|
18
18
|
"./admin/d1": { "types": "./dist/admin/adapters/d1.d.ts", "import": "./dist/admin/adapters/d1.js" },
|
|
19
19
|
"./admin/firestore": { "types": "./dist/admin/adapters/firestore.d.ts", "import": "./dist/admin/adapters/firestore.js" },
|
|
20
20
|
"./admin/panel": { "types": "./dist/admin/panel/index.d.ts", "import": "./dist/admin/panel/index.js" },
|
|
21
|
-
"./admin/style.css": "./src/admin/panel/style.css"
|
|
21
|
+
"./admin/style.css": "./src/admin/panel/style.css",
|
|
22
|
+
"./friends": { "types": "./dist/friends/index.d.ts", "import": "./dist/friends/index.js" },
|
|
23
|
+
"./friends/conformance": { "types": "./dist/friends/conformance.d.ts", "import": "./dist/friends/conformance.js" },
|
|
24
|
+
"./friends/d1": { "types": "./dist/friends/adapters/d1.d.ts", "import": "./dist/friends/adapters/d1.js" },
|
|
25
|
+
"./friends/d1.sql": "./src/friends/adapters/d1.sql",
|
|
26
|
+
"./friends/firestore": { "types": "./dist/friends/adapters/firestore.d.ts", "import": "./dist/friends/adapters/firestore.js" },
|
|
27
|
+
"./friends/panel": { "types": "./dist/friends/panel/index.d.ts", "import": "./dist/friends/panel/index.js" },
|
|
28
|
+
"./friends/style.css": "./src/friends/panel/style.css"
|
|
22
29
|
},
|
|
23
30
|
"sideEffects": false,
|
|
24
31
|
"files": ["dist", "src", "README.md", "CHANGELOG.md", "LICENSE", "ai"],
|
|
@@ -46,5 +53,5 @@
|
|
|
46
53
|
},
|
|
47
54
|
"homepage": "https://nano133.com/build/nanoterra",
|
|
48
55
|
"license": "MIT",
|
|
49
|
-
"keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics"]
|
|
56
|
+
"keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics", "friends", "friends list"]
|
|
50
57
|
}
|
package/src/admin/adapters/d1.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The admin store for a game on Cloudflare D1, with the tables of the game starter (migrations/0001_init.sql): `meta`, `orders` and `owned`.
|
|
2
|
-
// It writes ONLY the
|
|
2
|
+
// It writes ONLY the three settings (`setting:character`, `setting:signin`, `setting:friends` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
|
|
3
3
|
// (`logins: true`): then the one other write is `DELETE FROM sessions WHERE scope = 'admin'` ("sign out everywhere").
|
|
4
4
|
//
|
|
5
5
|
// import { createD1Store } from "@nano133/nanoterra/admin/d1";
|
|
@@ -21,7 +21,7 @@ export interface D1Like {
|
|
|
21
21
|
batch(statements: D1StatementLike[]): Promise<unknown[]>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export const D1_KEYS = { character: "setting:character", signin: "setting:signin" } as const;
|
|
24
|
+
export const D1_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" } as const;
|
|
25
25
|
|
|
26
26
|
type Row = { id: string; player: string; look_id: string; usd: string; amount: string; status: string; created_at: number; expires_at: number; paid_at: number | null; payer: string | null; hash: string | null };
|
|
27
27
|
|
|
@@ -34,9 +34,9 @@ type Row = { id: string; player: string; look_id: string; usd: string; amount: s
|
|
|
34
34
|
export function createD1Store(db: D1Like, options: { logins?: boolean } = {}): AdminStore {
|
|
35
35
|
const store: AdminStore = {
|
|
36
36
|
async readSettings() {
|
|
37
|
-
const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?)").bind(D1_KEYS.character, D1_KEYS.signin).all<{ key: string; value: string }>()).results;
|
|
37
|
+
const rows = (await db.prepare("SELECT key, value FROM meta WHERE key IN (?, ?, ?)").bind(D1_KEYS.character, D1_KEYS.signin, D1_KEYS.friends).all<{ key: string; value: string }>()).results;
|
|
38
38
|
const row = (key: string) => rows.find((r) => r.key === key)?.value;
|
|
39
|
-
return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin) };
|
|
39
|
+
return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin), friends: row(D1_KEYS.friends) };
|
|
40
40
|
},
|
|
41
41
|
|
|
42
42
|
async saveSettings(patch: GamePatch, now: number) {
|
|
@@ -45,6 +45,7 @@ export function createD1Store(db: D1Like, options: { logins?: boolean } = {}): A
|
|
|
45
45
|
const batch = [];
|
|
46
46
|
if (patch.character) batch.push(write(D1_KEYS.character, patch.character));
|
|
47
47
|
if (patch.signin) batch.push(write(D1_KEYS.signin, patch.signin));
|
|
48
|
+
if (patch.friends) batch.push(write(D1_KEYS.friends, patch.friends));
|
|
48
49
|
if (batch.length) await db.batch(batch);
|
|
49
50
|
},
|
|
50
51
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The admin store for a game on Firestore (the Admin SDK). The package has no Firebase dependency: the adapter uses the small interface below, which the
|
|
2
|
-
// Admin SDK's `Firestore` fits. It writes ONLY the
|
|
2
|
+
// Admin SDK's `Firestore` fits. It writes ONLY the three settings; everything else it does is a read.
|
|
3
3
|
//
|
|
4
4
|
// The data model (the names a game must have; only the three collection names can be changed):
|
|
5
|
-
// meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "rate" (value is JSON { usd, sources })
|
|
5
|
+
// meta/{key} { value: string, at: number } keys "setting:character", "setting:signin", "setting:friends", "rate" (value is JSON { usd, sources })
|
|
6
6
|
// orders/{id} { player, lookId, usd, amount, status, createdAt, expiresAt, paidAt, payer, hash } `amount` is the raw amount as text
|
|
7
7
|
// owned/{player__lookId} { player, lookId, orderId, grantedAt }
|
|
8
8
|
//
|
|
@@ -33,7 +33,7 @@ export interface FirestoreLike {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export const FIRESTORE_SCAN_LIMIT = 5000;
|
|
36
|
-
export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin" } as const;
|
|
36
|
+
export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin", friends: "setting:friends" } as const;
|
|
37
37
|
|
|
38
38
|
const text = (v: unknown, fallback = ""): string => (typeof v === "string" ? v : fallback);
|
|
39
39
|
const num = (v: unknown, fallback = 0): number => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
|
|
@@ -49,8 +49,8 @@ export function createFirestoreStore(db: FirestoreLike, collections: { meta?: st
|
|
|
49
49
|
};
|
|
50
50
|
return {
|
|
51
51
|
async readSettings() {
|
|
52
|
-
const [character, signin] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin)]);
|
|
53
|
-
return { character: character?.value, signin: signin?.value };
|
|
52
|
+
const [character, signin, friends] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin), valueOf(FIRESTORE_KEYS.friends)]);
|
|
53
|
+
return { character: character?.value, signin: signin?.value, friends: friends?.value };
|
|
54
54
|
},
|
|
55
55
|
|
|
56
56
|
async saveSettings(patch: GamePatch, now: number) {
|
|
@@ -58,6 +58,7 @@ export function createFirestoreStore(db: FirestoreLike, collections: { meta?: st
|
|
|
58
58
|
let n = 0;
|
|
59
59
|
if (patch.character) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.character), { value: patch.character, at: now }), n++);
|
|
60
60
|
if (patch.signin) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
|
|
61
|
+
if (patch.friends) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.friends), { value: patch.friends, at: now }), n++);
|
|
61
62
|
if (n) await batch.commit();
|
|
62
63
|
},
|
|
63
64
|
|
package/src/admin/conformance.ts
CHANGED
|
@@ -97,7 +97,7 @@ async function read(res: Response): Promise<{ status: number; text: string; json
|
|
|
97
97
|
const REFUSAL_KEYS = new Set(["error", "reason", "signedInAs"]);
|
|
98
98
|
|
|
99
99
|
/** Words of the game's data that a refusal or an answer to a visitor must never hold. */
|
|
100
|
-
const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"warnings"/;
|
|
100
|
+
const DATA_WORDS = /"orders"|"players"|"totals"|"counts"|"character"|"signin"|"friends"|"warnings"/;
|
|
101
101
|
|
|
102
102
|
export function adminConformance(host: ConformanceHost): ConformanceCase[] {
|
|
103
103
|
const cases: ConformanceCase[] = [];
|
|
@@ -332,7 +332,7 @@ export function adminConformance(host: ConformanceHost): ConformanceCase[] {
|
|
|
332
332
|
export type StoreHarness = {
|
|
333
333
|
store: AdminStore;
|
|
334
334
|
seed(data: { orders?: OrderRow[]; owned?: { player: string; lookId: string; at: number }[]; rate?: { value: string; at: number } }): Promise<void>;
|
|
335
|
-
/** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the
|
|
335
|
+
/** Every row that the store could change by mistake, as one text: the orders, the owned looks and the meta rows that are NOT the three settings. Same rows, same text. */
|
|
336
336
|
snapshot(): Promise<string>;
|
|
337
337
|
/** Only for a store with `recentLogins` and `signOutEverywhere`: put admin sign-ins in the game's database (the store only reads them). */
|
|
338
338
|
seedLogins?(rows: { at: number; country: string | null; browser: string }[]): Promise<void>;
|
|
@@ -358,7 +358,16 @@ export function adminStoreConformance(make: () => Promise<StoreHarness>): Confor
|
|
|
358
358
|
|
|
359
359
|
add("a new store has no settings of its own", async ({ store }) => {
|
|
360
360
|
const s = await store.readSettings();
|
|
361
|
-
check(s.character === undefined && s.signin === undefined, `expected no settings, got ${JSON.stringify(s)}`);
|
|
361
|
+
check(s.character === undefined && s.signin === undefined && s.friends === undefined, `expected no settings, got ${JSON.stringify(s)}`);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
add("the friends list is kept as its own setting: each of its three words, and a patch of it leaves the other two", async ({ store }) => {
|
|
365
|
+
await store.saveSettings({ signin: "none", character: "own" }, NOW);
|
|
366
|
+
for (const [i, friends] of (["own", "off", "nanoterra"] as const).entries()) {
|
|
367
|
+
await store.saveSettings({ friends }, NOW + 1 + i);
|
|
368
|
+
const s = await store.readSettings();
|
|
369
|
+
check(s.friends === friends && s.signin === "none" && s.character === "own", `after friends ${friends}: ${JSON.stringify(s)}`);
|
|
370
|
+
}
|
|
362
371
|
});
|
|
363
372
|
|
|
364
373
|
add("a patch saves only what it names; the next patch keeps the rest", async ({ store }) => {
|
|
@@ -376,13 +385,13 @@ export function adminStoreConformance(make: () => Promise<StoreHarness>): Confor
|
|
|
376
385
|
add("an empty patch writes nothing and does not fail", async ({ store }) => {
|
|
377
386
|
await store.saveSettings({}, NOW);
|
|
378
387
|
const s = await store.readSettings();
|
|
379
|
-
check(s.character === undefined && s.signin === undefined, "an empty patch must save nothing");
|
|
388
|
+
check(s.character === undefined && s.signin === undefined && s.friends === undefined, "an empty patch must save nothing");
|
|
380
389
|
});
|
|
381
390
|
|
|
382
|
-
add("saving changes nothing but the
|
|
391
|
+
add("saving changes nothing but the settings: no order, no owned look, no other meta row", async (h) => {
|
|
383
392
|
await h.seed({ orders: [order("1", {}), order("2", { status: "open" })], owned: [{ player: A, lookId: "mint-cap", at: NOW }], rate: { value: JSON.stringify({ usd: 1.2, sources: ["x"] }), at: NOW } });
|
|
384
393
|
const before = await h.snapshot();
|
|
385
|
-
await h.store.saveSettings({ character: "own", signin: "none" }, NOW + 5);
|
|
394
|
+
await h.store.saveSettings({ character: "own", signin: "none", friends: "off" }, NOW + 5);
|
|
386
395
|
check((await h.snapshot()) === before, "the rows of the game changed after saving the settings");
|
|
387
396
|
});
|
|
388
397
|
|
package/src/admin/handler.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The admin handler: the routes of /admin on the Web `Request` and `Response` types, so it fits a Worker, Firebase Functions and Node.
|
|
2
2
|
//
|
|
3
3
|
// GET {base}/summary the numbers: status, orders, the looks that players own
|
|
4
|
-
// GET {base}/game the
|
|
5
|
-
// POST {base}/game a press: { character } or {
|
|
4
|
+
// GET {base}/game the game switches
|
|
5
|
+
// POST {base}/game a press: { character }, { signin } or { friends } (only the known words)
|
|
6
6
|
// POST {base}/check the payment check, when the game gives one
|
|
7
7
|
//
|
|
8
8
|
// EVERY request under the base goes through `adminAccess` first, before the route is even looked up: a visitor who is not the owner gets 401 or 403
|
|
@@ -190,7 +190,7 @@ export function createAdminHandler(options: AdminHandlerOptions): AdminHandler {
|
|
|
190
190
|
return { routes, basePath: options.basePath ?? "/api/admin", handle };
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
/** The settings as the game's own routes need them (the listing file, the character): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
193
|
+
/** The settings as the game's own routes need them (the listing file, the character, the friends list): the stored words, or the game's defaults. A fault in the store gives the defaults. */
|
|
194
194
|
export async function loadGameSettings(store: Pick<AdminStore, "readSettings">, defaults: GameSettings): Promise<GameState> {
|
|
195
195
|
try {
|
|
196
196
|
return mergeSettings(await store.readSettings(), defaults);
|
package/src/admin/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// adminAccess the guard: one pure function, who may open /admin
|
|
4
4
|
// createAdminHandler the routes, on Request and Response
|
|
5
|
-
// loadGameSettings the
|
|
5
|
+
// loadGameSettings the game switches for the game's own routes
|
|
6
6
|
// createMemoryStore a store in memory, for tests
|
|
7
7
|
// (the conformance suite is its own path: "@nano133/nanoterra/admin/conformance"; the adapters are "/admin/d1" and "/admin/firestore")
|
|
8
8
|
|
|
@@ -17,5 +17,5 @@ export type { AdminLogin, AdminOrder, AdminPlayer, AdminStatus, AdminStore, Admi
|
|
|
17
17
|
export { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, statusOf, summarize } from "./summary.js";
|
|
18
18
|
export type { OrderRow } from "./summary.js";
|
|
19
19
|
export { RAW_PER_XNO, xnoText } from "./amount.js";
|
|
20
|
-
export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
|
|
21
|
-
export type { Character, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
|
|
20
|
+
export { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, DEFAULT_FRIENDS, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, isCharacter, isFriends, isSignin, isSigninChoice, mergeSettings, parsePatch, pressOf, signinOn, stateOf } from "./switches.js";
|
|
21
|
+
export type { Character, Friends, GamePatch, GameSettings, GameState, Signin, SwitchId } from "./switches.js";
|
package/src/admin/memory.ts
CHANGED
|
@@ -35,11 +35,12 @@ export function createMemoryStore(seed: { orders?: OrderRow[]; owned?: { player:
|
|
|
35
35
|
pendingAdminRequests: 0,
|
|
36
36
|
pendingGameRequests: 0,
|
|
37
37
|
async readSettings() {
|
|
38
|
-
return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin") };
|
|
38
|
+
return { character: store.rows.get("setting:character"), signin: store.rows.get("setting:signin"), friends: store.rows.get("setting:friends") };
|
|
39
39
|
},
|
|
40
40
|
async saveSettings(patch: GamePatch) {
|
|
41
41
|
if (patch.character) store.rows.set("setting:character", patch.character);
|
|
42
42
|
if (patch.signin) store.rows.set("setting:signin", patch.signin);
|
|
43
|
+
if (patch.friends) store.rows.set("setting:friends", patch.friends);
|
|
43
44
|
store.writes++;
|
|
44
45
|
},
|
|
45
46
|
async summary(now: number): Promise<StoreSummary> {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra"
|
|
2
|
-
// (POST {api}/game, behind the same admin guard as every admin call) and
|
|
3
|
-
// nothing. The words are in ../switches.ts. The card changes ONLY these
|
|
1
|
+
// The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra", and one three-way
|
|
2
|
+
// choice, "Friends list: Nanoterra / The game's own / Off". A press saves at once (POST {api}/game, behind the same admin guard as every admin call) and
|
|
3
|
+
// the game reads the setting at run time, so the creator pushes and deploys nothing. The words are in ../switches.ts. The card changes ONLY these three
|
|
4
|
+
// settings: never a look, a price or an order.
|
|
4
5
|
|
|
5
|
-
import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf, type GameState, type SwitchId } from "../switches.js";
|
|
6
|
+
import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, FRIENDS_CHOICES, FRIENDS_TITLE, REVIEW_LINK, REVIEW_NOTE, SWITCHES, pressOf, signinOn, stateOf, type Friends, type GameState, type SwitchId } from "../switches.js";
|
|
6
7
|
import { ApiError, type Api } from "./api.js";
|
|
7
8
|
import { h } from "./dom.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* 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
|
|
12
|
+
* written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the switches too).
|
|
12
13
|
*/
|
|
13
14
|
export function gameCard(api: Api, initial: GameState, defaultsFile = "the game's own settings", onChange?: (state: GameState) => void): HTMLElement {
|
|
14
15
|
let state = initial;
|
|
@@ -16,7 +17,11 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
|
|
|
16
17
|
/** 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. */
|
|
17
18
|
const needs = h("p", { class: "fine switch-needs" });
|
|
18
19
|
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" }, `
|
|
20
|
+
card.append(h("p", { class: "fine card-intro" }, `Three choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
|
|
21
|
+
/** The friends choice: a radio group of three keys (arrow keys move between them, as a radio group does). */
|
|
22
|
+
const friendsKeys = new Map<Friends, HTMLButtonElement>();
|
|
23
|
+
const friendsNote = h("p", { class: "switch-note", id: "switch-friends-note" });
|
|
24
|
+
const friendsStatus = h("span", { class: "fine", role: "status", "aria-live": "polite" });
|
|
20
25
|
|
|
21
26
|
const draw = () => {
|
|
22
27
|
for (const [id, row] of rows) {
|
|
@@ -28,6 +33,29 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
|
|
|
28
33
|
}
|
|
29
34
|
needs.hidden = !stateOf("character", state);
|
|
30
35
|
needs.textContent = signinOn(state.signin) ? CHARACTER_NEEDS_SIGNIN : CHARACTER_SIGNIN_OFF;
|
|
36
|
+
for (const [id, key] of friendsKeys) {
|
|
37
|
+
const on = state.friends === id;
|
|
38
|
+
key.setAttribute("aria-checked", on ? "true" : "false");
|
|
39
|
+
key.tabIndex = on ? 0 : -1;
|
|
40
|
+
key.classList.toggle("is-on", on);
|
|
41
|
+
}
|
|
42
|
+
friendsNote.textContent = FRIENDS_CHOICES[state.friends]?.note ?? "";
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const choose = async (id: Friends) => {
|
|
46
|
+
if (state.friends === id) return;
|
|
47
|
+
for (const key of friendsKeys.values()) key.disabled = true;
|
|
48
|
+
friendsStatus.textContent = "Saving…";
|
|
49
|
+
try {
|
|
50
|
+
state = await api.post<GameState>("/game", { friends: id });
|
|
51
|
+
friendsStatus.textContent = "Saved.";
|
|
52
|
+
draw();
|
|
53
|
+
onChange?.(state);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
friendsStatus.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Nothing was changed.";
|
|
56
|
+
} finally {
|
|
57
|
+
for (const key of friendsKeys.values()) key.disabled = false;
|
|
58
|
+
}
|
|
31
59
|
};
|
|
32
60
|
|
|
33
61
|
const press = async (id: SwitchId) => {
|
|
@@ -62,6 +90,24 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
|
|
|
62
90
|
// The sign-in word is part of the listing file: Nanoterra's review decides what Nanoterra shows. This page cannot see that review.
|
|
63
91
|
if (id === "signin") card.append(h("p", { class: "fine switch-review" }, REVIEW_NOTE, " ", h("a", { href: REVIEW_LINK, target: "_blank", rel: "noopener noreferrer" }, "Open the launch page")));
|
|
64
92
|
}
|
|
93
|
+
|
|
94
|
+
const order = Object.keys(FRIENDS_CHOICES) as Friends[];
|
|
95
|
+
const group = h("div", { class: "seg friends-choice", role: "radiogroup", "aria-labelledby": "switch-friends-label", "aria-describedby": "switch-friends-note" });
|
|
96
|
+
for (const id of order) {
|
|
97
|
+
const key = h("button", { class: "seg-btn", type: "button", role: "radio", "aria-checked": "false", "data-friends": id }, FRIENDS_CHOICES[id].name);
|
|
98
|
+
key.addEventListener("click", () => void choose(id));
|
|
99
|
+
key.addEventListener("keydown", (e) => {
|
|
100
|
+
const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
|
|
101
|
+
if (!step) return;
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
const next = order[(order.indexOf(id) + step + order.length) % order.length];
|
|
104
|
+
friendsKeys.get(next)!.focus();
|
|
105
|
+
void choose(next);
|
|
106
|
+
});
|
|
107
|
+
friendsKeys.set(id, key);
|
|
108
|
+
group.append(key);
|
|
109
|
+
}
|
|
110
|
+
card.append(h("div", { class: "switch-row friends-row" }, h("div", { class: "switch-head" }, h("span", { class: "switch-title", id: "switch-friends-label" }, FRIENDS_TITLE), group), friendsNote, friendsStatus));
|
|
65
111
|
draw();
|
|
66
112
|
return card;
|
|
67
113
|
}
|
package/src/admin/panel/mount.ts
CHANGED
|
@@ -69,7 +69,7 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
|
|
|
69
69
|
let gameState: GameState | null = null;
|
|
70
70
|
let dash: Dashboard | null = null;
|
|
71
71
|
|
|
72
|
-
/** The card, made once. A fault in reading the
|
|
72
|
+
/** The card, made once. A fault in reading the game switches must not hide the orders: the card is left out and the rest still shows. */
|
|
73
73
|
async function gameSection(): Promise<HTMLElement | null> {
|
|
74
74
|
if (game) return game;
|
|
75
75
|
try {
|
|
@@ -156,8 +156,8 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
|
|
|
156
156
|
return shelf.el;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
/**
|
|
160
|
-
const colourOf = (id: string): string | null => options.looks.find((l) => l.id === id)?.change
|
|
159
|
+
/** An outfit's body colour, while the game still has the look (for the dots in the lists); none for an item of another wear slot. */
|
|
160
|
+
const colourOf = (id: string): string | null => options.looks.find((l) => l.id === id)?.change?.body ?? null;
|
|
161
161
|
|
|
162
162
|
function build(): Dashboard {
|
|
163
163
|
const tabs = tabBar("overview");
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import type { Look } from "../../looks/look.js";
|
|
5
5
|
import { priceText } from "../../looks/ladder.js";
|
|
6
6
|
import type { AdminSummary } from "../store.js";
|
|
7
|
-
import { SWITCHES, stateOf, type GameState } from "../switches.js";
|
|
7
|
+
import { FRIENDS_CHOICES, FRIENDS_TITLE, SWITCHES, stateOf, type GameState } from "../switches.js";
|
|
8
8
|
import { dollars, h, plural } from "./dom.js";
|
|
9
9
|
import { icon } from "./icons.js";
|
|
10
10
|
import { orderRow, type OrderFilter } from "./orders.js";
|
|
@@ -29,7 +29,7 @@ export function bestSellers(s: Pick<AdminSummary, "orders" | "players">, looks:
|
|
|
29
29
|
export type OverviewView = {
|
|
30
30
|
el: HTMLElement;
|
|
31
31
|
update(s: AdminSummary): void;
|
|
32
|
-
/** The
|
|
32
|
+
/** The game switches as they are now (null: they could not be read). */
|
|
33
33
|
setGame(state: GameState | null): void;
|
|
34
34
|
};
|
|
35
35
|
|
|
@@ -51,9 +51,10 @@ export function overviewView(looks: readonly Look[], colourOf: (lookId: string)
|
|
|
51
51
|
const on = game ? stateOf(id, game) : null;
|
|
52
52
|
return h("li", {}, h("span", {}, SWITCHES[id].title), on === null ? h("span", { class: "pill" }, "Unknown") : h("span", { class: `pill ${on ? "pill-on" : "pill-off"}` }, on ? "On" : "Off"));
|
|
53
53
|
});
|
|
54
|
+
if (game) rows.push(h("li", {}, h("span", {}, FRIENDS_TITLE), h("span", { class: `pill ${game.friends === "off" ? "pill-off" : "pill-on"}` }, FRIENDS_CHOICES[game.friends]?.name ?? "Unknown")));
|
|
54
55
|
gameBox.replaceChildren(
|
|
55
56
|
h("div", { class: "card-head" }, h("h2", { id: "n133-glance-game" }, "Game switches"), linkKey("Change", () => go("settings"))),
|
|
56
|
-
game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The
|
|
57
|
+
game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The game switches could not be read. Reload the page to try again."),
|
|
57
58
|
);
|
|
58
59
|
}
|
|
59
60
|
|