@nano133/nanoterra 0.1.0 → 0.3.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 +88 -0
- package/README.md +269 -12
- package/ai/SPEC.md +7 -2
- 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 +24 -0
- package/dist/admin/panel/shelf.js +271 -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/character/bases/bee.js +7 -5
- package/dist/character/bases/spec.d.ts +7 -0
- package/dist/character/create.d.ts +15 -0
- package/dist/character/create.js +69 -2
- package/dist/character/ctx.d.ts +12 -2
- package/dist/character/index.d.ts +2 -0
- package/dist/character/index.js +2 -0
- package/dist/character/make.js +2 -1
- package/dist/character/preview.d.ts +32 -0
- package/dist/character/preview.js +112 -0
- package/dist/character/sockets.d.ts +37 -0
- package/dist/character/sockets.js +66 -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/dist/model/check.d.ts +59 -0
- package/dist/model/check.js +681 -0
- package/dist/model/index.d.ts +2 -0
- package/dist/model/index.js +5 -0
- package/dist/model/limits.d.ts +103 -0
- package/dist/model/limits.js +132 -0
- package/package.json +11 -3
- 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 +361 -0
- package/src/admin/panel/shelfData.ts +87 -0
- package/src/admin/panel/signins.ts +68 -0
- package/src/admin/panel/style.css +551 -0
- package/src/admin/store.ts +98 -0
- package/src/admin/summary.ts +103 -0
- package/src/admin/switches.ts +74 -0
- package/src/character/bases/bee.ts +7 -5
- package/src/character/bases/spec.ts +4 -0
- package/src/character/create.ts +73 -2
- package/src/character/ctx.ts +17 -2
- package/src/character/index.ts +2 -0
- package/src/character/make.ts +2 -1
- package/src/character/preview.ts +148 -0
- package/src/character/sockets.ts +81 -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
- package/src/model/check.ts +624 -0
- package/src/model/index.ts +5 -0
- package/src/model/limits.ts +193 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { LookRecord } from "./record.js";
|
|
3
|
+
/** What draws one look: the game's own character dressed in it (its hat, its models), or by default the package's character. */
|
|
4
|
+
export type PreviewBody<L> = {
|
|
5
|
+
object: THREE.Object3D;
|
|
6
|
+
/** Puts the look on. A promise waits (a model that loads). */
|
|
7
|
+
dress(look: L): void | Promise<void>;
|
|
8
|
+
/** Frees what the body made. */
|
|
9
|
+
dispose(): void;
|
|
10
|
+
};
|
|
11
|
+
export type LookPreviewOptions<L> = {
|
|
12
|
+
/** The picture's side in CSS pixels (default 112); it is drawn at twice that for a sharp screen. */
|
|
13
|
+
size?: number;
|
|
14
|
+
/** A body for the next picture. Left out: the package's character in `record` (else the island's first look), its shirt in the look's body colour. */
|
|
15
|
+
body?: () => PreviewBody<L>;
|
|
16
|
+
/** The record of the default body. */
|
|
17
|
+
record?: LookRecord;
|
|
18
|
+
/** The background (default: none, a clear picture). */
|
|
19
|
+
background?: THREE.ColorRepresentation | null;
|
|
20
|
+
/** The key of a look in the cache (default: its JSON text). */
|
|
21
|
+
key?: (look: L) => string;
|
|
22
|
+
};
|
|
23
|
+
export type LookPreview<L> = {
|
|
24
|
+
/** A PNG data URL of the look on the character, or null when the page has no WebGL or the drawing failed. */
|
|
25
|
+
render(look: L): Promise<string | null>;
|
|
26
|
+
/** How many pictures are kept. */
|
|
27
|
+
readonly cached: number;
|
|
28
|
+
/** Frees the renderer (and its WebGL context) and the cache. `render` answers null after it. */
|
|
29
|
+
dispose(): void;
|
|
30
|
+
};
|
|
31
|
+
/** A picture maker. Make one for a page and keep it: it holds one WebGL context while it lives. */
|
|
32
|
+
export declare function createLookPreview<L>(options?: LookPreviewOptions<L>): LookPreview<L>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { createCharacter } from "./create.js";
|
|
3
|
+
function defaultBody(record) {
|
|
4
|
+
return () => {
|
|
5
|
+
const c = createCharacter({ record, reducedMotion: true });
|
|
6
|
+
return {
|
|
7
|
+
object: c.object,
|
|
8
|
+
dress(look) {
|
|
9
|
+
const body = look.change?.body;
|
|
10
|
+
if (body && (look.priceUsd ?? 1) > 0)
|
|
11
|
+
c.setColors({ shirt: body });
|
|
12
|
+
},
|
|
13
|
+
dispose: () => c.dispose(),
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** A picture maker. Make one for a page and keep it: it holds one WebGL context while it lives. */
|
|
18
|
+
export function createLookPreview(options = {}) {
|
|
19
|
+
const size = options.size ?? 112;
|
|
20
|
+
const makeBody = options.body ?? defaultBody(options.record);
|
|
21
|
+
const keyOf = options.key ?? ((l) => JSON.stringify(l));
|
|
22
|
+
const cache = new Map();
|
|
23
|
+
let renderer = null;
|
|
24
|
+
let failed = false;
|
|
25
|
+
let disposed = false;
|
|
26
|
+
let queue = Promise.resolve();
|
|
27
|
+
const scene = new THREE.Scene();
|
|
28
|
+
if (options.background != null)
|
|
29
|
+
scene.background = new THREE.Color(options.background);
|
|
30
|
+
scene.add(new THREE.HemisphereLight("#ffffff", "#8a8f9e", 1.6));
|
|
31
|
+
const sun = new THREE.DirectionalLight("#ffffff", 1.6);
|
|
32
|
+
sun.position.set(2, 4, 5);
|
|
33
|
+
scene.add(sun);
|
|
34
|
+
const camera = new THREE.PerspectiveCamera(30, 1, 0.05, 50);
|
|
35
|
+
function getRenderer() {
|
|
36
|
+
if (renderer || failed)
|
|
37
|
+
return renderer;
|
|
38
|
+
try {
|
|
39
|
+
// three makes its own canvas (in a page); with no page or no WebGL this throws, and the answer is null.
|
|
40
|
+
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
|
|
41
|
+
renderer.setPixelRatio(1);
|
|
42
|
+
renderer.setSize(size * 2, size * 2, false);
|
|
43
|
+
renderer.setClearColor(0x000000, 0);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
failed = true;
|
|
47
|
+
renderer = null;
|
|
48
|
+
}
|
|
49
|
+
return renderer;
|
|
50
|
+
}
|
|
51
|
+
async function draw(look) {
|
|
52
|
+
if (disposed)
|
|
53
|
+
return null;
|
|
54
|
+
const r = getRenderer();
|
|
55
|
+
if (!r)
|
|
56
|
+
return null;
|
|
57
|
+
const body = makeBody();
|
|
58
|
+
try {
|
|
59
|
+
await body.dress(look);
|
|
60
|
+
if (disposed)
|
|
61
|
+
return null;
|
|
62
|
+
scene.add(body.object);
|
|
63
|
+
// Frame the whole character (with what it holds) from the front, a little from its right and above.
|
|
64
|
+
body.object.updateMatrixWorld(true);
|
|
65
|
+
const box = new THREE.Box3().setFromObject(body.object);
|
|
66
|
+
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
|
67
|
+
const dir = new THREE.Vector3(-0.6, 0.15, 1).normalize();
|
|
68
|
+
const dist = sphere.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.02;
|
|
69
|
+
camera.position.copy(sphere.center).addScaledVector(dir, dist);
|
|
70
|
+
camera.lookAt(sphere.center);
|
|
71
|
+
camera.near = Math.max(0.01, dist - sphere.radius * 2);
|
|
72
|
+
camera.far = dist + sphere.radius * 2;
|
|
73
|
+
camera.updateProjectionMatrix();
|
|
74
|
+
r.render(scene, camera);
|
|
75
|
+
return r.domElement.toDataURL("image/png");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
scene.remove(body.object);
|
|
82
|
+
body.dispose();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
render(look) {
|
|
87
|
+
if (disposed)
|
|
88
|
+
return Promise.resolve(null);
|
|
89
|
+
const key = keyOf(look);
|
|
90
|
+
const hit = cache.get(key);
|
|
91
|
+
if (hit)
|
|
92
|
+
return hit;
|
|
93
|
+
// One at a time: the renderer and the scene are shared.
|
|
94
|
+
const p = queue.then(() => draw(look));
|
|
95
|
+
queue = p.catch(() => null);
|
|
96
|
+
cache.set(key, p);
|
|
97
|
+
return p;
|
|
98
|
+
},
|
|
99
|
+
get cached() {
|
|
100
|
+
return cache.size;
|
|
101
|
+
},
|
|
102
|
+
dispose() {
|
|
103
|
+
if (disposed)
|
|
104
|
+
return;
|
|
105
|
+
disposed = true;
|
|
106
|
+
cache.clear();
|
|
107
|
+
renderer?.dispose();
|
|
108
|
+
renderer?.forceContextLoss();
|
|
109
|
+
renderer = null;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { BaseSpec } from "./bases/spec.js";
|
|
3
|
+
import { BUILD_FACTORS } from "./make.js";
|
|
4
|
+
import type { BoneName } from "./skeleton.js";
|
|
5
|
+
/** The names of the sockets. */
|
|
6
|
+
export declare const SOCKETS: readonly ["head", "hand", "back"];
|
|
7
|
+
export type SocketName = (typeof SOCKETS)[number];
|
|
8
|
+
/** Where a socket sits: its bone, and its place, turn and scale in that bone's space (the units of the character's own space). */
|
|
9
|
+
export type SocketFrame = {
|
|
10
|
+
bone: BoneName;
|
|
11
|
+
position: THREE.Vector3;
|
|
12
|
+
quaternion: THREE.Quaternion;
|
|
13
|
+
scale: THREE.Vector3;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The hand's hold: a hand item is modelled standing up (+Y), its grip at the origin, its front at +Z. The hand holds it pointing forward,
|
|
17
|
+
* as a torch is held out: the item's +Y along the character's forward (+Z), its front (+Z) up, its +X to the character's right (-X).
|
|
18
|
+
*/
|
|
19
|
+
export declare const HAND_HOLD: Readonly<THREE.Quaternion>;
|
|
20
|
+
/** What `socketFrame` reads of a look: its build and its head's size. */
|
|
21
|
+
export type SocketLook = {
|
|
22
|
+
build: keyof typeof BUILD_FACTORS;
|
|
23
|
+
head: number;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* A socket's frame on a base for a look.
|
|
27
|
+
* - "head": on the head bone, at the base's hat frame (`spec.hat`), scaled by the hat frame and the head's size: a thing made for the
|
|
28
|
+
* Classic's head, round the middle of the head (the game starter's hats are made so), fits every head.
|
|
29
|
+
* - "hand": on grip_R, in the middle of the right hand (the hands of a wide body are drawn further out than their bone), turned by
|
|
30
|
+
* `HAND_HOLD`.
|
|
31
|
+
* - "back": on the chest bone, on the back of the torso (`spec.back`, else the chest's height and the torso's radius there); its axes are the
|
|
32
|
+
* character's, so a back item is modelled with the side that touches the body at the origin and its bulk towards -Z.
|
|
33
|
+
*/
|
|
34
|
+
export declare function socketFrame(spec: BaseSpec, look: SocketLook, name: SocketName): SocketFrame;
|
|
35
|
+
/** Puts a socket object on its bone (from `bones`) at its frame. The object keeps its children: a hung item moves with it. */
|
|
36
|
+
export declare function placeSocket(socket: THREE.Object3D, bones: Partial<Record<BoneName, THREE.Object3D>>, spec: BaseSpec, look: SocketLook, name: SocketName): THREE.Object3D<THREE.Object3DEventMap>;
|
|
37
|
+
export declare const isSocketName: (v: unknown) => v is SocketName;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { BUILD_FACTORS } from "./make.js";
|
|
3
|
+
// The character's three attach points (sockets): the head, the right hand and the back. A socket is a THREE.Object3D on one bone of the
|
|
4
|
+
// island's skeleton, so a thing hung on it follows the walk, the run, the jump and the wave, and costs its own draws (a model of the game's,
|
|
5
|
+
// a .glb say). A worn thing that is merged into the body's geometry (ctx.ts `WornAdapter.add`) adds no draw and needs no socket.
|
|
6
|
+
//
|
|
7
|
+
// Each socket's place depends on the base and on the look (the build widens the body and moves the hands out; the head's size scales the
|
|
8
|
+
// head). `socketFrame` works it out; `createCharacter` keeps its sockets on the new bones after a rebuild (a new base, hair or clothes).
|
|
9
|
+
/** The names of the sockets. */
|
|
10
|
+
export const SOCKETS = ["head", "hand", "back"];
|
|
11
|
+
/** The head bone's rest height. */
|
|
12
|
+
const HEAD_BONE_Y = 1.5;
|
|
13
|
+
/** The chest bone's rest height. */
|
|
14
|
+
const CHEST_BONE_Y = 1.12;
|
|
15
|
+
/**
|
|
16
|
+
* The hand's hold: a hand item is modelled standing up (+Y), its grip at the origin, its front at +Z. The hand holds it pointing forward,
|
|
17
|
+
* as a torch is held out: the item's +Y along the character's forward (+Z), its front (+Z) up, its +X to the character's right (-X).
|
|
18
|
+
*/
|
|
19
|
+
export const HAND_HOLD = Object.freeze(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 1).normalize(), Math.PI));
|
|
20
|
+
/**
|
|
21
|
+
* A socket's frame on a base for a look.
|
|
22
|
+
* - "head": on the head bone, at the base's hat frame (`spec.hat`), scaled by the hat frame and the head's size: a thing made for the
|
|
23
|
+
* Classic's head, round the middle of the head (the game starter's hats are made so), fits every head.
|
|
24
|
+
* - "hand": on grip_R, in the middle of the right hand (the hands of a wide body are drawn further out than their bone), turned by
|
|
25
|
+
* `HAND_HOLD`.
|
|
26
|
+
* - "back": on the chest bone, on the back of the torso (`spec.back`, else the chest's height and the torso's radius there); its axes are the
|
|
27
|
+
* character's, so a back item is modelled with the side that touches the body at the origin and its bulk towards -Z.
|
|
28
|
+
*/
|
|
29
|
+
export function socketFrame(spec, look, name) {
|
|
30
|
+
const t = BUILD_FACTORS[look.build] ?? BUILD_FACTORS.regular;
|
|
31
|
+
const hs = look.head;
|
|
32
|
+
if (name === "head") {
|
|
33
|
+
const h = spec.hat;
|
|
34
|
+
return {
|
|
35
|
+
bone: "head",
|
|
36
|
+
position: new THREE.Vector3(0, (h.y - HEAD_BONE_Y) * hs, (h.z ?? 0) * hs),
|
|
37
|
+
quaternion: new THREE.Quaternion(),
|
|
38
|
+
scale: new THREE.Vector3(h.s[0] * hs, h.s[1] * hs, h.s[2] * hs),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (name === "hand") {
|
|
42
|
+
// limbs.ts draws the hand at 0.47 + 0.45 (torso - 1) + armOut from the middle; the bone stays at 0.47.
|
|
43
|
+
const out = 0.45 * (t.torso - 1) + (spec.limbs.armOut ?? 0);
|
|
44
|
+
const at = spec.handAt ?? [0, 0, 0];
|
|
45
|
+
return { bone: "grip_R", position: new THREE.Vector3(-out + at[0], at[1], at[2]), quaternion: HAND_HOLD.clone(), scale: new THREE.Vector3(1, 1, 1) };
|
|
46
|
+
}
|
|
47
|
+
if (name === "back") {
|
|
48
|
+
const y = spec.back?.y ?? CHEST_BONE_Y;
|
|
49
|
+
const z = spec.back?.z ?? -spec.torsoR(y) * t.torso;
|
|
50
|
+
return { bone: "chest", position: new THREE.Vector3(0, y - CHEST_BONE_Y, z), quaternion: new THREE.Quaternion(), scale: new THREE.Vector3(1, 1, 1) };
|
|
51
|
+
}
|
|
52
|
+
throw new TypeError(`@nano133/nanoterra: there is no socket ${JSON.stringify(name)} (the sockets are ${SOCKETS.join(", ")})`);
|
|
53
|
+
}
|
|
54
|
+
/** Puts a socket object on its bone (from `bones`) at its frame. The object keeps its children: a hung item moves with it. */
|
|
55
|
+
export function placeSocket(socket, bones, spec, look, name) {
|
|
56
|
+
const f = socketFrame(spec, look, name);
|
|
57
|
+
const bone = bones[f.bone];
|
|
58
|
+
if (!bone)
|
|
59
|
+
throw new TypeError(`@nano133/nanoterra: the skeleton has no bone ${f.bone} for the socket ${name}`);
|
|
60
|
+
socket.position.copy(f.position);
|
|
61
|
+
socket.quaternion.copy(f.quaternion);
|
|
62
|
+
socket.scale.copy(f.scale);
|
|
63
|
+
bone.add(socket);
|
|
64
|
+
return socket;
|
|
65
|
+
}
|
|
66
|
+
export const isSocketName = (v) => typeof v === "string" && SOCKETS.includes(v);
|
package/dist/looks/change.d.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import type
|
|
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
|
|
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
|
|
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;
|
package/dist/looks/change.js
CHANGED
|
@@ -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
|
|
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 (
|
|
25
|
+
if (!own.has(l.id))
|
|
12
26
|
continue;
|
|
13
|
-
|
|
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
|
}
|
package/dist/looks/index.d.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/dist/looks/index.js
CHANGED
|
@@ -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";
|
package/dist/looks/json.d.ts
CHANGED
|
@@ -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
|
|
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"). */
|
package/dist/looks/json.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/looks/look.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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(
|
|
60
|
+
export declare function checkLooks(rawInput: unknown): Look[];
|
|
35
61
|
/** "$0.25" */
|
|
36
62
|
export declare const usdText: (usd: number) => string;
|
package/dist/looks/look.js
CHANGED
|
@@ -4,11 +4,22 @@
|
|
|
4
4
|
export const HATS = ["cap", "beanie", "crown", "tophat"];
|
|
5
5
|
export const ID_PATTERN = /^[a-z0-9-]{1,40}$/;
|
|
6
6
|
export const COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
7
|
+
/** At most this many slots on a look. */
|
|
8
|
+
export const SLOTS_MAX = 8;
|
|
9
|
+
/** A slot's name: 1 to 24 lowercase letters, digits or dashes, starting with a letter. */
|
|
10
|
+
export const SLOT_NAME_PATTERN = /^[a-z][a-z0-9-]{0,23}$/;
|
|
11
|
+
/** Names that a slot may not have: they mean something to an object (`constructor`, `prototype`). */
|
|
12
|
+
export const RESERVED_SLOT_NAMES = ["constructor", "prototype"];
|
|
13
|
+
/** 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.) */
|
|
14
|
+
export const SLOT_WORD_PATTERN = /^[a-z0-9-]{1,40}$/;
|
|
7
15
|
export const NAME_MAX = 40;
|
|
8
16
|
export const PRICE_MAX_USD = 1000;
|
|
9
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* 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
|
|
19
|
+
* and 1e-9 would pass): a price is whole cents when it is exactly the number that its own two-decimal text names.
|
|
20
|
+
*/
|
|
10
21
|
export function wholeCents(usd) {
|
|
11
|
-
return
|
|
22
|
+
return typeof usd === "number" && Number.isFinite(usd) && Number(usd.toFixed(2)) === usd;
|
|
12
23
|
}
|
|
13
24
|
/** 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. */
|
|
14
25
|
export function lookProblems(raw) {
|
|
@@ -41,17 +52,62 @@ export function lookProblems(raw) {
|
|
|
41
52
|
add("change.hat", `"change.hat" must be one of ${HATS.join(", ")}`);
|
|
42
53
|
if (v.change?.hatColor !== undefined && (typeof v.change.hatColor !== "string" || !COLOR_PATTERN.test(v.change.hatColor)))
|
|
43
54
|
add("change.hatColor", '"change.hatColor" must be a colour like "#1c6e4f"');
|
|
55
|
+
if (v.slots !== undefined)
|
|
56
|
+
slotProblems(v.slots).forEach((s) => add(s.field, s.message));
|
|
44
57
|
});
|
|
45
58
|
if (!out.length && !free)
|
|
46
59
|
out.push({ look: null, field: "free", message: "looks.json needs at least one free look (priceUsd 0): every player starts with it" });
|
|
47
60
|
return out;
|
|
48
61
|
}
|
|
62
|
+
/** Whether `v` is a plain object (not null, not a list, not a class instance). */
|
|
63
|
+
const isPlain = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
64
|
+
/** Whether a slot's value is good: a word or a colour. */
|
|
65
|
+
export const slotValueOk = (v) => typeof v === "string" && (SLOT_WORD_PATTERN.test(v) || COLOR_PATTERN.test(v));
|
|
66
|
+
/** The mistakes in one look's `slots` (the look's own message is added by the caller): the field is "slots" or "slots.<name>". */
|
|
67
|
+
function slotProblems(slots) {
|
|
68
|
+
if (!isPlain(slots))
|
|
69
|
+
return [{ field: "slots", message: '"slots" must be an object of names and values, like { "wings": "ember" }' }];
|
|
70
|
+
const names = Object.keys(slots);
|
|
71
|
+
const out = [];
|
|
72
|
+
if (names.length > SLOTS_MAX)
|
|
73
|
+
out.push({ field: "slots", message: `"slots" can have at most ${SLOTS_MAX} slots (this one has ${names.length})` });
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
if (!SLOT_NAME_PATTERN.test(name))
|
|
76
|
+
out.push({ field: `slots.${name}`, message: `the slot name "${name}" must be 1 to 24 lowercase letters, digits or dashes, starting with a letter` });
|
|
77
|
+
else if (RESERVED_SLOT_NAMES.includes(name))
|
|
78
|
+
out.push({ field: `slots.${name}`, message: `the slot name "${name}" is reserved: choose another` });
|
|
79
|
+
else if (!slotValueOk(slots[name]))
|
|
80
|
+
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"` });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 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
|
|
86
|
+
* ("#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.
|
|
87
|
+
*/
|
|
88
|
+
export function cleanSlots(slots) {
|
|
89
|
+
if (!slots)
|
|
90
|
+
return undefined;
|
|
91
|
+
const names = Object.keys(slots);
|
|
92
|
+
return names.length ? Object.fromEntries(names.map((n) => [n, typeof slots[n] === "string" && COLOR_PATTERN.test(slots[n]) ? slots[n].toLowerCase() : slots[n]])) : undefined;
|
|
93
|
+
}
|
|
49
94
|
/** The looks, or an Error that says the first mistake in plain words. The same words as the starter's `checkLooks`. */
|
|
50
|
-
export function checkLooks(
|
|
95
|
+
export function checkLooks(rawInput) {
|
|
96
|
+
// 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.)
|
|
97
|
+
let raw = rawInput;
|
|
98
|
+
try {
|
|
99
|
+
raw = rawInput === undefined ? undefined : JSON.parse(JSON.stringify(rawInput));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new Error("looks.json must be plain JSON data");
|
|
103
|
+
}
|
|
51
104
|
const first = lookProblems(raw)[0];
|
|
52
105
|
if (first)
|
|
53
106
|
throw new Error(first.message);
|
|
54
|
-
return raw.map((l) =>
|
|
107
|
+
return raw.map((l) => {
|
|
108
|
+
const slots = cleanSlots(l.slots);
|
|
109
|
+
return { id: l.id, name: l.name, priceUsd: l.priceUsd, change: { body: l.change.body, hat: l.change.hat, hatColor: l.change.hatColor }, ...(slots ? { slots } : {}) };
|
|
110
|
+
});
|
|
55
111
|
}
|
|
56
112
|
/** "$0.25" */
|
|
57
113
|
export const usdText = (usd) => `$${usd.toFixed(2)}`;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ModelLimits } from "./limits.js";
|
|
2
|
+
export type ModelRule = "file" | "size" | "embedded" | "extensions" | "lights" | "motion" | "triangles" | "textures" | "draws" | "zone" | "markers" | "time";
|
|
3
|
+
/** One thing wrong with the model: `fix` is one plain sentence or two, for the creator. */
|
|
4
|
+
export type ModelProblem = {
|
|
5
|
+
rule: ModelRule;
|
|
6
|
+
fix: string;
|
|
7
|
+
};
|
|
8
|
+
export type Vec3 = [number, number, number];
|
|
9
|
+
export type TextureFact = {
|
|
10
|
+
type: "image/png" | "image/jpeg" | "image/webp" | "image/ktx2";
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
bytes: number;
|
|
14
|
+
};
|
|
15
|
+
export type ModelFacts = {
|
|
16
|
+
bytes: number;
|
|
17
|
+
/** Triangles drawn (every instance counted; the `mesh` markers, as `nano_collide`, left out). Null when not counted. */
|
|
18
|
+
triangles: number | null;
|
|
19
|
+
textures: TextureFact[];
|
|
20
|
+
/**
|
|
21
|
+
* Draws: each part (glTF primitive) of each drawn mesh instance is one draw call, the `mesh` markers left out. Null when not counted.
|
|
22
|
+
* (A game may join parts or draw an ink outline; this counts the file as it is.)
|
|
23
|
+
*/
|
|
24
|
+
draws: number | null;
|
|
25
|
+
/** The materials that the drawn parts use (a part with no material uses the default one, which counts once). */
|
|
26
|
+
materials: number;
|
|
27
|
+
/**
|
|
28
|
+
* The model's box in its own axes (metres), every drawn mesh and every `mesh` marker in it. Null when not measured. When the limits name an
|
|
29
|
+
* `anchor` and the file has it, the box, the reach and the zone are measured from the anchor's point (`anchor`), not the file's origin.
|
|
30
|
+
*/
|
|
31
|
+
bounds: {
|
|
32
|
+
min: Vec3;
|
|
33
|
+
max: Vec3;
|
|
34
|
+
} | null;
|
|
35
|
+
/** The farthest drawn point from the y axis (x and z together, metres), the `mesh` markers in it. Null when not measured. */
|
|
36
|
+
reach: number | null;
|
|
37
|
+
/** True: the box and the reach come from every vertex. False: past the work cap, from each instance's box (larger, never smaller). */
|
|
38
|
+
exactBounds: boolean;
|
|
39
|
+
/** Where each marker that the file has stands (its node's origin, in the model's axes). */
|
|
40
|
+
markers: Record<string, Vec3>;
|
|
41
|
+
/** The anchor's point in the file's axes (the limits' `anchor` marker), or null: the file's origin is the attach point. */
|
|
42
|
+
anchor: Vec3 | null;
|
|
43
|
+
nodes: number;
|
|
44
|
+
meshes: number;
|
|
45
|
+
extensions: string[];
|
|
46
|
+
};
|
|
47
|
+
/** `advice`: plain lines that would make the model lighter (never a failure: `ok` does not read them). */
|
|
48
|
+
export type ModelCheck = {
|
|
49
|
+
ok: boolean;
|
|
50
|
+
problems: ModelProblem[];
|
|
51
|
+
facts: ModelFacts;
|
|
52
|
+
advice: string[];
|
|
53
|
+
};
|
|
54
|
+
/** The picture's type and size from its bytes (never from the file's word for it), or null. */
|
|
55
|
+
export declare function sniffTexture(b: Uint8Array): Omit<TextureFact, "bytes"> | null;
|
|
56
|
+
/** Checks a .glb against `limits`. Never throws: a file that cannot be read answers a problem. */
|
|
57
|
+
export declare function checkModel(input: Uint8Array, limits: ModelLimits): ModelCheck;
|
|
58
|
+
/** One short line of what was measured: "12,000 triangles, 3 textures, 1.2 MB, 20.0 x 15.0 x 10.0 m". */
|
|
59
|
+
export declare function modelSummary(f: ModelFacts): string;
|